Compare commits

..

104 Commits

Author SHA1 Message Date
lukas ce3c4b55f0 Order devices alphabetically on new session form
continuous-integration/drone/push Build is passing
2023-11-09 10:09:32 +01:00
lukas c52cd822ae Use safe_division in more places 2023-11-09 10:06:14 +01:00
lukas cdc6ca1324 Fix potential division by zero
continuous-integration/drone/push Build is passing
2023-11-09 09:18:49 +01:00
lukas e7ed349356 Add more stats
continuous-integration/drone/push Build is passing
* Finished (count)
* Unfinished (count)
* Refunded (count)
2023-11-08 18:13:48 +01:00
lukas 5052ca7dbf Add more stats
continuous-integration/drone/push Build is passing
* All finished games
* All finished 2023 games
* All finished games that were purchased this year
* Total sessions
* Days played
2023-11-08 16:24:22 +01:00
lukas f408bfd927 stats: change overall stats table layout 2023-11-08 15:48:06 +01:00
lukas 666dee33ba Model changes
continuous-integration/drone/push Build is passing
* More fields are now optional. This is to make it easier to add new items in bulk.
  * Game: Wikidata ID
  * Edition: Platform, Year
  * Purchase: Platform
  * Platform: Group
  * Session: Device
* New fields:
  * Game: Year Released
    * To record original year of release
    * Upon migration, this will be set to a year of any of the game's edition that has it set
  * Purchase: Date Finished
* Editions are now unique combination of name and platform
2023-11-06 19:48:12 +01:00
lukas e0b09e051a simplify playtime range display 2023-11-06 12:05:39 +01:00
lukas 4552cf7616 Version 1.3.0
continuous-integration/drone/push Build is passing
2023-11-05 15:10:56 +01:00
lukas a614b51d29 Make some pages redirect back instead to session list 2023-11-05 15:09:51 +01:00
lukas e67aa3fda1 Add more stats
continuous-integration/drone/push Build is passing
2023-11-02 20:12:32 +01:00
lukas 8423fd02b4 Extend stats range to 2018
continuous-integration/drone/push Build is passing
2023-11-02 15:32:57 +01:00
lukas 2bd07e5f2d Remove cruft
continuous-integration/drone/push Build is passing
2023-11-02 15:14:57 +01:00
lukas 058b83522c Group by game instead of purchase 2023-11-02 15:14:50 +01:00
lukas f13ed8a078 Reorder imports in views.py
continuous-integration/drone/push Build is passing
2023-11-02 09:53:28 +01:00
lukas 02d5adcb3c Remove hardcoded year 2023-11-02 09:52:59 +01:00
lukas d6fb16bb74 Make navigation more compact 2023-11-02 09:52:42 +01:00
lukas 71b90b8202 Add stats link, year selector 2023-11-02 09:20:09 +01:00
lukas 3ee36932c3 Limit stats of single year correctly 2023-11-02 09:17:08 +01:00
lukas 391fcc79a8 Version 1.2.0
continuous-integration/drone/push Build is passing
2023-11-01 20:35:58 +01:00
lukas 57d4fd7212 Add yearly stats page
Fixes #15
2023-11-01 20:35:52 +01:00
lukas a5b2854bf6 Add a button to start session from game overview
continuous-integration/drone/push Build is passing
Fix #62
2023-10-13 19:22:43 +02:00
lukas 518c0ecd56 Add more time tests for fractional numbers
continuous-integration/drone/push Build is passing
2023-10-13 17:01:33 +02:00
lukas a6cd7a3430 Do not format as float if no precision specified
continuous-integration/drone/push Build is passing
2023-10-13 16:58:12 +02:00
lukas dba8414fd9 Version 1.1.2
continuous-integration/drone/push Build is failing
2023-10-13 16:33:55 +02:00
lukas 0e2113eefd Display durations in a consistent manner
Fixes #61
2023-10-13 16:32:12 +02:00
lukas c4b0347f3b Version 1.1.1
continuous-integration/drone/push Build is passing
2023-10-09 20:56:23 +02:00
lukas c6ed21167c Remove debugging cruft from container 2023-10-09 20:56:13 +02:00
lukas 4ce15c44fc Add edit buttons to game overview, notes 2023-10-09 20:55:31 +02:00
lukas c814b4c2cb Version 1.1.0
continuous-integration/drone/push Build is passing
2023-10-09 00:04:46 +02:00
lukas 11b9c602de Improve game overview
- add counts for each section
- add average hours per session
2023-10-09 00:00:45 +02:00
lukas 9a332593f4 Fix playtime range not working with manual session
continuous-integration/drone/push Build is passing
Fixes #55
2023-10-08 21:13:32 +02:00
lukas 22935721ca Remove unused chart functionality
continuous-integration/drone/push Build is passing
2023-10-08 21:08:03 +02:00
lukas a2ecdcf44a ci: automatically redeploy container
continuous-integration/drone/push Build was killed
2023-10-04 20:21:00 +02:00
lukas 3c958c4a13 Organize changelog better
continuous-integration/drone/push Build is passing
2023-10-04 18:14:21 +02:00
lukas 3db1724e22 Fix session being wrongly considered in progress
continuous-integration/drone/push Build is passing
Fixes #58
2023-10-04 18:12:13 +02:00
lukas d2a9630b04 Fix date range on game overview
continuous-integration/drone/push Build is passing
2023-10-01 21:51:32 +02:00
lukas e3ee832d3f Adjust game edit URL
continuous-integration/drone/push Build is passing
2023-10-01 21:28:20 +02:00
lukas 7467e2732d Add game overview page
Fixes #8
2023-10-01 21:28:02 +02:00
lukas 787ee8640f Try to shutdown container gracefully and faster
continuous-integration/drone/push Build is passing
2023-10-01 19:57:15 +02:00
lukas ab41222f3c Switch fonts to WOFF2 2023-10-01 19:53:07 +02:00
lukas 29bf3b1946 Further improve session list
continuous-integration/drone/push Build is passing
2023-09-30 19:44:35 +02:00
lukas 3f7ccea2e2 fix tailwind config to only scan relevant files 2023-09-30 18:13:50 +02:00
lukas b5ffb3586b Update base.css 2023-09-30 16:25:19 +02:00
lukas 26d57a238e Update .vscode 2023-09-30 16:25:05 +02:00
lukas 2d5ad3182c Update CSS 2023-09-30 16:24:54 +02:00
lukas 49cc3ea0cc Improve session list
continuous-integration/drone/push Build is passing
Fixes #53
2023-09-30 15:55:38 +02:00
lukas 440e1cfb71 Focus important fields on forms
continuous-integration/drone/push Build is passing
2023-09-30 12:40:02 +02:00
lukas 1cbd8c5c55 Update generated CSS
continuous-integration/drone/push Build is passing
2023-09-20 19:06:04 +02:00
lukas bc81a0ee8e Add hacky way to not reload page when starting/ending session
continuous-integration/drone/push Build is passing
Partially fixes #52
2023-09-20 18:54:54 +02:00
lukas c5653977ff Add time copy button, improve session editing
continuous-integration/drone/push Build is passing
2023-09-18 20:21:05 +02:00
lukas f151730ab6 Fix bug when filtering only manual sessions (#51)
continuous-integration/drone/push Build is passing
2023-09-18 19:37:54 +02:00
lukas f469a67d94 add robots.txt
continuous-integration/drone/push Build is passing
2023-09-17 17:58:22 +02:00
lukas 104ffc9d03 disable deleting sessions in code
continuous-integration/drone/push Build is passing
2023-09-17 17:17:22 +02:00
lukas a4b13eb247 allow admin in prod 2/2
continuous-integration/drone/push Build is passing
2023-09-17 14:12:23 +02:00
lukas 2307fac83a allow admin in prod
continuous-integration/drone/push Build is passing
2023-09-17 14:03:46 +02:00
lukas 6b52c0d4c4 disable deleting sessions
continuous-integration/drone/push Build is passing
2023-09-17 13:28:15 +02:00
lukas ff5d8c215d install dev dependecies
continuous-integration/drone/push Build is passing
2023-09-16 18:24:10 +02:00
lukas cdb3b89b08 do not filter out refunded games when adding session
continuous-integration/drone/push Build is passing
2023-09-16 18:18:03 +02:00
lukas ffa8198540 allow django admit 2023-09-16 18:17:36 +02:00
lukas 0b7da3550c Change recent session view to current year
continuous-integration/drone/push Build is passing
2023-09-14 18:49:16 +02:00
lukas e1655d6cfa input datetime-local needs day to also be two digits
continuous-integration/drone/push Build is passing
2023-03-02 21:33:07 +01:00
lukas 29c41865d0 Exclude refunded purchases from start session form
continuous-integration/drone/push Build is passing
2023-03-01 22:07:49 +01:00
lukas d21b461726 Update styles
continuous-integration/drone/push Build is passing
2023-02-21 23:51:07 +01:00
lukas 95489cfb78 Add .editorconfig 2023-02-21 23:50:09 +01:00
lukas fa4f1c4810 Improve forms, add helper buttons on add session form 2023-02-21 23:49:57 +01:00
lukas 366c25a1ff Register Edition, Device in the admin UI 2023-02-20 22:01:20 +01:00
lukas a3042caa20 Use date and datetime inputs
continuous-integration/drone/push Build is passing
Properly implements 4d91a76513
2023-02-20 21:33:15 +01:00
lukas 7997f9bbb2 Sort games by name on edition form 2023-02-20 20:17:42 +01:00
lukas b78c4ba9c5 Sync game name and edition name fields for QOL
continuous-integration/drone/push Build is passing
2023-02-20 18:21:48 +01:00
lukas 1df889c45d Fix gitignoring root static folder 2023-02-20 18:20:49 +01:00
lukas 468d05a9e2 Sort games alphabetically on edition form 2023-02-20 17:37:14 +01:00
lukas 2640a49734 Version 1.0.3
continuous-integration/drone/push Build is passing
2023-02-20 17:18:26 +01:00
lukas 65c175afb2 Allow editing filtered entities from session list 2023-02-20 17:16:19 +01:00
lukas 0814071a26 Improve display of editions on purchase form 2023-02-20 17:15:21 +01:00
lukas 5f845f866e Sort platforms alphabetically on edition form 2023-02-20 17:14:43 +01:00
lukas c3d4697470 Add wikidata ID and year for editions 2023-02-20 17:13:35 +01:00
lukas 77293f03e9 Add icons
continuous-integration/drone/push Build is passing
2023-02-19 17:20:57 +01:00
lukas 1fa364e2ec Change timetracker icon 2023-02-19 16:35:04 +01:00
lukas 4a6f4a2f9a Add icons for session filters
continuous-integration/drone/push Build is passing
2023-02-19 16:18:14 +01:00
lukas 9590988b6a Fix error when generating charts with less than 2 entries
continuous-integration/drone/push Build is passing
2023-02-19 14:55:06 +01:00
lukas 938c82a395 Allow filtering by game, edition, purchase from the session list 2023-02-19 14:36:12 +01:00
lukas 33939f631c Fix form styling
continuous-integration/drone/push Build is passing
2023-02-18 22:36:26 +01:00
lukas ac8cd6534a Version 1.0.2
continuous-integration/drone/push Build is passing
2023-02-18 21:48:55 +01:00
lukas 51d8e953c0 Allow editing editions 2023-02-18 21:47:25 +01:00
lukas 2eec677f41 Allow editing purchases 2023-02-18 21:44:19 +01:00
lukas f2eb14d3ef Fix session starting 2023-02-18 21:43:51 +01:00
lukas c337d2200f Fix ownership display
continuous-integration/drone/push Build is passing
2023-02-18 21:12:44 +01:00
lukas 8a8b05b0bd Add support for device info
Closes #49
2023-02-18 21:12:18 +01:00
lukas 9446065271 Add support for purchase ownership information
Closes #48
2023-02-18 20:57:03 +01:00
lukas 755093845d Add support for prices on purchases 2023-02-18 20:56:23 +01:00
lukas d4ab0596da Re-introduce index view
continuous-integration/drone/push Build is passing
2023-02-18 20:50:36 +01:00
lukas 8dcbe2f0ad Fix displaying of "filterying by..." text 2023-02-18 20:50:19 +01:00
lukas 25bc74eff1 Add support for game editions (#28) 2023-02-18 20:49:46 +01:00
lukas 8a7d083fb2 Fix make css 2023-02-18 20:35:59 +01:00
lukas 8296ebcf31 Fix redirect to index
continuous-integration/drone/push Build is passing
2023-02-06 17:23:44 +01:00
lukas 04a4f2e0be Order homepage sessions from newest
continuous-integration/drone/push Build is passing
2023-01-31 16:37:44 +01:00
lukas 4070b4e46e Version 1.0.1
continuous-integration/drone/push Build is passing
2023-01-30 22:17:47 +01:00
lukas 4892218c83 Show only last 30 days on homepage
Fixes #47
2023-01-30 22:16:28 +01:00
lukas 6b00a950ce Show markers on smaller graphs 2023-01-30 22:01:27 +01:00
lukas feee9d6dac Make it possible to edit sessions
continuous-integration/drone/push Build is passing
Fixes #46
2023-01-30 17:38:44 +01:00
lukas 9654fb017d Separate Docker container by branch
continuous-integration/drone/push Build is passing
2023-01-25 11:36:34 +01:00
lukas 1741397ee7 Prepare changelog
continuous-integration/drone/push Build is passing
2023-01-25 00:50:44 +01:00
lukas da0c8d710b Ignore dist directory
continuous-integration/drone/push Build is passing
2023-01-22 11:02:26 +01:00
71 changed files with 2617 additions and 870 deletions
+33 -3
View File
@@ -11,15 +11,45 @@ steps:
- poetry install - poetry install
- poetry env info - poetry env info
- poetry run pytest - poetry run pytest
- name: build container
- name: build-prod
image: plugins/docker image: plugins/docker
settings: settings:
repo: registry.kucharczyk.xyz/timetracker repo: registry.kucharczyk.xyz/timetracker
tags: tags:
- latest - latest
- 1.1.0
depends_on:
- "test"
when:
branch:
- main
- name: build-non-prod
image: plugins/docker
settings:
repo: registry.kucharczyk.xyz/timetracker
tags:
- ${DRONE_COMMIT_REF}
- ${DRONE_COMMIT_BRANCH}
when:
branch:
exclude:
- main
depends_on:
- "test"
- name: redeploy on portainer
image: plugins/webhook
settings:
urls:
from_secret: PORTAINER_TIMETRACKER_WEBHOOK_URL
depends_on:
- "build-prod"
trigger: trigger:
event: event:
- push - push
- cron - cron
exclude:
- pull_request
+17
View File
@@ -0,0 +1,17 @@
root = true
[*]
end_of_line = lf
insert_final_newline = true
[*.{js,py}]
charset = utf-8
# 4 space indentation
[*.py]
indent_style = space
indent_size = 4
[**/*.js]
indent_style = space
indent_size = 2
+2 -1
View File
@@ -5,4 +5,5 @@ __pycache__
node_modules node_modules
package-lock.json package-lock.json
db.sqlite3 db.sqlite3
static /static/
dist/
+5 -2
View File
@@ -4,5 +4,8 @@
], ],
"python.testing.unittestEnabled": false, "python.testing.unittestEnabled": false,
"python.testing.pytestEnabled": true, "python.testing.pytestEnabled": true,
"python.analysis.typeCheckingMode": "basic" "python.analysis.typeCheckingMode": "basic",
} "[python]": {
"editor.defaultFormatter": "ms-python.black-formatter"
},
}
+110
View File
@@ -1,3 +1,113 @@
## Unreleased
### New
* More fields are now optional. This is to make it easier to add new items in bulk.
* Game: Wikidata ID
* Edition: Platform, Year
* Purchase: Platform
* Platform: Group
* Session: Device
* New fields:
* Game: Year Released
* To record original year of release
* Upon migration, this will be set to a year of any of the game's edition that has it set
* Purchase: Date Finished
* Editions are now unique combination of name and platform
* Add more stats:
* All finished games
* All finished 2023 games
* All finished games that were purchased this year
* Sessions (count)
* Days played
* Finished (count)
* Unfinished (count)
* Refunded (count)
### Improved
* game overview: simplify playtime range display
* new session: order devices alphabetically
## 1.3.0 / 2023-11-05 15:09+01:00
### New
* Add Stats to the main navigation
* Allow selecting year on the Stats page
### Improved
* Make some pages redirect back instead to session list
### Improved
* Make navigation more compact
### Fixed
* Correctly limit sessions to a single year for stats
## 1.2.0 / 2023-11-01 20:18+01:00
### New
* Add yearly stats page (https://git.kucharczyk.xyz/lukas/timetracker/issues/15)
### Enhancements
* Add a button to start session from game overview
## 1.1.2 / 2023-10-13 16:30+02:00
### Enhancements
* Durations are formatted in a consisent manner across all pages
### Fixes
* Game Overview: display duration when >1 hour instead of displaying 0
## 1.1.1 / 2023-10-09 20:52+02:00
### New
* Add notes section to game overview
### Enhancements
* Make it possible to add any data on the game overview page
## 1.1.0 / 2023-10-09 00:01+02:00
### New
* Add game overview page (https://git.kucharczyk.xyz/lukas/timetracker/issues/8)
* Add helper buttons next to datime fields
* Add copy button on Add session page to copy times between fields
* Change fonts to IBM Plex
### Enhancements
* Improve form appearance
* Focus important fields on forms
* Use the same form when editing a session as when adding a session
* Change recent session view to current year instead of last 30 days
* Add a hacky way not to reload a page when starting or ending a session (https://git.kucharczyk.xyz/lukas/timetracker/issues/52)
* Improve session list (https://git.kucharczyk.xyz/lukas/timetracker/issues/53)
### Fixes
* Fix session being wrongly considered in progress if it had a certain amount of manual hours (https://git.kucharczyk.xyz/lukas/timetracker/issues/58)
* Fix bug when filtering only manual sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/51)
## 1.0.3 / 2023-02-20 17:16+01:00
* Add wikidata ID and year for editions
* Add icons for game, edition, purchase filters
* Allow filtering by game, edition, purchase from the session list
* Allow editing filtered entities from session list
## 1.0.2 / 2023-02-18 21:48+01:00
* Add support for device info (https://git.kucharczyk.xyz/lukas/timetracker/issues/49)
* Add support for purchase ownership information (https://git.kucharczyk.xyz/lukas/timetracker/issues/48)
* Add support for purchase prices
* Add support for game editions (https://git.kucharczyk.xyz/lukas/timetracker/issues/28)
## 1.0.1 / 2023-01-30 22:17+01:00
* Make it possible to edit sessions (https://git.kucharczyk.xyz/lukas/timetracker/issues/46)
* Show markers on smaller graphs to make it clearer which dates the session belong to
* Show only last 30 days on the homepage (https://git.kucharczyk.xyz/lukas/timetracker/issues/47)
## 1.0.0 / 2023-01-20 19:54+01:00 ## 1.0.0 / 2023-01-20 19:54+01:00
* Breaking * Breaking
+3 -10
View File
@@ -6,17 +6,10 @@ RUN npm install && \
FROM python:3.10.9-slim-bullseye FROM python:3.10.9-slim-bullseye
ENV VERSION_NUMBER 1.0.0 ENV VERSION_NUMBER 1.3.0
ENV PROD 1 ENV PROD 1
ENV PYTHONUNBUFFERED=1 ENV PYTHONUNBUFFERED=1
RUN apt update && \
apt install -y \
bash \
vim \
curl && \
rm -rf /var/lib/apt/lists/*
RUN useradd -m --uid 1000 timetracker RUN useradd -m --uid 1000 timetracker
WORKDIR /home/timetracker/app WORKDIR /home/timetracker/app
COPY . /home/timetracker/app/ COPY . /home/timetracker/app/
@@ -28,7 +21,7 @@ RUN chmod +x /entrypoint.sh
USER timetracker USER timetracker
ENV PATH="$PATH:/home/timetracker/.local/bin" ENV PATH="$PATH:/home/timetracker/.local/bin"
RUN pip install --no-cache-dir poetry RUN pip install --no-cache-dir poetry
RUN poetry install --without dev RUN poetry install
EXPOSE 8000 EXPOSE 8000
CMD [ "/entrypoint.sh" ] CMD [ "/entrypoint.sh" ]
+3 -3
View File
@@ -7,11 +7,11 @@ HTMLFILES := $(shell find games/templates -type f)
npm: npm:
npm install npm install
css: input.css css: common/input.css
npx tailwindcss -i ./input.css -o ./games/static/base.css npx tailwindcss -i ./common/input.css -o ./games/static/base.css
css-dev: css css-dev: css
npx tailwindcss -i ./input.css -o ./games/static/base.css --watch npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch
makemigrations: makemigrations:
poetry run python manage.py makemigrations poetry run python manage.py makemigrations
+84 -3
View File
@@ -2,21 +2,102 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@font-face {
font-family: "IBM Plex Mono";
src: url("fonts/IBMPlexMono-regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Sans";
src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Serif";
src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
form label { form label {
@apply dark:text-slate-400; @apply dark:text-slate-400;
} }
.responsive-table {
@apply dark:text-white mx-auto;
}
.responsive-table tr:nth-child(even) {
@apply bg-slate-800
}
.responsive-table tbody tr:nth-child(odd) {
@apply bg-slate-900
}
.responsive-table thead th {
@apply text-left border-b-2 border-b-slate-500 text-xl;
}
.responsive-table thead th:not(:first-child),
.responsive-table td:not(:first-child) {
@apply border-l border-l-slate-500;
}
@layer utilities {
.max-w-20char {
max-width: 20ch;
}
.max-w-35char {
max-width: 40ch;
}
.max-w-40char {
max-width: 40ch;
}
}
form input, form input,
select, select,
textarea { textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100; @apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
} }
#session-table { @media screen and (min-width: 768px) {
display: grid; form input,
grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr; select,
textarea {
width: 300px;
}
}
@media screen and (max-width: 768px) {
form input,
select,
textarea {
width: 150px;
}
} }
#button-container button { #button-container button {
@apply mx-1; @apply mx-1;
} }
th {
@apply text-right;
}
th label {
@apply mr-4;
}
.basic-button-container {
@apply flex space-x-2 justify-center;
}
.basic-button {
@apply inline-block px-6 py-2.5 bg-blue-600 text-white font-medium text-xs leading-tight uppercase rounded shadow-md hover:bg-blue-700 hover:shadow-lg focus:bg-blue-700 focus:shadow-lg focus:outline-none focus:ring-0 active:bg-blue-800 active:shadow-lg transition duration-150 ease-in-out;
}
-91
View File
@@ -1,91 +0,0 @@
import base64
from datetime import datetime
from io import BytesIO
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
import pandas as pd
from django.db.models import F, IntegerField, QuerySet, Sum
from django.db.models.functions import TruncDay
from games.models import Session
def key_value_to_value_value(data):
return {data["date"]: data["hours"]}
def playtime_over_time_chart(queryset: QuerySet = Session.objects):
microsecond_in_second = 1000000
result = (
queryset.exclude(timestamp_end__exact=None)
.annotate(date=TruncDay("timestamp_start"))
.values("date")
.annotate(
hours=Sum(
F("duration_calculated"),
output_field=IntegerField(),
)
)
.values("date", "hours")
)
keys = []
values = []
running_total = int(0)
for item in result:
# date_value = datetime.strftime(item["date"], "%d-%m-%Y")
date_value = item["date"]
keys.append(date_value)
running_total += int(item["hours"] / (3600 * microsecond_in_second))
values.append(running_total)
data = [keys, values]
return get_chart(
data,
title="Playtime over time (manual excluded)",
xlabel="Date",
ylabel="Hours",
)
def get_graph():
buffer = BytesIO()
plt.savefig(buffer, format="svg", transparent=True)
buffer.seek(0)
image_png = buffer.getvalue()
graph = base64.b64encode(image_png)
graph = graph.decode("utf-8")
buffer.close()
return graph
def get_chart(data, title="", xlabel="", ylabel=""):
x = data[0]
y = data[1]
plt.style.use("dark_background")
plt.switch_backend("SVG")
fig, ax = plt.subplots()
fig.set_size_inches(10, 4)
ax.plot(x, y)
first = x[0]
last = x[-1]
difference = last - first
if difference.days <= 14:
ax.xaxis.set_major_locator(mdates.DayLocator())
elif difference.days < 60 or len(x) < 60:
ax.xaxis.set_major_locator(mdates.WeekdayLocator())
ax.xaxis.set_minor_locator(mdates.DayLocator())
elif difference.days < 720:
ax.xaxis.set_major_locator(mdates.MonthLocator())
ax.xaxis.set_minor_locator(mdates.WeekdayLocator())
else:
ax.xaxis.set_major_locator(mdates.YearLocator())
ax.xaxis.set_minor_locator(mdates.MonthLocator())
ax.xaxis.set_major_formatter(mdates.DateFormatter("%Y-%m-%d"))
for label in ax.get_xticklabels(which="major"):
label.set(rotation=30, horizontalalignment="right")
ax.set_xlabel(xlabel)
ax.set_ylabel(ylabel)
ax.set_title(title)
fig.tight_layout()
chart = get_graph()
return chart
+27 -11
View File
@@ -32,13 +32,15 @@ def format_duration(
from the formatting string. For example: from the formatting string. For example:
- 61 seconds as "%s" = 61 seconds - 61 seconds as "%s" = 61 seconds
- 61 seconds as "%m %s" = 1 minutes 1 seconds" - 61 seconds as "%m %s" = 1 minutes 1 seconds"
Format specifiers can include width and precision options:
- %5.2H: hours formatted with width 5 and 2 decimal places (padded with zeros)
""" """
minute_seconds = 60 minute_seconds = 60
hour_seconds = 60 * minute_seconds hour_seconds = 60 * minute_seconds
day_seconds = 24 * hour_seconds day_seconds = 24 * hour_seconds
duration = _safe_timedelta(duration) safe_duration = _safe_timedelta(duration)
# we don't need float # we don't need float
seconds_total = int(duration.total_seconds()) seconds_total = int(safe_duration.total_seconds())
# timestamps where end is before start # timestamps where end is before start
if seconds_total < 0: if seconds_total < 0:
seconds_total = 0 seconds_total = 0
@@ -46,18 +48,32 @@ def format_duration(
remainder = seconds = seconds_total remainder = seconds = seconds_total
if "%d" in format_string: if "%d" in format_string:
days, remainder = divmod(seconds_total, day_seconds) days, remainder = divmod(seconds_total, day_seconds)
if "%H" in format_string: if re.search(r"%\d*\.?\d*H", format_string):
hours, remainder = divmod(remainder, hour_seconds) hours_float, remainder = divmod(remainder, hour_seconds)
if "%m" in format_string: hours = float(hours_float) + remainder / hour_seconds
if re.search(r"%\d*\.?\d*m", format_string):
minutes, seconds = divmod(remainder, minute_seconds) minutes, seconds = divmod(remainder, minute_seconds)
literals = { literals = {
"%d": str(days), "d": str(days),
"%H": str(hours), "H": str(hours),
"%m": str(minutes), "m": str(minutes),
"%s": str(seconds), "s": str(seconds),
"%r": str(seconds_total), "r": str(seconds_total),
} }
formatted_string = format_string formatted_string = format_string
for pattern, replacement in literals.items(): for pattern, replacement in literals.items():
formatted_string = re.sub(pattern, replacement, formatted_string) # Match format specifiers with optional width and precision
match = re.search(rf"%(\d*\.?\d*){pattern}", formatted_string)
if match:
format_spec = match.group(1)
if "." in format_spec:
# Format the number as float if precision is specified
replacement = f"{float(replacement):{format_spec}f}"
else:
# Format the number as integer if no precision is specified
replacement = f"{int(float(replacement)):>{format_spec}}"
# Replace the format specifier with the formatted number
formatted_string = re.sub(
rf"%\d*\.?\d*{pattern}", replacement, formatted_string
)
return formatted_string return formatted_string
+9
View File
@@ -0,0 +1,9 @@
def safe_division(numerator: int | float, denominator: int | float) -> int | float:
"""
Divides without triggering division by zero exception.
Returns 0 if denominator is 0.
"""
try:
return numerator / denominator
except ZeroDivisionError:
return 0
+9 -1
View File
@@ -7,5 +7,13 @@ poetry run python manage.py migrate
echo "Collect static files" echo "Collect static files"
poetry run python manage.py collectstatic --clear --no-input poetry run python manage.py collectstatic --clear --no-input
_term() {
echo "Caught SIGTERM signal!"
kill -SIGTERM "$gunicorn_pid"
}
trap _term SIGTERM
echo "Starting app" echo "Starting app"
poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - poetry run python -m gunicorn --bind 0.0.0.0:8001 timetracker.asgi:application -k uvicorn.workers.UvicornWorker --access-logfile - --error-logfile - & gunicorn_pid=$!
wait "$gunicorn_pid"
+3 -1
View File
@@ -1,9 +1,11 @@
from django.contrib import admin from django.contrib import admin
from games.models import Game, Platform, Purchase, Session from games.models import Game, Platform, Purchase, Session, Edition, Device
# Register your models here. # Register your models here.
admin.site.register(Game) admin.site.register(Game)
admin.site.register(Purchase) admin.site.register(Purchase)
admin.site.register(Platform) admin.site.register(Platform)
admin.site.register(Session) admin.site.register(Session)
admin.site.register(Edition)
admin.site.register(Device)
+68 -5
View File
@@ -1,38 +1,101 @@
from django import forms from django import forms
from games.models import Game, Platform, Purchase, Session from games.models import Game, Platform, Purchase, Session, Edition, Device
custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput(
attrs={"type": "datetime-local"}, format="%Y-%m-%d %H:%M"
)
autofocus_select_widget = forms.Select(attrs={"autofocus": "autofocus"})
autofocus_input_widget = forms.TextInput(attrs={"autofocus": "autofocus"})
class SessionForm(forms.ModelForm): class SessionForm(forms.ModelForm):
purchase = forms.ModelChoiceField(queryset=Purchase.objects.order_by("game__name")) # purchase = forms.ModelChoiceField(
# queryset=Purchase.objects.filter(date_refunded=None).order_by("edition__name")
# )
purchase = forms.ModelChoiceField(
queryset=Purchase.objects.order_by("edition__name"),
widget=autofocus_select_widget,
)
device = forms.ModelChoiceField(queryset=Device.objects.order_by("name"))
class Meta: class Meta:
widgets = {
"timestamp_start": custom_datetime_widget,
"timestamp_end": custom_datetime_widget,
}
model = Session model = Session
fields = [ fields = [
"purchase", "purchase",
"timestamp_start", "timestamp_start",
"timestamp_end", "timestamp_end",
"duration_manual", "duration_manual",
"device",
"note", "note",
] ]
class EditionChoiceField(forms.ModelChoiceField):
def label_from_instance(self, obj) -> str:
return f"{obj.name} ({obj.platform}, {obj.year_released})"
class PurchaseForm(forms.ModelForm): class PurchaseForm(forms.ModelForm):
game = forms.ModelChoiceField(queryset=Game.objects.order_by("name")) edition = EditionChoiceField(
queryset=Edition.objects.order_by("name"), widget=autofocus_select_widget
)
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
class Meta: class Meta:
widgets = {
"date_purchased": custom_date_widget,
"date_refunded": custom_date_widget,
"date_finished": custom_date_widget,
}
model = Purchase model = Purchase
fields = ["game", "platform", "date_purchased", "date_refunded"] fields = [
"edition",
"platform",
"date_purchased",
"date_refunded",
"date_finished",
"price",
"price_currency",
"ownership_type",
]
class EditionForm(forms.ModelForm):
game = forms.ModelChoiceField(
queryset=Game.objects.order_by("name"), widget=autofocus_select_widget
)
platform = forms.ModelChoiceField(
queryset=Platform.objects.order_by("name"), required=False
)
class Meta:
model = Edition
fields = ["game", "name", "platform", "year_released", "wikidata"]
class GameForm(forms.ModelForm): class GameForm(forms.ModelForm):
class Meta: class Meta:
model = Game model = Game
fields = ["name", "wikidata"] fields = ["name", "year_released", "wikidata"]
widgets = {"name": autofocus_input_widget}
class PlatformForm(forms.ModelForm): class PlatformForm(forms.ModelForm):
class Meta: class Meta:
model = Platform model = Platform
fields = ["name", "group"] fields = ["name", "group"]
widgets = {"name": autofocus_input_widget}
class DeviceForm(forms.ModelForm):
class Meta:
model = Device
fields = ["name", "type"]
widgets = {"name": autofocus_input_widget}
+41
View File
@@ -0,0 +1,41 @@
# Generated by Django 4.1.5 on 2023-02-18 16:29
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0007_alter_purchase_game_alter_purchase_platform_and_more"),
]
operations = [
migrations.CreateModel(
name="Edition",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"game",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.game"
),
),
(
"platform",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.platform"
),
),
],
),
]
+34
View File
@@ -0,0 +1,34 @@
# Generated by Django 4.1.5 on 2023-02-18 18:51
from django.db import migrations
def create_edition_of_game(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
Platform = apps.get_model("games", "Platform")
first_platform = Platform.objects.first()
all_games = Game.objects.all()
all_editions = Edition.objects.all()
for game in all_games:
existing_edition = None
try:
existing_edition = all_editions.objects.get(game=game.id)
except:
pass
if existing_edition == None:
edition = Edition()
edition.id = game.id
edition.game = game
edition.name = game.name
edition.platform = first_platform
edition.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0008_edition"),
]
operations = [migrations.RunPython(create_edition_of_game)]
@@ -0,0 +1,21 @@
# Generated by Django 4.1.5 on 2023-02-18 19:06
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0009_create_editions"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="game",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE, to="games.edition"
),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-02-18 19:18
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0010_alter_purchase_game"),
]
operations = [
migrations.RenameField(
model_name="purchase",
old_name="game",
new_name="edition",
),
]
@@ -0,0 +1,23 @@
# Generated by Django 4.1.5 on 2023-02-18 19:53
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0011_rename_game_purchase_edition"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="price",
field=models.IntegerField(default=0),
),
migrations.AddField(
model_name="purchase",
name="price_currency",
field=models.CharField(default="USD", max_length=3),
),
]
@@ -0,0 +1,31 @@
# Generated by Django 4.1.5 on 2023-02-18 19:54
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0012_purchase_price_purchase_price_currency"),
]
operations = [
migrations.AddField(
model_name="purchase",
name="ownership_type",
field=models.CharField(
choices=[
("ph", "Physical"),
("di", "Digital"),
("du", "Digital Upgrade"),
("re", "Rented"),
("bo", "Borrowed"),
("tr", "Trial"),
("de", "Demo"),
("pi", "Pirated"),
],
default="di",
max_length=2,
),
),
]
@@ -0,0 +1,52 @@
# Generated by Django 4.1.5 on 2023-02-18 19:59
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0013_purchase_ownership_type"),
]
operations = [
migrations.CreateModel(
name="Device",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("name", models.CharField(max_length=255)),
(
"type",
models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
],
default="pc",
max_length=3,
),
),
],
),
migrations.AddField(
model_name="session",
name="device",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
]
@@ -0,0 +1,23 @@
# Generated by Django 4.1.5 on 2023-02-20 14:55
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0014_device_session_device"),
]
operations = [
migrations.AddField(
model_name="edition",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AddField(
model_name="edition",
name="year_released",
field=models.IntegerField(default=2023),
),
]
@@ -0,0 +1,51 @@
# Generated by Django 4.1.5 on 2023-11-06 11:10
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0015_edition_wikidata_edition_year_released"),
]
operations = [
migrations.AlterField(
model_name="edition",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.AlterField(
model_name="edition",
name="year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.AlterField(
model_name="game",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AlterField(
model_name="platform",
name="group",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.AlterField(
model_name="session",
name="device",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
]
@@ -0,0 +1,141 @@
# Generated by Django 4.1.5 on 2023-11-06 18:14
from django.db import migrations, models
import django.db.models.deletion
def rename_duplicates(apps, schema_editor):
Edition = apps.get_model("games", "Edition")
duplicates = (
Edition.objects.values("name", "platform")
.annotate(name_count=models.Count("id"))
.filter(name_count__gt=1)
)
for duplicate in duplicates:
counter = 1
duplicate_editions = Edition.objects.filter(
name=duplicate["name"], platform_id=duplicate["platform"]
).order_by("id")
for edition in duplicate_editions[1:]: # Skip the first one
edition.name = f"{edition.name} {counter}"
edition.save()
counter += 1
def update_game_year(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
for game in Game.objects.filter(year__isnull=True):
# Try to get the first related edition with a non-null year_released
edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
if edition:
# If an edition is found, update the game's year
game.year = edition.year_released
game.save()
class Migration(migrations.Migration):
replaces = [
("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
("games", "0017_alter_device_type_alter_purchase_platform"),
("games", "0018_auto_20231106_1825"),
("games", "0019_alter_edition_unique_together"),
("games", "0020_game_year"),
("games", "0021_auto_20231106_1909"),
("games", "0022_rename_year_game_year_released"),
]
dependencies = [
("games", "0015_edition_wikidata_edition_year_released"),
]
operations = [
migrations.AlterField(
model_name="edition",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.AlterField(
model_name="edition",
name="year_released",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.AlterField(
model_name="game",
name="wikidata",
field=models.CharField(blank=True, default=None, max_length=50, null=True),
),
migrations.AlterField(
model_name="platform",
name="group",
field=models.CharField(blank=True, default=None, max_length=255, null=True),
),
migrations.AlterField(
model_name="session",
name="device",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.device",
),
),
migrations.AlterField(
model_name="device",
name="type",
field=models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
("un", "Unknown"),
],
default="un",
max_length=3,
),
),
migrations.AlterField(
model_name="purchase",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
migrations.RunPython(
code=rename_duplicates,
),
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform")},
),
migrations.AddField(
model_name="game",
name="year",
field=models.IntegerField(blank=True, default=None, null=True),
),
migrations.RunPython(
code=update_game_year,
),
migrations.RenameField(
model_name="game",
old_name="year",
new_name="year_released",
),
]
@@ -0,0 +1,41 @@
# Generated by Django 4.1.5 on 2023-11-06 16:53
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("games", "0016_alter_edition_platform_alter_edition_year_released_and_more"),
]
operations = [
migrations.AlterField(
model_name="device",
name="type",
field=models.CharField(
choices=[
("pc", "PC"),
("co", "Console"),
("ha", "Handheld"),
("mo", "Mobile"),
("sbc", "Single-board computer"),
("un", "Unknown"),
],
default="un",
max_length=3,
),
),
migrations.AlterField(
model_name="purchase",
name="platform",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.CASCADE,
to="games.platform",
),
),
]
@@ -0,0 +1,34 @@
# Generated by Django 4.1.5 on 2023-11-06 17:25
from django.db import migrations, models
def rename_duplicates(apps, schema_editor):
Edition = apps.get_model("games", "Edition")
duplicates = (
Edition.objects.values("name", "platform")
.annotate(name_count=models.Count("id"))
.filter(name_count__gt=1)
)
for duplicate in duplicates:
counter = 1
duplicate_editions = Edition.objects.filter(
name=duplicate["name"], platform_id=duplicate["platform"]
).order_by("id")
for edition in duplicate_editions[1:]: # Skip the first one
edition.name = f"{edition.name} {counter}"
edition.save()
counter += 1
class Migration(migrations.Migration):
dependencies = [
("games", "0017_alter_device_type_alter_purchase_platform"),
]
operations = [
migrations.RunPython(rename_duplicates),
]
@@ -0,0 +1,17 @@
# Generated by Django 4.1.5 on 2023-11-06 17:26
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0018_auto_20231106_1825"),
]
operations = [
migrations.AlterUniqueTogether(
name="edition",
unique_together={("name", "platform")},
),
]
+18
View File
@@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-11-06 18:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0019_alter_edition_unique_together"),
]
operations = [
migrations.AddField(
model_name="game",
name="year",
field=models.IntegerField(blank=True, default=None, null=True),
),
]
@@ -0,0 +1,24 @@
from django.db import migrations
def update_game_year(apps, schema_editor):
Game = apps.get_model("games", "Game")
Edition = apps.get_model("games", "Edition")
for game in Game.objects.filter(year__isnull=True):
# Try to get the first related edition with a non-null year_released
edition = Edition.objects.filter(game=game, year_released__isnull=False).first()
if edition:
# If an edition is found, update the game's year
game.year = edition.year_released
game.save()
class Migration(migrations.Migration):
dependencies = [
("games", "0020_game_year"),
]
operations = [
migrations.RunPython(update_game_year),
]
@@ -0,0 +1,18 @@
# Generated by Django 4.1.5 on 2023-11-06 18:12
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
("games", "0021_auto_20231106_1909"),
]
operations = [
migrations.RenameField(
model_name="game",
old_name="year",
new_name="year_released",
),
]
@@ -0,0 +1,21 @@
# Generated by Django 4.1.5 on 2023-11-06 18:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"games",
"0016_alter_edition_platform_alter_edition_year_released_and_more_squashed_0022_rename_year_game_year_released",
),
]
operations = [
migrations.AddField(
model_name="purchase",
name="date_finished",
field=models.DateField(blank=True, null=True),
),
]
+109 -11
View File
@@ -10,36 +10,99 @@ from django.db.models import F, Manager, Sum
class Game(models.Model): class Game(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
wikidata = models.CharField(max_length=50) year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
def __str__(self): def __str__(self):
return self.name return self.name
class Purchase(models.Model): class Edition(models.Model):
class Meta:
unique_together = [["name", "platform"]]
game = models.ForeignKey("Game", on_delete=models.CASCADE) game = models.ForeignKey("Game", on_delete=models.CASCADE)
platform = models.ForeignKey("Platform", on_delete=models.CASCADE) name = models.CharField(max_length=255)
date_purchased = models.DateField() platform = models.ForeignKey(
date_refunded = models.DateField(blank=True, null=True) "Platform", on_delete=models.CASCADE, null=True, blank=True, default=None
)
year_released = models.IntegerField(null=True, blank=True, default=None)
wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
def __str__(self): def __str__(self):
return f"{self.game} ({self.platform})" return self.name
class PurchaseQueryset(models.QuerySet):
def refunded(self):
return self.filter(date_refunded__isnull=False)
def not_refunded(self):
return self.filter(date_refunded__isnull=True)
def finished(self):
return self.filter(date_finished__isnull=False)
class Purchase(models.Model):
PHYSICAL = "ph"
DIGITAL = "di"
DIGITALUPGRADE = "du"
RENTED = "re"
BORROWED = "bo"
TRIAL = "tr"
DEMO = "de"
PIRATED = "pi"
OWNERSHIP_TYPES = [
(PHYSICAL, "Physical"),
(DIGITAL, "Digital"),
(DIGITALUPGRADE, "Digital Upgrade"),
(RENTED, "Rented"),
(BORROWED, "Borrowed"),
(TRIAL, "Trial"),
(DEMO, "Demo"),
(PIRATED, "Pirated"),
]
objects = PurchaseQueryset().as_manager()
edition = models.ForeignKey("Edition", on_delete=models.CASCADE)
platform = models.ForeignKey(
"Platform", on_delete=models.CASCADE, default=None, null=True, blank=True
)
date_purchased = models.DateField()
date_refunded = models.DateField(blank=True, null=True)
date_finished = models.DateField(blank=True, null=True)
price = models.IntegerField(default=0)
price_currency = models.CharField(max_length=3, default="USD")
ownership_type = models.CharField(
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
)
def __str__(self):
platform_info = self.platform
if self.platform != self.edition.platform:
platform_info = f"{self.edition.platform} version on {self.platform}"
return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})"
class Platform(models.Model): class Platform(models.Model):
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
group = models.CharField(max_length=255) group = models.CharField(max_length=255, null=True, blank=True, default=None)
def __str__(self): def __str__(self):
return self.name return self.name
class SessionQuerySet(models.QuerySet): class SessionQuerySet(models.QuerySet):
def total_duration(self): def total_duration_formatted(self):
return format_duration(self.total_duration_unformatted())
def total_duration_unformatted(self):
result = self.aggregate( result = self.aggregate(
duration=Sum(F("duration_calculated") + F("duration_manual")) duration=Sum(F("duration_calculated") + F("duration_manual"))
) )
return format_duration(result["duration"]) return result["duration"]
class Session(models.Model): class Session(models.Model):
@@ -48,6 +111,13 @@ class Session(models.Model):
timestamp_end = models.DateTimeField(blank=True, null=True) timestamp_end = models.DateTimeField(blank=True, null=True)
duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0)) duration_manual = models.DurationField(blank=True, null=True, default=timedelta(0))
duration_calculated = models.DurationField(blank=True, null=True) duration_calculated = models.DurationField(blank=True, null=True)
device = models.ForeignKey(
"Device",
on_delete=models.CASCADE,
null=True,
blank=True,
default=None,
)
note = models.TextField(blank=True, null=True) note = models.TextField(blank=True, null=True)
objects = SessionQuerySet.as_manager() objects = SessionQuerySet.as_manager()
@@ -72,16 +142,44 @@ class Session(models.Model):
return timedelta(seconds=(manual + calculated).total_seconds()) return timedelta(seconds=(manual + calculated).total_seconds())
def duration_formatted(self) -> str: def duration_formatted(self) -> str:
result = format_duration(self.duration_seconds(), "%H:%m") result = format_duration(self.duration_seconds(), "%02.0H:%02.0m")
return result return result
@property @property
def duration_sum(self) -> str: def duration_sum(self) -> str:
return Session.objects.all().total_duration() return Session.objects.all().total_duration_formatted()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.timestamp_start != None and self.timestamp_end != None: if self.timestamp_start != None and self.timestamp_end != None:
self.duration_calculated = self.timestamp_end - self.timestamp_start self.duration_calculated = self.timestamp_end - self.timestamp_start
else: else:
self.duration_calculated = timedelta(0) self.duration_calculated = timedelta(0)
if not self.device:
default_device, _ = Device.objects.get_or_create(
type=Device.UNKNOWN, defaults={"name": "Unknown"}
)
self.device = default_device
super(Session, self).save(*args, **kwargs) super(Session, self).save(*args, **kwargs)
class Device(models.Model):
PC = "pc"
CONSOLE = "co"
HANDHELD = "ha"
MOBILE = "mo"
SBC = "sbc"
UNKNOWN = "un"
DEVICE_TYPES = [
(PC, "PC"),
(CONSOLE, "Console"),
(HANDHELD, "Handheld"),
(MOBILE, "Mobile"),
(SBC, "Single-board computer"),
(UNKNOWN, "Unknown"),
]
name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN)
def __str__(self):
return f"{self.name} ({self.get_type_display()})"
+457 -114
View File
@@ -1,5 +1,5 @@
/* /*
! tailwindcss v3.2.4 | MIT License | https://tailwindcss.com ! tailwindcss v3.3.3 | MIT License | https://tailwindcss.com
*/ */
/* /*
@@ -31,6 +31,7 @@
3. Use a more readable tab size. 3. Use a more readable tab size.
4. Use the user's configured `sans` font-family by default. 4. Use the user's configured `sans` font-family by default.
5. Use the user's configured `sans` font-feature-settings by default. 5. Use the user's configured `sans` font-feature-settings by default.
6. Use the user's configured `sans` font-variation-settings by default.
*/ */
html { html {
@@ -43,10 +44,12 @@ html {
-o-tab-size: 4; -o-tab-size: 4;
tab-size: 4; tab-size: 4;
/* 3 */ /* 3 */
font-family: Inter, sans-serif; font-family: IBM Plex Sans, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
/* 4 */ /* 4 */
font-feature-settings: normal; font-feature-settings: normal;
/* 5 */ /* 5 */
font-variation-settings: normal;
/* 6 */
} }
/* /*
@@ -126,7 +129,7 @@ code,
kbd, kbd,
samp, samp,
pre { pre {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
/* 1 */ /* 1 */
font-size: 1em; font-size: 1em;
/* 2 */ /* 2 */
@@ -188,6 +191,10 @@ select,
textarea { textarea {
font-family: inherit; font-family: inherit;
/* 1 */ /* 1 */
font-feature-settings: inherit;
/* 1 */
font-variation-settings: inherit;
/* 1 */
font-size: 100%; font-size: 100%;
/* 1 */ /* 1 */
font-weight: inherit; font-weight: inherit;
@@ -338,6 +345,14 @@ menu {
padding: 0; padding: 0;
} }
/*
Reset default styling for dialogs.
*/
dialog {
padding: 0;
}
/* /*
Prevent resizing textareas horizontally by default. Prevent resizing textareas horizontally by default.
*/ */
@@ -419,7 +434,7 @@ video {
display: none; display: none;
} }
[type='text'],[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select { [type='text'],input:where(:not([type])),[type='email'],[type='url'],[type='password'],[type='number'],[type='date'],[type='datetime-local'],[type='month'],[type='search'],[type='tel'],[type='time'],[type='week'],[multiple],textarea,select {
-webkit-appearance: none; -webkit-appearance: none;
-moz-appearance: none; -moz-appearance: none;
appearance: none; appearance: none;
@@ -436,7 +451,7 @@ video {
--tw-shadow: 0 0 #0000; --tw-shadow: 0 0 #0000;
} }
[type='text']:focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus { [type='text']:focus, input:where(:not([type])):focus, [type='email']:focus, [type='url']:focus, [type='password']:focus, [type='number']:focus, [type='date']:focus, [type='datetime-local']:focus, [type='month']:focus, [type='search']:focus, [type='tel']:focus, [type='time']:focus, [type='week']:focus, [multiple]:focus, textarea:focus, select:focus {
outline: 2px solid transparent; outline: 2px solid transparent;
outline-offset: 2px; outline-offset: 2px;
--tw-ring-inset: var(--tw-empty,/*!*/ /*!*/); --tw-ring-inset: var(--tw-empty,/*!*/ /*!*/);
@@ -465,6 +480,11 @@ input::placeholder,textarea::placeholder {
::-webkit-date-and-time-value { ::-webkit-date-and-time-value {
min-height: 1.5em; min-height: 1.5em;
text-align: inherit;
}
::-webkit-datetime-edit {
display: inline-flex;
} }
::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field { ::-webkit-datetime-edit,::-webkit-datetime-edit-year-field,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute-field,::-webkit-datetime-edit-second-field,::-webkit-datetime-edit-millisecond-field,::-webkit-datetime-edit-meridiem-field {
@@ -482,7 +502,7 @@ select {
print-color-adjust: exact; print-color-adjust: exact;
} }
[multiple] { [multiple],[size]:where(select:not([size="1"])) {
background-image: initial; background-image: initial;
background-position: initial; background-position: initial;
background-repeat: unset; background-repeat: unset;
@@ -599,6 +619,9 @@ select {
--tw-pan-y: ; --tw-pan-y: ;
--tw-pinch-zoom: ; --tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity; --tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ; --tw-ordinal: ;
--tw-slashed-zero: ; --tw-slashed-zero: ;
--tw-numeric-figure: ; --tw-numeric-figure: ;
@@ -646,6 +669,9 @@ select {
--tw-pan-y: ; --tw-pan-y: ;
--tw-pinch-zoom: ; --tw-pinch-zoom: ;
--tw-scroll-snap-strictness: proximity; --tw-scroll-snap-strictness: proximity;
--tw-gradient-from-position: ;
--tw-gradient-via-position: ;
--tw-gradient-to-position: ;
--tw-ordinal: ; --tw-ordinal: ;
--tw-slashed-zero: ; --tw-slashed-zero: ;
--tw-numeric-figure: ; --tw-numeric-figure: ;
@@ -713,6 +739,10 @@ select {
} }
} }
.invisible {
visibility: hidden;
}
.static { .static {
position: static; position: static;
} }
@@ -721,16 +751,33 @@ select {
position: fixed; position: fixed;
} }
.left-2 { .absolute {
left: 0.5rem; position: absolute;
}
.relative {
position: relative;
} }
.bottom-2 { .bottom-2 {
bottom: 0.5rem; bottom: 0.5rem;
} }
.clear-both { .left-2 {
clear: both; left: 0.5rem;
}
.right-3 {
right: 0.75rem;
}
.top-3 {
top: 0.75rem;
}
.mx-2 {
margin-left: 0.5rem;
margin-right: 0.5rem;
} }
.mx-auto { .mx-auto {
@@ -738,30 +785,57 @@ select {
margin-right: auto; margin-right: auto;
} }
.mb-4 { .my-2 {
margin-top: 0.5rem;
margin-bottom: 0.5rem;
}
.my-4 {
margin-top: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.mt-4 { .my-6 {
margin-top: 1rem; margin-top: 1.5rem;
margin-bottom: 1.5rem;
} }
.mb-3 { .mb-1 {
margin-bottom: 0.75rem; margin-bottom: 0.25rem;
} }
.mt-10 { .mb-10 {
margin-top: 2.5rem; margin-bottom: 2.5rem;
}
.mb-4 {
margin-bottom: 1rem;
} }
.ml-1 { .ml-1 {
margin-left: 0.25rem; margin-left: 0.25rem;
} }
.ml-2 {
margin-left: 0.5rem;
}
.mr-4 {
margin-right: 1rem;
}
.mt-4 {
margin-top: 1rem;
}
.block { .block {
display: block; display: block;
} }
.inline-block {
display: inline-block;
}
.inline { .inline {
display: inline; display: inline;
} }
@@ -770,46 +844,72 @@ select {
display: flex; display: flex;
} }
.h-6 { .table {
height: 1.5rem; display: table;
} }
.h-5 { .hidden {
height: 1.25rem; display: none;
} }
.h-4 { .h-4 {
height: 1rem; height: 1rem;
} }
.h-5 {
height: 1.25rem;
}
.h-6 {
height: 1.5rem;
}
.min-h-screen { .min-h-screen {
min-height: 100vh; min-height: 100vh;
} }
.w-full {
width: 100%;
}
.w-6 {
width: 1.5rem;
}
.w-5 { .w-5 {
width: 1.25rem; width: 1.25rem;
} }
.w-6 {
width: 1.5rem;
}
.w-7 { .w-7 {
width: 1.75rem; width: 1.75rem;
} }
.w-4 { .w-auto {
width: 1rem; width: auto;
}
.w-full {
width: 100%;
} }
.max-w-screen-lg { .max-w-screen-lg {
max-width: 1024px; max-width: 1024px;
} }
.max-w-sm {
max-width: 24rem;
}
.max-w-xs {
max-width: 20rem;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.animate-spin {
animation: spin 1s linear infinite;
}
.flex-col { .flex-col {
flex-direction: column; flex-direction: column;
} }
@@ -822,10 +922,6 @@ select {
align-items: center; align-items: center;
} }
.justify-end {
justify-content: flex-end;
}
.justify-center { .justify-center {
justify-content: center; justify-content: center;
} }
@@ -834,20 +930,18 @@ select {
justify-content: space-between; justify-content: space-between;
} }
.gap-4 { .gap-2 {
gap: 1rem; gap: 0.5rem;
} }
.self-center { .self-center {
align-self: center; align-self: center;
} }
.overflow-hidden { .truncate {
overflow: hidden; overflow: hidden;
}
.text-ellipsis {
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap;
} }
.whitespace-nowrap { .whitespace-nowrap {
@@ -862,8 +956,8 @@ select {
border-radius: 0.5rem; border-radius: 0.5rem;
} }
.rounded-xl { .rounded-sm {
border-radius: 0.75rem; border-radius: 0.125rem;
} }
.border-gray-200 { .border-gray-200 {
@@ -871,9 +965,14 @@ select {
border-color: rgb(229 231 235 / var(--tw-border-opacity)); border-color: rgb(229 231 235 / var(--tw-border-opacity));
} }
.bg-white { .border-slate-500 {
--tw-border-opacity: 1;
border-color: rgb(100 116 139 / var(--tw-border-opacity));
}
.bg-gray-200 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity)); background-color: rgb(229 231 235 / var(--tw-bg-opacity));
} }
.bg-green-600 { .bg-green-600 {
@@ -881,27 +980,28 @@ select {
background-color: rgb(22 163 74 / var(--tw-bg-opacity)); background-color: rgb(22 163 74 / var(--tw-bg-opacity));
} }
.bg-blue-600 { .bg-violet-600 {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity)); background-color: rgb(124 58 237 / var(--tw-bg-opacity));
} }
.bg-red-600 { .bg-white {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity)); background-color: rgb(255 255 255 / var(--tw-bg-opacity));
} }
.p-4 { .p-4 {
padding: 1rem; padding: 1rem;
} }
.p-2 { .px-2 {
padding: 0.5rem; padding-left: 0.5rem;
padding-right: 0.5rem;
} }
.py-2 { .px-4 {
padding-top: 0.5rem; padding-left: 1rem;
padding-bottom: 0.5rem; padding-right: 1rem;
} }
.py-1 { .py-1 {
@@ -909,9 +1009,9 @@ select {
padding-bottom: 0.25rem; padding-bottom: 0.25rem;
} }
.px-2 { .py-2 {
padding-left: 0.5rem; padding-top: 0.5rem;
padding-right: 0.5rem; padding-bottom: 0.5rem;
} }
.pl-3 { .pl-3 {
@@ -922,12 +1022,21 @@ select {
padding-right: 1rem; padding-right: 1rem;
} }
.pt-1 {
padding-top: 0.25rem;
}
.text-center { .text-center {
text-align: center; text-align: center;
} }
.text-right { .font-mono {
text-align: right; font-family: IBM Plex Mono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
}
.text-3xl {
font-size: 1.875rem;
line-height: 2.25rem;
} }
.text-4xl { .text-4xl {
@@ -935,14 +1044,9 @@ select {
line-height: 2.5rem; line-height: 2.5rem;
} }
.text-xl { .text-5xl {
font-size: 1.25rem; font-size: 3rem;
line-height: 1.75rem; line-height: 1;
}
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
} }
.text-base { .text-base {
@@ -955,13 +1059,27 @@ select {
line-height: 1.75rem; line-height: 1.75rem;
} }
.text-xl {
font-size: 1.25rem;
line-height: 1.75rem;
}
.text-xs {
font-size: 0.75rem;
line-height: 1rem;
}
.font-semibold { .font-semibold {
font-weight: 600; font-weight: 600;
} }
.text-white { .italic {
font-style: italic;
}
.text-gray-700 {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(55 65 81 / var(--tw-text-opacity));
} }
.text-slate-300 { .text-slate-300 {
@@ -969,9 +1087,22 @@ select {
color: rgb(203 213 225 / var(--tw-text-opacity)); color: rgb(203 213 225 / var(--tw-text-opacity));
} }
.text-red-400 { .text-white {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(248 113 113 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
}
.text-yellow-300 {
--tw-text-opacity: 1;
color: rgb(253 224 71 / var(--tw-text-opacity));
}
.underline {
text-decoration-line: underline;
}
.decoration-slate-500 {
text-decoration-color: #64748b;
} }
.shadow-md { .shadow-md {
@@ -980,10 +1111,8 @@ select {
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow); box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
} }
.shadow { .filter {
--tw-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1); filter: var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow);
--tw-shadow-colored: 0 1px 3px 0 var(--tw-shadow-color), 0 1px 2px -1px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
} }
.transition { .transition {
@@ -1002,14 +1131,88 @@ select {
transition-timing-function: cubic-bezier(0.4, 0, 1, 1); transition-timing-function: cubic-bezier(0.4, 0, 1, 1);
} }
.dark form label { .max-w-20char {
max-width: 20ch;
}
.\[a-zA-Z\:\\-\] {
a-z-a--z: \-;
}
@font-face {
font-family: "IBM Plex Mono";
src: url("fonts/IBMPlexMono-regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Sans";
src: url("fonts/IBMPlexSans-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
@font-face {
font-family: "IBM Plex Serif";
src: url("fonts/IBMPlexSerif-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
}
:is(.dark form label) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity)); color: rgb(148 163 184 / var(--tw-text-opacity));
} }
.dark form input,.dark .responsive-table {
select,.dark margin-left: auto;
textarea { margin-right: auto;
}
:is(.dark .responsive-table) {
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
}
.responsive-table tr:nth-child(even) {
--tw-bg-opacity: 1;
background-color: rgb(30 41 59 / var(--tw-bg-opacity));
}
.responsive-table tbody tr:nth-child(odd) {
--tw-bg-opacity: 1;
background-color: rgb(15 23 42 / var(--tw-bg-opacity));
}
.responsive-table thead th {
border-bottom-width: 2px;
--tw-border-opacity: 1;
border-bottom-color: rgb(100 116 139 / var(--tw-border-opacity));
text-align: left;
font-size: 1.25rem;
line-height: 1.75rem;
}
.responsive-table thead th:not(:first-child),
.responsive-table td:not(:first-child) {
border-left-width: 1px;
--tw-border-opacity: 1;
border-left-color: rgb(100 116 139 / var(--tw-border-opacity));
}
:is(.dark form input),:is(.dark
select),:is(.dark
textarea) {
border-width: 1px; border-width: 1px;
--tw-border-opacity: 1; --tw-border-opacity: 1;
border-color: rgb(15 23 42 / var(--tw-border-opacity)); border-color: rgb(15 23 42 / var(--tw-border-opacity));
@@ -1019,9 +1222,20 @@ textarea {
color: rgb(241 245 249 / var(--tw-text-opacity)); color: rgb(241 245 249 / var(--tw-text-opacity));
} }
#session-table { @media screen and (min-width: 768px) {
display: grid; form input,
grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr; select,
textarea {
width: 300px;
}
}
@media screen and (max-width: 768px) {
form input,
select,
textarea {
width: 150px;
}
} }
#button-container button { #button-container button {
@@ -1029,19 +1243,93 @@ textarea {
margin-right: 0.25rem; margin-right: 0.25rem;
} }
th {
text-align: right;
}
th label {
margin-right: 1rem;
}
.basic-button-container {
display: flex;
justify-content: center;
}
.basic-button-container > :not([hidden]) ~ :not([hidden]) {
--tw-space-x-reverse: 0;
margin-right: calc(0.5rem * var(--tw-space-x-reverse));
margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse)));
}
.basic-button {
display: inline-block;
border-radius: 0.25rem;
--tw-bg-opacity: 1;
background-color: rgb(37 99 235 / var(--tw-bg-opacity));
padding-left: 1.5rem;
padding-right: 1.5rem;
padding-top: 0.625rem;
padding-bottom: 0.625rem;
font-size: 0.75rem;
line-height: 1rem;
font-weight: 500;
text-transform: uppercase;
line-height: 1.25;
--tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity));
--tw-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 4px 6px -1px var(--tw-shadow-color), 0 2px 4px -2px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, -webkit-backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter;
transition-property: color, background-color, border-color, text-decoration-color, fill, stroke, opacity, box-shadow, transform, filter, backdrop-filter, -webkit-backdrop-filter;
transition-duration: 150ms;
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
}
.basic-button:hover {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.basic-button:focus {
--tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity));
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
outline: 2px solid transparent;
outline-offset: 2px;
--tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);
--tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(0px + var(--tw-ring-offset-width)) var(--tw-ring-color);
box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000);
}
.basic-button:active {
--tw-bg-opacity: 1;
background-color: rgb(30 64 175 / var(--tw-bg-opacity));
--tw-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);
--tw-shadow-colored: 0 10px 15px -3px var(--tw-shadow-color), 0 4px 6px -4px var(--tw-shadow-color);
box-shadow: var(--tw-ring-offset-shadow, 0 0 #0000), var(--tw-ring-shadow, 0 0 #0000), var(--tw-shadow);
}
.hover\:bg-gray-400:hover {
--tw-bg-opacity: 1;
background-color: rgb(156 163 175 / var(--tw-bg-opacity));
}
.hover\:bg-green-700:hover { .hover\:bg-green-700:hover {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(21 128 61 / var(--tw-bg-opacity)); background-color: rgb(21 128 61 / var(--tw-bg-opacity));
} }
.hover\:bg-blue-700:hover { .hover\:bg-violet-700:hover {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(29 78 216 / var(--tw-bg-opacity)); background-color: rgb(109 40 217 / var(--tw-bg-opacity));
}
.hover\:bg-red-700:hover {
--tw-bg-opacity: 1;
background-color: rgb(185 28 28 / var(--tw-bg-opacity));
} }
.hover\:underline:hover { .hover\:underline:hover {
@@ -1064,14 +1352,9 @@ textarea {
--tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity)); --tw-ring-color: rgb(34 197 94 / var(--tw-ring-opacity));
} }
.focus\:ring-blue-500:focus { .focus\:ring-violet-500:focus {
--tw-ring-opacity: 1; --tw-ring-opacity: 1;
--tw-ring-color: rgb(59 130 246 / var(--tw-ring-opacity)); --tw-ring-color: rgb(139 92 246 / var(--tw-ring-opacity));
}
.focus\:ring-red-500:focus {
--tw-ring-opacity: 1;
--tw-ring-color: rgb(239 68 68 / var(--tw-ring-opacity));
} }
.focus\:ring-offset-2:focus { .focus\:ring-offset-2:focus {
@@ -1082,44 +1365,72 @@ textarea {
--tw-ring-offset-color: #bfdbfe; --tw-ring-offset-color: #bfdbfe;
} }
.dark .dark\:border-white { .focus\:ring-offset-violet-200:focus {
--tw-border-opacity: 1; --tw-ring-offset-color: #ddd6fe;
border-color: rgb(255 255 255 / var(--tw-border-opacity));
} }
.dark .dark\:bg-gray-800 { .group:hover .group-hover\:block {
display: block;
}
:is(.dark .dark\:bg-gray-800) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(31 41 55 / var(--tw-bg-opacity)); background-color: rgb(31 41 55 / var(--tw-bg-opacity));
} }
.dark .dark\:bg-gray-900 { :is(.dark .dark\:bg-gray-900) {
--tw-bg-opacity: 1; --tw-bg-opacity: 1;
background-color: rgb(17 24 39 / var(--tw-bg-opacity)); background-color: rgb(17 24 39 / var(--tw-bg-opacity));
} }
.dark .dark\:bg-slate-700 { :is(.dark .dark\:text-slate-500) {
--tw-bg-opacity: 1;
background-color: rgb(51 65 85 / var(--tw-bg-opacity));
}
.dark .dark\:text-white {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(255 255 255 / var(--tw-text-opacity)); color: rgb(100 116 139 / var(--tw-text-opacity));
} }
.dark .dark\:text-slate-600 { :is(.dark .dark\:text-slate-600) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(71 85 105 / var(--tw-text-opacity)); color: rgb(71 85 105 / var(--tw-text-opacity));
} }
.dark .dark\:text-slate-400 { :is(.dark .dark\:text-white) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity)); color: rgb(255 255 255 / var(--tw-text-opacity));
} }
.dark .dark\:text-slate-300 { @media (min-width: 640px) {
--tw-text-opacity: 1; .sm\:inline {
color: rgb(203 213 225 / var(--tw-text-opacity)); display: inline;
}
.sm\:table-cell {
display: table-cell;
}
.sm\:max-w-md {
max-width: 28rem;
}
.sm\:max-w-xl {
max-width: 36rem;
}
.sm\:px-4 {
padding-left: 1rem;
padding-right: 1rem;
}
.sm\:pl-2 {
padding-left: 0.5rem;
}
.sm\:pl-4 {
padding-left: 1rem;
}
.sm\:decoration-2 {
text-decoration-thickness: 2px;
}
} }
@media (min-width: 768px) { @media (min-width: 768px) {
@@ -1127,6 +1438,10 @@ textarea {
display: block; display: block;
} }
.md\:w-1\/2 {
width: 50%;
}
.md\:w-auto { .md\:w-auto {
width: auto; width: auto;
} }
@@ -1134,4 +1449,32 @@ textarea {
.md\:flex-row { .md\:flex-row {
flex-direction: row; flex-direction: row;
} }
.md\:px-6 {
padding-left: 1.5rem;
padding-right: 1.5rem;
}
.md\:py-2 {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
}
.md\:max-w-40char {
max-width: 40ch;
}
}
@media (min-width: 1024px) {
.lg\:table-cell {
display: table-cell;
}
.lg\:max-w-3xl {
max-width: 48rem;
}
.lg\:max-w-lg {
max-width: 32rem;
}
} }
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 787 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

+29
View File
@@ -0,0 +1,29 @@
/**
* @description Sync select field with input field until user focuses it.
* @param {HTMLSelectElement} sourceElementSelector
* @param {HTMLInputElement} targetElementSelector
*/
function syncSelectInputUntilChanged(
sourceElementSelector,
targetElementSelector
) {
const sourceElement = document.querySelector(sourceElementSelector);
const targetElement = document.querySelector(targetElementSelector);
function sourceElementHandler(event) {
let selected = event.target.value;
let selectedValue = document.querySelector(
`#id_game option[value='${selected}']`
).textContent;
targetElement.value = selectedValue;
}
function targetElementHandler(event) {
sourceElement.removeEventListener("change", sourceElementHandler);
}
sourceElement.addEventListener("change", sourceElementHandler);
targetElement.addEventListener("focus", targetElementHandler);
}
window.addEventListener("load", () => {
syncSelectInputUntilChanged("#id_game", "#id_name");
});
+19
View File
@@ -0,0 +1,19 @@
import { toISOUTCString } from "./utils.js";
for (let button of document.querySelectorAll("[data-target]")) {
let target = button.getAttribute("data-target");
let type = button.getAttribute("data-type");
let targetElement = document.querySelector(`#id_${target}`);
button.addEventListener("click", (event) => {
event.preventDefault();
if (type == "now") {
targetElement.value = toISOUTCString(new Date);
} else if (type == "copy") {
const oppositeName = targetElement.name == "timestamp_start" ? "timestamp_end" : "timestamp_start";
document.querySelector(`[name='${oppositeName}']`).value = targetElement.value;
} else if (type == "toggle") {
if (targetElement.type == "datetime-local") targetElement.type = "text";
else targetElement.type = "datetime-local";
}
});
}
+1
View File
File diff suppressed because one or more lines are too long
+16
View File
@@ -0,0 +1,16 @@
/**
* @description Formats Date to a UTC string accepted by the datetime-local input field.
* @param {Date} date
* @returns {string}
*/
export function toISOUTCString(date) {
function stringAndPad(number) {
return number.toString().padStart(2, 0);
}
const year = date.getFullYear();
const month = stringAndPad(date.getMonth() + 1);
const day = stringAndPad(date.getDate());
const hours = stringAndPad(date.getHours());
const minutes = stringAndPad(date.getMinutes());
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
+43
View File
@@ -0,0 +1,43 @@
function elt(type, props, ...children) {
let dom = document.createElement(type);
if (props) Object.assign(dom, props);
for (let child of children) {
if (typeof child != "string") dom.appendChild(child);
else dom.appendChild(document.createTextNode(child));
}
return dom;
}
/**
* @param {Node} targetNode
*/
function addToggleButton(targetNode) {
let manualToggleButton = elt(
"td",
{},
elt(
"div",
{ className: "basic-button" },
elt(
"button",
{
onclick: (event) => {
let textInputField = elt("input", { type: "text", id: targetNode.id });
targetNode.replaceWith(textInputField);
event.target.addEventListener("click", (event) => {
textInputField.replaceWith(targetNode);
});
},
},
"Toggle manual"
)
)
);
targetNode.parentElement.appendChild(manualToggleButton);
}
const toggleableFields = ["#id_game", "#id_edition", "#id_platform"];
toggleableFields.map((selector) => {
addToggleButton(document.querySelector(selector));
});
+2
View File
@@ -0,0 +1,2 @@
User-agent: *
Disallow: /
+9 -5
View File
@@ -3,11 +3,15 @@
{% block title %}{{ title }}{% endblock title %} {% block title %}{{ title }}{% endblock title %}
{% block content %} {% block content %}
<form method="post" enctype="multipart/form-data" class="mx-auto"> <form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %} {% csrf_token %}
{{ form.as_p }} {{ form.as_table }}
<tr>
<input type="submit" value="Submit"/> <td></td>
<td><input type="submit" value="Submit"/></td>
</tr>
</table>
</form> </form>
{% endblock content %} {% endblock content %}
+22
View File
@@ -0,0 +1,22 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{{ form.as_table }}
<tr>
<td></td>
<td><input type="submit" value="Submit"/></td>
</tr>
</table>
</form>
{% endblock content %}
{% block scripts %}
{% load static %}
<script type="module" src="{% static 'js/add_edition.js' %}"></script>
{% endblock scripts %}
+37
View File
@@ -0,0 +1,37 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% block content %}
<form method="post" enctype="multipart/form-data">
<table class="mx-auto">
{% csrf_token %}
{% for field in form %}
<tr>
<th>{{ field.label_tag }}</th>
{% if field.name == "note" %}
<td>{{ field }}</td>
{% else %}
<td>{{ field }}</td>
{% endif %}
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
<td>
<div class="basic-button-container">
<button class="basic-button" data-target="{{field.name}}" data-type="now">Set to now</button>
<button class="basic-button" data-target="{{field.name}}" data-type="toggle">Toggle text</button>
<button class="basic-button" data-target="{{field.name}}" data-type="copy">Copy</button>
</div>
</td>
{% endif %}
</tr>
{% endfor %}
<tr>
<td></td>
<td><input type="submit" value="Submit"/></td>
</tr>
</table>
</form>
{% load static %}
<script type="module" src="{% static 'js/add_session.js' %}"></script>
{% endblock content %}
+37 -13
View File
@@ -9,30 +9,53 @@
<meta name="keywords" content="time, tracking, video games, self-hosted"/> <meta name="keywords" content="time, tracking, video games, self-hosted"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/> <meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Timetracker - {% block title %}Untitled{% endblock title %}</title> <title>Timetracker - {% block title %}Untitled{% endblock title %}</title>
<link rel="stylesheet" href="https://rsms.me/inter/inter.css"/> <script src="{% static 'js/htmx.min.js' %}"></script>
<link rel="stylesheet" href="{% static 'base.css' %}" /> <link rel="stylesheet" href="{% static 'base.css' %}" />
</head> </head>
<body class="dark"> <body class="dark">
<img id="indicator" src="{% static 'icons/loading.png' %}" class="absolute right-3 top-3 animate-spin htmx-indicator" />
<div class="dark:bg-gray-800 min-h-screen"> <div class="dark:bg-gray-800 min-h-screen">
<nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded"> <nav class="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
<div class="container flex flex-wrap items-center justify-between mx-auto"> <div class="container flex flex-wrap items-center justify-between mx-auto">
<a href="{% url 'index' %}" class="flex items-center"> <a href="{% url 'list_sessions_recent' %}" class="flex items-center">
<span class="text-4xl"></span> <span class="text-4xl"><img src="{% static 'icons/schedule.png' %}" width="48" class="mr-4" /></span>
<span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span> <span class="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
</a> </a>
<div class="w-full md:block md:w-auto"> <div class="w-full md:block md:w-auto">
<ul <ul
class="flex flex-col md:flex-row p-4 mt-4 dark:text-white"> class="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li> <li class="relative group">
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li> <a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New</a>
{% if game_available and platform_available %} <ul class="absolute hidden text-gray-700 pt-1 group-hover:block w-auto whitespace-nowrap">
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li> {% if purchase_available %}
{% endif %} <li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_device' %}">Device</a></li>
{% if purchase_available %} {% endif %}
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li> <li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_game' %}">Game</a></li>
{% endif %} {% if game_available and platform_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_edition' %}">Edition</a></li>
{% endif %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_platform' %}">Platform</a></li>
{% if edition_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_purchase' %}">Purchase</a></li>
{% endif %}
{% if purchase_available %}
<li><a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'add_session' %}">Session</a></li>
{% endif %}
</ul>
</li>
{% if session_count > 0 %} {% if session_count > 0 %}
<li class="relative group">
<a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'stats_current_year' %}">Stats</a>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block">
{% for year in stats_dropdown_year_range %}
<li>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" href="{% url 'stats_by_year' year %}">{{ year }}</a>
</li>
{% endfor %}
</ul>
</li>
<li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
{% endif %} {% endif %}
</ul> </ul>
@@ -43,6 +66,7 @@
</div> </div>
{% load version %} {% load version %}
<span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> <span class="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span>
{% block scripts %}{% endblock scripts %}
</body> </body>
</html> </html>
+26
View File
@@ -0,0 +1,26 @@
{% comment %}
title
text
{% endcomment %}
<a
href="{{ link }}"
title="{{ title }}"
class="truncate max-w-xs py-1 px-2 text-xs bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-sm"
>
{% comment %} <svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="self-center w-6 h-6 inline"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
/>
</svg>
{% endcomment %}
{{ text }}
</a>
@@ -0,0 +1,26 @@
{% comment %}
title
text
{% endcomment %}
<button
type="button"
title="{{ title }}"
autofocus
class="truncate max-w-xs sm:max-w-md lg:max-w-lg py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="self-center w-6 h-6 inline"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z"
/>
</svg>
{{ text }}
</button>
@@ -0,0 +1,21 @@
<a href="{{ edit_url }}">
<button
type="button"
title="Edit"
class="ml-1 py-1 px-2 flex justify-center items-center bg-violet-600 hover:bg-violet-700 focus:ring-violet-500 focus:ring-offset-violet-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg"
>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 20 20"
fill="currentColor"
class="w-5 h-5"
>
<path
d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z"
/>
<path
d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z"
/>
</svg>
</button>
</a>
+2 -2
View File
@@ -5,7 +5,7 @@
{% block content %} {% block content %}
<div class="text-slate-300 mx-auto max-w-screen-lg text-center"> <div class="text-slate-300 mx-auto max-w-screen-lg text-center">
{% if session_count > 0 %} {% if session_count > 0 %}
You have played a total of {{ session_count }} sessions for a total of {{ total_duration }}. You have played a total of {{ session_count }} sessions for a total of {{ total_duration_formatted }}.
{% elif not game_available or not platform_available %} {% elif not game_available or not platform_available %}
There are no games in the database. Start by clicking "New Game" and "New Platform". There are no games in the database. Start by clicking "New Game" and "New Platform".
{% elif not purchase_available %} {% elif not purchase_available %}
@@ -14,4 +14,4 @@
You haven't played any games yet. Click "New Session" to add one now. You haven't played any games yet. Click "New Session" to add one now.
{% endif %} {% endif %}
</div> </div>
{% endblock content %} {% endblock content %}
+73 -77
View File
@@ -1,81 +1,77 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% block title %}Sessions{% endblock title %} {% load static %}
{% block title %}{{ title }}{% endblock title %}
{% block content %} {% block content %}
<div class="text-center text-xl mb-4 dark:text-slate-400">
{% if dataset.count >= 2 %} {% if dataset.count >= 1 %}
<img src="data:image/svg+xml;base64,{{ chart|safe }}" class="mx-auto mb-3" /> <div class="mx-auto text-center my-4">
{% endif %} <a
{% if dataset.count >= 1 %} id="last-session-start"
<div class="mb-4">Total playtime: {{ total_duration }} over {{ dataset.count }} sessions.</div> href="{% url 'start_session_same_as_last' last.id %}"
{% endif %} hx-get="{% url 'start_session_same_as_last' last.id %}"
{% if purchase or platform or game %} hx-indicator="#indicator"
<a class="text-red-400 inline" href="{% url 'list_sessions' %}"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-6 h-6 inline"> hx-swap="afterbegin"
<path stroke-linecap="round" stroke-linejoin="round" d="M9.75 9.75l4.5 4.5m0-4.5l-4.5 4.5M21 12a9 9 0 11-18 0 9 9 0 0118 0z" /> hx-target=".responsive-table tbody"
</svg> hx-select=".responsive-table tbody tr:first-child"
</a><span>Filtering by "{% firstof purchase platform game %}"</span> onClick="document.querySelector('#last-session-start').classList.add('invisible')"
{% if purchase %}<a class="dark:text-white hover:underline block" href="{% url 'list_sessions_by_game' purchase.game.id %}">See all platforms</a>{% endif %} class="{% if last.timestamp_end == null %}invisible{% endif %}"
{% endif %} >
{% if dataset.count >= 1 %} {% include 'components/button_start.html' with text=last.purchase title="Start session of last played game" only %}
<a class="clear-both" href="{% url 'start_session' last.purchase.id %}"> </a>
<button type="button" title="Track last tracked" class="mt-10 py-1 px-2 bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 rounded-lg "> </div>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="self-center w-6 h-6 inline"> {% endif %}
<path stroke-linecap="round" stroke-linejoin="round" d="M5.25 5.653c0-.856.917-1.398 1.667-.986l11.54 6.348a1.125 1.125 0 010 1.971l-11.54 6.347a1.125 1.125 0 01-1.667-.985V5.653z" />
</svg> <table class="responsive-table">
{{ last.purchase }} <thead>
</button> <tr>
</a> <th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
{% endif %} <th class="hidden sm:table-cell px-2 sm:px-4 md:px-6 md:py-2">Start</th>
</div> <th class="hidden lg:table-cell px-2 sm:px-4 md:px-6 md:py-2">End</th>
<div id="session-table" class="gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center"> <th class="px-2 sm:px-4 md:px-6 md:py-2">Duration</th>
<div class="dark:border-white dark:text-slate-300 text-lg">Name</div> </tr>
<div class="dark:border-white dark:text-slate-300 text-lg">Platform</div> </thead>
<div class="dark:border-white dark:text-slate-300 text-lg text-center">Start</div> <tbody>
<div class="dark:border-white dark:text-slate-300 text-lg text-center">End</div> {% for data in dataset %}
<div class="dark:border-white dark:text-slate-300 text-lg">Duration</div> <tr>
<div class="dark:border-white dark:text-slate-300 text-lg text-right">Manage</div> <td
{% for data in dataset %} class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char"
<div class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"><a class="hover:underline" href="{% url 'list_sessions_by_purchase' data.purchase.id %}">{{ data.purchase.game }}</a></div> >
<div class="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap"><a class="hover:underline" href="{% url 'list_sessions_by_platform' data.purchase.platform.id %}">{{ data.purchase.platform }}</a></div> <a
<div class="dark:text-slate-400 text-center">{{ data.timestamp_start | date:"d/m/Y H:i" }}</div> class="underline decoration-slate-500 sm:decoration-2"
<div class="dark:text-slate-400 text-center"> href="{% url 'view_game' data.purchase.edition.game.id %}">
{% if data.unfinished %} {{ data.purchase.edition }}
<span class="text-red-400">Not finished yet.</span> </a>
{% elif data.duration_manual %} </td>
-- <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell">
{% else %} {{ data.timestamp_start | date:"d/m/Y H:i" }}
{{ data.timestamp_end | date:"d/m/Y H:i" }} </td>
{% endif %} <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
</div> {% if data.unfinished %}
<div class="dark:text-slate-400 flex">{{ data.duration_formatted }}{% if data.duration_manual %} <svg title="Added manually" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5 ml-1 self-center"> <a
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a.75.75 0 000 1.5h.253a.25.25 0 01.244.304l-.459 2.066A1.75 1.75 0 0010.747 15H11a.75.75 0 000-1.5h-.253a.25.25 0 01-.244-.304l.459-2.066A1.75 1.75 0 009.253 9H9z" clip-rule="evenodd" /> href="{% url 'update_session' data.id %}"
</svg> hx-get="{% url 'update_session' data.id %}"
{% endif %}</div> hx-swap="outerHTML"
<div id="button-container" class="flex justify-end"> hx-target=".responsive-table tbody tr:first-child"
{% if data.unfinished %} hx-select=".responsive-table tbody tr:first-child"
<a href="{% url 'update_session' data.id %}"> hx-indicator="#indicator"
<button type="button" title="Set to finished" class="py-1 px-2 flex justify-center items-center bg-green-600 hover:bg-green-700 focus:ring-green-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg "> onClick="document.querySelector('#last-session-start').classList.remove('invisible')"
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-4 h-4"> >
<path fill-rule="evenodd" d="M16.704 4.153a.75.75 0 01.143 1.052l-8 10.5a.75.75 0 01-1.127.075l-4.5-4.5a.75.75 0 011.06-1.06l3.894 3.893 7.48-9.817a.75.75 0 011.05-.143z" clip-rule="evenodd" /> <span class="text-yellow-300">Finish now?</span>
</svg> </a>
</button> {% elif data.duration_manual %}
</a> --
{% endif %} {% else %}
<button type="button" title="Edit" class="py-1 px-2 flex justify-center items-center bg-blue-600 hover:bg-blue-700 focus:ring-blue-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg "> {{ data.timestamp_end | date:"d/m/Y H:i" }}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"> {% endif %}
<path d="M5.433 13.917l1.262-3.155A4 4 0 017.58 9.42l6.92-6.918a2.121 2.121 0 013 3l-6.92 6.918c-.383.383-.84.685-1.343.886l-3.154 1.262a.5.5 0 01-.65-.65z" /> </td>
<path d="M3.5 5.75c0-.69.56-1.25 1.25-1.25H10A.75.75 0 0010 3H4.75A2.75 2.75 0 002 5.75v9.5A2.75 2.75 0 004.75 18h9.5A2.75 2.75 0 0017 15.25V10a.75.75 0 00-1.5 0v5.25c0 .69-.56 1.25-1.25 1.25h-9.5c-.69 0-1.25-.56-1.25-1.25v-9.5z" /> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
</svg> {{ data.duration_formatted }}
</button> </td>
<a href="{% url 'delete_session' data.id %}"> </tr>
<button type="button" edit="Delete" class="py-1 px-2 flex justify-center items-center bg-red-600 hover:bg-red-700 focus:ring-red-500 focus:ring-offset-blue-200 text-white transition ease-in duration-200 text-center text-base font-semibold shadow-md focus:outline-none focus:ring-2 focus:ring-offset-2 w-7 h-4 rounded-lg "> {% endfor %}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5"> </tbody>
<path fill-rule="evenodd" d="M8.75 1A2.75 2.75 0 006 3.75v.443c-.795.077-1.584.176-2.365.298a.75.75 0 10.23 1.482l.149-.022.841 10.518A2.75 2.75 0 007.596 19h4.807a2.75 2.75 0 002.742-2.53l.841-10.52.149.023a.75.75 0 00.23-1.482A41.03 41.03 0 0014 4.193V3.75A2.75 2.75 0 0011.25 1h-2.5zM10 4c.84 0 1.673.025 2.5.075V3.75c0-.69-.56-1.25-1.25-1.25h-2.5c-.69 0-1.25.56-1.25 1.25v.325C8.327 4.025 9.16 4 10 4zM8.58 7.72a.75.75 0 00-1.5.06l.3 7.5a.75.75 0 101.5-.06l-.3-7.5zm4.34.06a.75.75 0 10-1.5-.06l-.3 7.5a.75.75 0 101.5.06l.3-7.5z" clip-rule="evenodd" /> </table>
</svg> {% endblock content %}
</button>
</a>
</div>
{% endfor %}
</div>
{% endblock content %}
+184
View File
@@ -0,0 +1,184 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% load static %}
{% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<div class="flex justify-center items-center">
<form method="get" class="text-center">
<label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label>
<select name="year" id="yearSelect" onchange="this.form.submit();" class="mx-2">
{% for year_item in stats_dropdown_year_range %}
<option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option>
{% endfor %}
</select>
</form>
</div>
<div class="flex flex-column flex-wrap justify-center">
<div class="md:w-1/2">
<h1 class="text-5xl text-center my-6">Playtime</h1>
<table class="responsive-table">
<tbody>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Hours</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_hours }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Sessions</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_sessions }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Days</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ unique_days }} ({{ unique_days_percent }}%)</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_games }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Games ({{ year }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_2023_games }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Finished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_finished_this_year.count }}</td>
</tr>
</tbody>
</table>
</div>
<div class="md:w-1/2">
<h1 class="text-5xl text-center my-6">Purchases</h1>
<table class="responsive-table">
<tbody>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Total</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_this_year.count }}</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Refunded</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_refunded_this_year.count }} ({{ refunded_percent }}%)</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Unfinished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)</td>
</tr>
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Spendings ({{ total_spent_currency }})</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ total_spent }} ({{ spent_per_game }}/game)</td>
</tr>
</tbody>
</table>
</div>
</div>
<h1 class="text-5xl text-center my-6">Top games by playtime</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th>
</tr>
</thead>
<tbody>
{% for game in top_10_games_by_playtime %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'view_game' game.id %}">{{ game.name }}
</a>
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Platforms by playtime</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Platform</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Playtime (hours)</th>
</tr>
</thead>
<tbody>
{% for item in total_playtime_per_platform %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.platform_name }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.formatted_playtime }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Finished</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in all_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Finished (2023 games)</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in this_year_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">Bought and Finished ({{ year }})</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in purchased_this_year_finished_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
<h1 class="text-5xl text-center my-6">All Purchases</h1>
<table class="responsive-table">
<thead>
<tr>
<th class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char">Name</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Price ({{ total_spent_currency }})</th>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Date</th>
</tr>
</thead>
<tbody>
{% for purchase in all_purchased_this_year %}
<tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"><a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a></td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock content %}
+87
View File
@@ -0,0 +1,87 @@
{% extends "base.html" %}
{% block title %}{{ title }}{% endblock title %}
{% load static %}
{% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto">
<h1 class="text-4xl flex items-center">
{{ game.name }}
<span class="dark:text-slate-500">(#{{ game.pk }})</span>
{% url 'edit_game' game.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</h1>
<h2 class="text-lg my-2 ml-2">
{{ total_hours }} <span class="dark:text-slate-500">total</span>
{{ session_average }} <span class="dark:text-slate-500">avg</span>
({{ playrange }}) </h2>
<hr class="border-slate-500">
<h1 class="text-3xl mt-4 mb-1">Editions <span class="dark:text-slate-500">({{ editions.count }})</span></h1>
<ul>
{% for edition in editions %}
<li class="sm:pl-2 flex items-center">
{{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }})
{% if edition.wikidata %}
<span class="hidden sm:inline">
<a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}">
<img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}"/>
</a>
</span>
{% endif %}
{% url 'edit_edition' edition.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1">Purchases <span class="dark:text-slate-500">({{ purchases.count }})</span></h1>
<ul>
{% for purchase in purchases %}
<li class="sm:pl-2 flex items-center">
{{ purchase.platform }}
({{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }}, {{ purchase.price }} {{ purchase.price_currency}})
{% url 'edit_purchase' purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center">
Sessions
<span class="dark:text-slate-500">
({{ sessions.count }})
</span>
{% url 'start_game_session' game.id as add_session_link %}
{% include 'components/button.html' with title="Start new session" text="New" link=add_session_link %}
</h1>
<ul>
{% for session in sessions %}
<li class="sm:pl-2 flex items-center">
{{ session.timestamp_start | date:"d/m/Y" }}
({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }})
{% url 'edit_session' session.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %}
</li>
{% endfor %}
</ul>
<h1 class="text-3xl mt-4 mb-1">Notes <span class="dark:text-slate-500">({{ sessions_with_notes.count }})</span></h1>
<ul>
{% for session in sessions_with_notes %}
<li class="sm:pl-2">
<ul>
<li class="block dark:text-slate-500">
<span class="flex items-center">
{{ session.timestamp_start | date:"d/m/Y H:m" }}
{% url 'edit_session' session.id as edit_session_url %}
{% include 'components/edit_button.html' with edit_url=edit_session_url %}
</span>
</li>
<li class="sm:pl-4 italic">
{{ session.note|linebreaks }}
</li>
</ul>
</li>
{% endfor %}
</ul>
</div>
{% endblock content %}
+43 -6
View File
@@ -4,6 +4,12 @@ from games import views
urlpatterns = [ urlpatterns = [
path("", views.index, name="index"), path("", views.index, name="index"),
path(
"list-sessions/recent",
views.list_sessions,
{"filter": "recent"},
name="list_sessions_recent",
),
path("add-game/", views.add_game, name="add_game"), path("add-game/", views.add_game, name="add_game"),
path("add-platform/", views.add_platform, name="add_platform"), path("add-platform/", views.add_platform, name="add_platform"),
path("add-session/", views.add_session, name="add_session"), path("add-session/", views.add_session, name="add_session"),
@@ -13,16 +19,29 @@ urlpatterns = [
name="update_session", name="update_session",
), ),
path( path(
"start-session/<int:purchase_id>", "start-session-same-as-last/<int:last_session_id>",
views.start_session, views.start_session_same_as_last,
name="start_session", name="start_session_same_as_last",
), ),
path( path(
"delete_session/by-id/<int:session_id>", "start-session/<int:game_id>",
views.delete_session, views.start_game_session,
name="delete_session", name="start_game_session",
), ),
# path(
# "delete_session/by-id/<int:session_id>",
# views.delete_session,
# name="delete_session",
# ),
path("add-purchase/", views.add_purchase, name="add_purchase"), path("add-purchase/", views.add_purchase, name="add_purchase"),
path("add-edition/", views.add_edition, name="add_edition"),
path("edit-edition/<int:edition_id>", views.edit_edition, name="edit_edition"),
path("game/<int:game_id>/view", views.view_game, name="view_game"),
path("game/<int:game_id>/edit", views.edit_game, name="edit_game"),
path("edit-platform/<int:platform_id>", views.edit_platform, name="edit_platform"),
path("add-device/", views.add_device, name="add_device"),
path("edit-session/<int:session_id>", views.edit_session, name="edit_session"),
path("edit-purchase/<int:purchase_id>", views.edit_purchase, name="edit_purchase"),
path("list-sessions/", views.list_sessions, name="list_sessions"), path("list-sessions/", views.list_sessions, name="list_sessions"),
path( path(
"list-sessions/by-purchase/<int:purchase_id>", "list-sessions/by-purchase/<int:purchase_id>",
@@ -42,4 +61,22 @@ urlpatterns = [
{"filter": "game"}, {"filter": "game"},
name="list_sessions_by_game", name="list_sessions_by_game",
), ),
path(
"list-sessions/by-edition/<int:edition_id>",
views.list_sessions,
{"filter": "edition"},
name="list_sessions_by_edition",
),
path(
"list-sessions/by-ownership/<str:ownership_type>",
views.list_sessions,
{"filter": "ownership_type"},
name="list_sessions_by_ownership_type",
),
path("stats/", views.stats, name="stats_current_year"),
path(
"stats/<int:year>",
views.stats,
name="stats_by_year",
),
] ]
+351 -25
View File
@@ -1,24 +1,40 @@
from datetime import datetime from common.time import format_duration, now as now_with_tz
from common.utils import safe_division
from datetime import datetime, timedelta
from django.conf import settings
from django.db.models import Sum, F, Count
from django.db.models.functions import TruncDate
from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
from django.shortcuts import redirect, render
from django.urls import reverse
from typing import Callable, Any
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from common.plots import playtime_over_time_chart from .forms import (
from common.time import now as now_with_tz GameForm,
from django.conf import settings PlatformForm,
from django.shortcuts import redirect, render PurchaseForm,
SessionForm,
from .forms import GameForm, PlatformForm, PurchaseForm, SessionForm EditionForm,
from .models import Game, Platform, Purchase, Session DeviceForm,
)
from .models import Game, Platform, Purchase, Session, Edition
def model_counts(request): def model_counts(request):
return { return {
"game_available": Game.objects.count() != 0, "game_available": Game.objects.count() != 0,
"edition_available": Edition.objects.count() != 0,
"platform_available": Platform.objects.count() != 0, "platform_available": Platform.objects.count() != 0,
"purchase_available": Purchase.objects.count() != 0, "purchase_available": Purchase.objects.count() != 0,
"session_count": Session.objects.count(), "session_count": Session.objects.count(),
} }
def stats_dropdown_year_range(request):
return {"stats_dropdown_year_range": range(2018, 2024)}
def add_session(request): def add_session(request):
context = {} context = {}
initial = {} initial = {}
@@ -37,7 +53,7 @@ def add_session(request):
context["title"] = "Add New Session" context["title"] = "Add New Session"
context["form"] = form context["form"] = form
return render(request, "add.html", context) return render(request, "add_session.html", context)
def update_session(request, session_id=None): def update_session(request, session_id=None):
@@ -47,20 +63,170 @@ def update_session(request, session_id=None):
return redirect("list_sessions") return redirect("list_sessions")
def start_session(request, purchase_id=None): def use_custom_redirect(
session = SessionForm({"purchase": purchase_id, "timestamp_start": now_with_tz()}) func: Callable[..., HttpResponse]
) -> Callable[..., HttpResponse]:
"""
Will redirect to "return_path" session variable if set.
"""
def wrapper(request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
response = func(request, *args, **kwargs)
if isinstance(response, HttpResponseRedirect) and (
next_url := request.session.get("return_path")
):
return HttpResponseRedirect(next_url)
return response
return wrapper
@use_custom_redirect
def edit_session(request, session_id=None):
context = {}
session = Session.objects.get(id=session_id)
form = SessionForm(request.POST or None, instance=session)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Session"
context["form"] = form
return render(request, "add_session.html", context)
@use_custom_redirect
def edit_purchase(request, purchase_id=None):
context = {}
purchase = Purchase.objects.get(id=purchase_id)
form = PurchaseForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Purchase"
context["form"] = form
return render(request, "add.html", context)
@use_custom_redirect
def edit_game(request, game_id=None):
context = {}
purchase = Game.objects.get(id=game_id)
form = GameForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Game"
context["form"] = form
return render(request, "add.html", context)
def view_game(request, game_id=None):
context = {}
game = Game.objects.get(id=game_id)
context["title"] = "View Game"
context["game"] = game
context["editions"] = Edition.objects.filter(game_id=game_id)
context["purchases"] = Purchase.objects.filter(edition__game_id=game_id)
context["sessions"] = Session.objects.filter(
purchase__edition__game_id=game_id
).order_by("-timestamp_start")
context["total_hours"] = float(
format_duration(context["sessions"].total_duration_unformatted(), "%2.1H")
)
context["session_average"] = round(
(context["total_hours"]) / int(context["sessions"].count()), 1
)
# here first and last is flipped
# because sessions are ordered from newest to oldest
# so the most recent are on top
playrange_start = context["sessions"].last().timestamp_start.strftime("%b %Y")
playrange_end = context["sessions"].first().timestamp_start.strftime("%b %Y")
context["playrange"] = (
playrange_start
if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}"
)
context["sessions_with_notes"] = context["sessions"].exclude(note="")
request.session["return_path"] = request.path
return render(request, "view_game.html", context)
@use_custom_redirect
def edit_platform(request, platform_id=None):
context = {}
purchase = Platform.objects.get(id=platform_id)
form = PlatformForm(request.POST or None, instance=purchase)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Platform"
context["form"] = form
return render(request, "add.html", context)
@use_custom_redirect
def edit_edition(request, edition_id=None):
context = {}
edition = Edition.objects.get(id=edition_id)
form = EditionForm(request.POST or None, instance=edition)
if form.is_valid():
form.save()
return redirect("list_sessions")
context["title"] = "Edit Edition"
context["form"] = form
return render(request, "add.html", context)
@use_custom_redirect
def start_game_session(request, game_id: int):
last_session = (
Session.objects.filter(purchase__edition__game_id=game_id)
.order_by("-timestamp_start")
.first()
)
session = SessionForm(
{
"purchase": last_session.purchase.id,
"timestamp_start": now_with_tz(),
"device": last_session.device,
}
)
session.save() session.save()
return redirect("list_sessions") return redirect("list_sessions")
def delete_session(request, session_id=None): def start_session_same_as_last(request, last_session_id: int):
session = Session.objects.get(id=session_id) last_session = Session.objects.get(id=last_session_id)
session.delete() session = SessionForm(
{
"purchase": last_session.purchase.id,
"timestamp_start": now_with_tz(),
"device": last_session.device,
}
)
session.save()
return redirect("list_sessions") return redirect("list_sessions")
def list_sessions(request, filter="", purchase_id="", platform_id="", game_id=""): # def delete_session(request, session_id=None):
# session = Session.objects.get(id=session_id)
# session.delete()
# return redirect("list_sessions")
def list_sessions(
request,
filter="",
purchase_id="",
platform_id="",
game_id="",
edition_id="",
ownership_type: str = "",
):
context = {} context = {}
context["title"] = "Sessions"
if filter == "purchase": if filter == "purchase":
dataset = Session.objects.filter(purchase=purchase_id) dataset = Session.objects.filter(purchase=purchase_id)
@@ -68,29 +234,168 @@ def list_sessions(request, filter="", purchase_id="", platform_id="", game_id=""
elif filter == "platform": elif filter == "platform":
dataset = Session.objects.filter(purchase__platform=platform_id) dataset = Session.objects.filter(purchase__platform=platform_id)
context["platform"] = Platform.objects.get(id=platform_id) context["platform"] = Platform.objects.get(id=platform_id)
elif filter == "edition":
dataset = Session.objects.filter(purchase__edition=edition_id)
context["edition"] = Edition.objects.get(id=edition_id)
elif filter == "game": elif filter == "game":
dataset = Session.objects.filter(purchase__game=game_id) dataset = Session.objects.filter(purchase__edition__game=game_id)
context["game"] = Game.objects.get(id=game_id) context["game"] = Game.objects.get(id=game_id)
elif filter == "ownership_type":
dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
elif filter == "recent":
current_year = datetime.now().year
first_day_of_year = datetime(current_year, 1, 1)
dataset = Session.objects.filter(
timestamp_start__gte=first_day_of_year
).order_by("-timestamp_start")
context["title"] = "This year"
else: else:
# by default, sort from newest to oldest # by default, sort from newest to oldest
dataset = Session.objects.all().order_by("-timestamp_start") dataset = Session.objects.all().order_by("-timestamp_start")
for session in dataset: for session in dataset:
if session.timestamp_end == None and session.duration_manual.seconds == 0: if session.timestamp_end == None and session.duration_manual == timedelta(
seconds=0
):
session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE)) session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
session.unfinished = True session.unfinished = True
context["total_duration"] = dataset.total_duration() context["total_duration"] = dataset.total_duration_formatted()
context["dataset"] = dataset context["dataset"] = dataset
# cannot use dataset[0] here because that might be only partial QuerySet # cannot use dataset[0] here because that might be only partial QuerySet
context["last"] = Session.objects.all().order_by("timestamp_start").last() context["last"] = Session.objects.all().order_by("timestamp_start").last()
# charts are always oldest->newest
if Session.objects.count() >= 2:
context["chart"] = playtime_over_time_chart(dataset.order_by("timestamp_start"))
return render(request, "list_sessions.html", context) return render(request, "list_sessions.html", context)
def stats(request, year: int = 0):
selected_year = request.GET.get("year")
if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
if year == 0:
year = now_with_tz().year
first_day_of_year = datetime(year, 1, 1)
last_day_of_year = datetime(year + 1, 1, 1)
year_sessions = Session.objects.filter(timestamp_start__year=year)
unique_days = (
year_sessions.annotate(date=TruncDate("timestamp_start"))
.values("date")
.distinct()
.aggregate(dates=Count("date"))
)
year_played_purchases = Purchase.objects.filter(
session__in=year_sessions
).distinct()
selected_currency = "CZK"
all_purchased_this_year = (
Purchase.objects.filter(date_purchased__year=year)
.filter(price_currency__exact=selected_currency)
.order_by("date_purchased")
)
all_purchased_without_refunded_this_year = all_purchased_this_year.not_refunded()
all_purchased_refunded_this_year = (
Purchase.objects.filter(date_purchased__year=year)
.filter(price_currency__exact=selected_currency)
.refunded()
.order_by("date_purchased")
)
purchased_unfinished = all_purchased_without_refunded_this_year.filter(
date_finished__isnull=True
)
unfinished_purchases_percent = int(
safe_division(
purchased_unfinished.count(), all_purchased_refunded_this_year.count()
)
* 100
)
all_finished_this_year = Purchase.objects.filter(date_finished__year=year).order_by(
"date_finished"
)
this_year_finished_this_year = (
Purchase.objects.filter(date_finished__year=year)
.filter(edition__year_released=year)
.order_by("date_finished")
)
purchased_this_year_finished_this_year = (
all_purchased_without_refunded_this_year.filter(
date_finished__year=year
).order_by("date_finished")
)
this_year_spendings = all_purchased_without_refunded_this_year.aggregate(
total_spent=Sum(F("price"))
)
total_spent = this_year_spendings["total_spent"]
games_with_playtime = (
Game.objects.filter(edition__purchase__session__in=year_sessions)
.annotate(
total_playtime=Sum(
F("edition__purchase__session__duration_calculated")
+ F("edition__purchase__session__duration_manual")
)
)
.values("id", "name", "total_playtime")
)
top_10_games_by_playtime = games_with_playtime.order_by("-total_playtime")[:10]
for game in top_10_games_by_playtime:
game["formatted_playtime"] = format_duration(game["total_playtime"], "%2.0H")
total_playtime_per_platform = (
year_sessions.values("purchase__platform__name")
.annotate(total_playtime=Sum(F("duration_calculated") + F("duration_manual")))
.annotate(platform_name=F("purchase__platform__name"))
.values("platform_name", "total_playtime")
.order_by("-total_playtime")
)
for item in total_playtime_per_platform:
item["formatted_playtime"] = format_duration(item["total_playtime"], "%2.0H")
context = {
"total_hours": format_duration(
year_sessions.total_duration_unformatted(), "%2.0H"
),
"total_games": year_played_purchases.count(),
"total_2023_games": year_played_purchases.filter(
edition__year_released=year
).count(),
"top_10_games_by_playtime": top_10_games_by_playtime,
"year": year,
"total_playtime_per_platform": total_playtime_per_platform,
"total_spent": total_spent,
"total_spent_currency": selected_currency,
"all_purchased_this_year": all_purchased_without_refunded_this_year,
"spent_per_game": int(
safe_division(total_spent, all_purchased_without_refunded_this_year.count())
),
"all_finished_this_year": all_finished_this_year,
"this_year_finished_this_year": this_year_finished_this_year,
"purchased_this_year_finished_this_year": purchased_this_year_finished_this_year,
"total_sessions": year_sessions.count(),
"unique_days": unique_days["dates"],
"unique_days_percent": int(unique_days["dates"] / 365 * 100),
"purchased_unfinished": purchased_unfinished,
"unfinished_purchases_percent": unfinished_purchases_percent,
"refunded_percent": int(
safe_division(
all_purchased_refunded_this_year.count(),
all_purchased_this_year.count(),
)
* 100
),
"all_purchased_refunded_this_year": all_purchased_refunded_this_year,
"all_purchased_this_year": all_purchased_this_year,
}
request.session["return_path"] = request.path
return render(request, "stats.html", context)
def add_purchase(request): def add_purchase(request):
context = {} context = {}
now = datetime.now() now = datetime.now()
@@ -117,6 +422,18 @@ def add_game(request):
return render(request, "add.html", context) return render(request, "add.html", context)
def add_edition(request):
context = {}
form = EditionForm(request.POST or None)
if form.is_valid():
form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Edition"
return render(request, "add_edition.html", context)
def add_platform(request): def add_platform(request):
context = {} context = {}
form = PlatformForm(request.POST or None) form = PlatformForm(request.POST or None)
@@ -129,8 +446,17 @@ def add_platform(request):
return render(request, "add.html", context) return render(request, "add.html", context)
def index(request): def add_device(request):
context = {} context = {}
context["total_duration"] = Session().duration_sum form = DeviceForm(request.POST or None)
context["title"] = "Index" if form.is_valid():
return render(request, "index.html", context) form.save()
return redirect("index")
context["form"] = form
context["title"] = "Add New Device"
return render(request, "add.html", context)
def index(request):
return redirect("list_sessions_recent")
+3 -3
View File
@@ -1,7 +1,7 @@
{ {
"devDependencies": { "devDependencies": {
"@tailwindcss/forms": "^0.5.3", "@tailwindcss/forms": "^0.5.6",
"@tailwindcss/typography": "^0.5.8", "@tailwindcss/typography": "^0.5.10",
"tailwindcss": "^3.2.4" "tailwindcss": "^3.3.3"
} }
} }
Generated
+3 -468
View File
@@ -96,81 +96,6 @@ files = [
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
] ]
[[package]]
name = "contourpy"
version = "1.0.7"
description = "Python library for calculating contours of 2D quadrilateral grids"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
{file = "contourpy-1.0.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:95c3acddf921944f241b6773b767f1cbce71d03307270e2d769fd584d5d1092d"},
{file = "contourpy-1.0.7-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fc1464c97579da9f3ab16763c32e5c5d5bb5fa1ec7ce509a4ca6108b61b84fab"},
{file = "contourpy-1.0.7-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8acf74b5d383414401926c1598ed77825cd530ac7b463ebc2e4f46638f56cce6"},
{file = "contourpy-1.0.7-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c71fdd8f1c0f84ffd58fca37d00ca4ebaa9e502fb49825484da075ac0b0b803"},
{file = "contourpy-1.0.7-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f99e9486bf1bb979d95d5cffed40689cb595abb2b841f2991fc894b3452290e8"},
{file = "contourpy-1.0.7-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:87f4d8941a9564cda3f7fa6a6cd9b32ec575830780677932abdec7bcb61717b0"},
{file = "contourpy-1.0.7-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:9e20e5a1908e18aaa60d9077a6d8753090e3f85ca25da6e25d30dc0a9e84c2c6"},
{file = "contourpy-1.0.7-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:a877ada905f7d69b2a31796c4b66e31a8068b37aa9b78832d41c82fc3e056ddd"},
{file = "contourpy-1.0.7-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:6381fa66866b0ea35e15d197fc06ac3840a9b2643a6475c8fff267db8b9f1e69"},
{file = "contourpy-1.0.7-cp310-cp310-win32.whl", hash = "sha256:3c184ad2433635f216645fdf0493011a4667e8d46b34082f5a3de702b6ec42e3"},
{file = "contourpy-1.0.7-cp310-cp310-win_amd64.whl", hash = "sha256:3caea6365b13119626ee996711ab63e0c9d7496f65641f4459c60a009a1f3e80"},
{file = "contourpy-1.0.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ed33433fc3820263a6368e532f19ddb4c5990855e4886088ad84fd7c4e561c71"},
{file = "contourpy-1.0.7-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:38e2e577f0f092b8e6774459317c05a69935a1755ecfb621c0a98f0e3c09c9a5"},
{file = "contourpy-1.0.7-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ae90d5a8590e5310c32a7630b4b8618cef7563cebf649011da80874d0aa8f414"},
{file = "contourpy-1.0.7-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130230b7e49825c98edf0b428b7aa1125503d91732735ef897786fe5452b1ec2"},
{file = "contourpy-1.0.7-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:58569c491e7f7e874f11519ef46737cea1d6eda1b514e4eb5ac7dab6aa864d02"},
{file = "contourpy-1.0.7-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d43960d809c4c12508a60b66cb936e7ed57d51fb5e30b513934a4a23874fae"},
{file = "contourpy-1.0.7-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:152fd8f730c31fd67fe0ffebe1df38ab6a669403da93df218801a893645c6ccc"},
{file = "contourpy-1.0.7-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:9056c5310eb1daa33fc234ef39ebfb8c8e2533f088bbf0bc7350f70a29bde1ac"},
{file = "contourpy-1.0.7-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a9d7587d2fdc820cc9177139b56795c39fb8560f540bba9ceea215f1f66e1566"},
{file = "contourpy-1.0.7-cp311-cp311-win32.whl", hash = "sha256:4ee3ee247f795a69e53cd91d927146fb16c4e803c7ac86c84104940c7d2cabf0"},
{file = "contourpy-1.0.7-cp311-cp311-win_amd64.whl", hash = "sha256:5caeacc68642e5f19d707471890f037a13007feba8427eb7f2a60811a1fc1350"},
{file = "contourpy-1.0.7-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:fd7dc0e6812b799a34f6d12fcb1000539098c249c8da54f3566c6a6461d0dbad"},
{file = "contourpy-1.0.7-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0f9d350b639db6c2c233d92c7f213d94d2e444d8e8fc5ca44c9706cf72193772"},
{file = "contourpy-1.0.7-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:e96a08b62bb8de960d3a6afbc5ed8421bf1a2d9c85cc4ea73f4bc81b4910500f"},
{file = "contourpy-1.0.7-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:031154ed61f7328ad7f97662e48660a150ef84ee1bc8876b6472af88bf5a9b98"},
{file = "contourpy-1.0.7-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e9ebb4425fc1b658e13bace354c48a933b842d53c458f02c86f371cecbedecc"},
{file = "contourpy-1.0.7-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:efb8f6d08ca7998cf59eaf50c9d60717f29a1a0a09caa46460d33b2924839dbd"},
{file = "contourpy-1.0.7-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6c180d89a28787e4b73b07e9b0e2dac7741261dbdca95f2b489c4f8f887dd810"},
{file = "contourpy-1.0.7-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:b8d587cc39057d0afd4166083d289bdeff221ac6d3ee5046aef2d480dc4b503c"},
{file = "contourpy-1.0.7-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:769eef00437edf115e24d87f8926955f00f7704bede656ce605097584f9966dc"},
{file = "contourpy-1.0.7-cp38-cp38-win32.whl", hash = "sha256:62398c80ef57589bdbe1eb8537127321c1abcfdf8c5f14f479dbbe27d0322e66"},
{file = "contourpy-1.0.7-cp38-cp38-win_amd64.whl", hash = "sha256:57119b0116e3f408acbdccf9eb6ef19d7fe7baf0d1e9aaa5381489bc1aa56556"},
{file = "contourpy-1.0.7-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:30676ca45084ee61e9c3da589042c24a57592e375d4b138bd84d8709893a1ba4"},
{file = "contourpy-1.0.7-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3e927b3868bd1e12acee7cc8f3747d815b4ab3e445a28d2e5373a7f4a6e76ba1"},
{file = "contourpy-1.0.7-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:366a0cf0fc079af5204801786ad7a1c007714ee3909e364dbac1729f5b0849e5"},
{file = "contourpy-1.0.7-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:89ba9bb365446a22411f0673abf6ee1fea3b2cf47b37533b970904880ceb72f3"},
{file = "contourpy-1.0.7-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:71b0bf0c30d432278793d2141362ac853859e87de0a7dee24a1cea35231f0d50"},
{file = "contourpy-1.0.7-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e7281244c99fd7c6f27c1c6bfafba878517b0b62925a09b586d88ce750a016d2"},
{file = "contourpy-1.0.7-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b6d0f9e1d39dbfb3977f9dd79f156c86eb03e57a7face96f199e02b18e58d32a"},
{file = "contourpy-1.0.7-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7f6979d20ee5693a1057ab53e043adffa1e7418d734c1532e2d9e915b08d8ec2"},
{file = "contourpy-1.0.7-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5dd34c1ae752515318224cba7fc62b53130c45ac6a1040c8b7c1a223c46e8967"},
{file = "contourpy-1.0.7-cp39-cp39-win32.whl", hash = "sha256:c5210e5d5117e9aec8c47d9156d1d3835570dd909a899171b9535cb4a3f32693"},
{file = "contourpy-1.0.7-cp39-cp39-win_amd64.whl", hash = "sha256:60835badb5ed5f4e194a6f21c09283dd6e007664a86101431bf870d9e86266c4"},
{file = "contourpy-1.0.7-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ce41676b3d0dd16dbcfabcc1dc46090aaf4688fd6e819ef343dbda5a57ef0161"},
{file = "contourpy-1.0.7-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5a011cf354107b47c58ea932d13b04d93c6d1d69b8b6dce885e642531f847566"},
{file = "contourpy-1.0.7-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:31a55dccc8426e71817e3fe09b37d6d48ae40aae4ecbc8c7ad59d6893569c436"},
{file = "contourpy-1.0.7-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:69f8ff4db108815addd900a74df665e135dbbd6547a8a69333a68e1f6e368ac2"},
{file = "contourpy-1.0.7-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:efe99298ba37e37787f6a2ea868265465410822f7bea163edcc1bd3903354ea9"},
{file = "contourpy-1.0.7-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:a1e97b86f73715e8670ef45292d7cc033548266f07d54e2183ecb3c87598888f"},
{file = "contourpy-1.0.7-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cc331c13902d0f50845099434cd936d49d7a2ca76cb654b39691974cb1e4812d"},
{file = "contourpy-1.0.7-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:24847601071f740837aefb730e01bd169fbcaa610209779a78db7ebb6e6a7051"},
{file = "contourpy-1.0.7-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:abf298af1e7ad44eeb93501e40eb5a67abbf93b5d90e468d01fc0c4451971afa"},
{file = "contourpy-1.0.7-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:64757f6460fc55d7e16ed4f1de193f362104285c667c112b50a804d482777edd"},
{file = "contourpy-1.0.7.tar.gz", hash = "sha256:d8165a088d31798b59e91117d1f5fc3df8168d8b48c4acc10fc0df0d0bdbcc5e"},
]
[package.dependencies]
numpy = ">=1.16"
[package.extras]
bokeh = ["bokeh", "chromedriver", "selenium"]
docs = ["furo", "sphinx-copybutton"]
mypy = ["contourpy[bokeh]", "docutils-stubs", "mypy (==0.991)", "types-Pillow"]
test = ["Pillow", "matplotlib", "pytest"]
test-no-images = ["pytest"]
[[package]] [[package]]
name = "cssbeautifier" name = "cssbeautifier"
version = "1.14.7" version = "1.14.7"
@@ -187,18 +112,6 @@ editorconfig = ">=0.12.2"
jsbeautifier = "*" jsbeautifier = "*"
six = ">=1.13.0" six = ">=1.13.0"
[[package]]
name = "cycler"
version = "0.11.0"
description = "Composable style cycles"
category = "main"
optional = false
python-versions = ">=3.6"
files = [
{file = "cycler-0.11.0-py3-none-any.whl", hash = "sha256:3a27e95f763a428a739d2add979fa7494c912a32c17c4c38c4d5f082cad165a3"},
{file = "cycler-0.11.0.tar.gz", hash = "sha256:9c87405839a19696e837b3b818fed3f5f69f16f1eec1a1ad77e043dcea9c772f"},
]
[[package]] [[package]]
name = "django" name = "django"
version = "4.1.5" version = "4.1.5"
@@ -302,32 +215,6 @@ files = [
[package.extras] [package.extras]
test = ["pytest (>=6)"] test = ["pytest (>=6)"]
[[package]]
name = "fonttools"
version = "4.38.0"
description = "Tools to manipulate font files"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "fonttools-4.38.0-py3-none-any.whl", hash = "sha256:820466f43c8be8c3009aef8b87e785014133508f0de64ec469e4efb643ae54fb"},
{file = "fonttools-4.38.0.zip", hash = "sha256:2bb244009f9bf3fa100fc3ead6aeb99febe5985fa20afbfbaa2f8946c2fbdaf1"},
]
[package.extras]
all = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "fs (>=2.2.0,<3)", "lxml (>=4.0,<5)", "lz4 (>=1.7.4.2)", "matplotlib", "munkres", "scipy", "skia-pathops (>=0.5.0)", "sympy", "uharfbuzz (>=0.23.0)", "unicodedata2 (>=14.0.0)", "xattr", "zopfli (>=0.1.4)"]
graphite = ["lz4 (>=1.7.4.2)"]
interpolatable = ["munkres", "scipy"]
lxml = ["lxml (>=4.0,<5)"]
pathops = ["skia-pathops (>=0.5.0)"]
plot = ["matplotlib"]
repacker = ["uharfbuzz (>=0.23.0)"]
symfont = ["sympy"]
type1 = ["xattr"]
ufo = ["fs (>=2.2.0,<3)"]
unicode = ["unicodedata2 (>=14.0.0)"]
woff = ["brotli (>=1.0.1)", "brotlicffi (>=0.8.0)", "zopfli (>=0.1.4)"]
[[package]] [[package]]
name = "gunicorn" name = "gunicorn"
version = "20.1.0" version = "20.1.0"
@@ -450,84 +337,6 @@ files = [
editorconfig = ">=0.12.2" editorconfig = ">=0.12.2"
six = ">=1.13.0" six = ">=1.13.0"
[[package]]
name = "kiwisolver"
version = "1.4.4"
description = "A fast implementation of the Cassowary constraint solver"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "kiwisolver-1.4.4-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:2f5e60fabb7343a836360c4f0919b8cd0d6dbf08ad2ca6b9cf90bf0c76a3c4f6"},
{file = "kiwisolver-1.4.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:10ee06759482c78bdb864f4109886dff7b8a56529bc1609d4f1112b93fe6423c"},
{file = "kiwisolver-1.4.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c79ebe8f3676a4c6630fd3f777f3cfecf9289666c84e775a67d1d358578dc2e3"},
{file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:abbe9fa13da955feb8202e215c4018f4bb57469b1b78c7a4c5c7b93001699938"},
{file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7577c1987baa3adc4b3c62c33bd1118c3ef5c8ddef36f0f2c950ae0b199e100d"},
{file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f8ad8285b01b0d4695102546b342b493b3ccc6781fc28c8c6a1bb63e95d22f09"},
{file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8ed58b8acf29798b036d347791141767ccf65eee7f26bde03a71c944449e53de"},
{file = "kiwisolver-1.4.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a68b62a02953b9841730db7797422f983935aeefceb1679f0fc85cbfbd311c32"},
{file = "kiwisolver-1.4.4-cp310-cp310-win32.whl", hash = "sha256:e92a513161077b53447160b9bd8f522edfbed4bd9759e4c18ab05d7ef7e49408"},
{file = "kiwisolver-1.4.4-cp310-cp310-win_amd64.whl", hash = "sha256:3fe20f63c9ecee44560d0e7f116b3a747a5d7203376abeea292ab3152334d004"},
{file = "kiwisolver-1.4.4-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e0ea21f66820452a3f5d1655f8704a60d66ba1191359b96541eaf457710a5fc6"},
{file = "kiwisolver-1.4.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:bc9db8a3efb3e403e4ecc6cd9489ea2bac94244f80c78e27c31dcc00d2790ac2"},
{file = "kiwisolver-1.4.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d5b61785a9ce44e5a4b880272baa7cf6c8f48a5180c3e81c59553ba0cb0821ca"},
{file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c2dbb44c3f7e6c4d3487b31037b1bdbf424d97687c1747ce4ff2895795c9bf69"},
{file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6295ecd49304dcf3bfbfa45d9a081c96509e95f4b9d0eb7ee4ec0530c4a96514"},
{file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4bd472dbe5e136f96a4b18f295d159d7f26fd399136f5b17b08c4e5f498cd494"},
{file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf7d9fce9bcc4752ca4a1b80aabd38f6d19009ea5cbda0e0856983cf6d0023f5"},
{file = "kiwisolver-1.4.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:78d6601aed50c74e0ef02f4204da1816147a6d3fbdc8b3872d263338a9052c51"},
{file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:877272cf6b4b7e94c9614f9b10140e198d2186363728ed0f701c6eee1baec1da"},
{file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:db608a6757adabb32f1cfe6066e39b3706d8c3aa69bbc353a5b61edad36a5cb4"},
{file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5853eb494c71e267912275e5586fe281444eb5e722de4e131cddf9d442615626"},
{file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:f0a1dbdb5ecbef0d34eb77e56fcb3e95bbd7e50835d9782a45df81cc46949750"},
{file = "kiwisolver-1.4.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:283dffbf061a4ec60391d51e6155e372a1f7a4f5b15d59c8505339454f8989e4"},
{file = "kiwisolver-1.4.4-cp311-cp311-win32.whl", hash = "sha256:d06adcfa62a4431d404c31216f0f8ac97397d799cd53800e9d3efc2fbb3cf14e"},
{file = "kiwisolver-1.4.4-cp311-cp311-win_amd64.whl", hash = "sha256:e7da3fec7408813a7cebc9e4ec55afed2d0fd65c4754bc376bf03498d4e92686"},
{file = "kiwisolver-1.4.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:62ac9cc684da4cf1778d07a89bf5f81b35834cb96ca523d3a7fb32509380cbf6"},
{file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:41dae968a94b1ef1897cb322b39360a0812661dba7c682aa45098eb8e193dbdf"},
{file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:02f79693ec433cb4b5f51694e8477ae83b3205768a6fb48ffba60549080e295b"},
{file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d0611a0a2a518464c05ddd5a3a1a0e856ccc10e67079bb17f265ad19ab3c7597"},
{file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:db5283d90da4174865d520e7366801a93777201e91e79bacbac6e6927cbceede"},
{file = "kiwisolver-1.4.4-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1041feb4cda8708ce73bb4dcb9ce1ccf49d553bf87c3954bdfa46f0c3f77252c"},
{file = "kiwisolver-1.4.4-cp37-cp37m-win32.whl", hash = "sha256:a553dadda40fef6bfa1456dc4be49b113aa92c2a9a9e8711e955618cd69622e3"},
{file = "kiwisolver-1.4.4-cp37-cp37m-win_amd64.whl", hash = "sha256:03baab2d6b4a54ddbb43bba1a3a2d1627e82d205c5cf8f4c924dc49284b87166"},
{file = "kiwisolver-1.4.4-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:841293b17ad704d70c578f1f0013c890e219952169ce8a24ebc063eecf775454"},
{file = "kiwisolver-1.4.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f4f270de01dd3e129a72efad823da90cc4d6aafb64c410c9033aba70db9f1ff0"},
{file = "kiwisolver-1.4.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:f9f39e2f049db33a908319cf46624a569b36983c7c78318e9726a4cb8923b26c"},
{file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c97528e64cb9ebeff9701e7938653a9951922f2a38bd847787d4a8e498cc83ae"},
{file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d1573129aa0fd901076e2bfb4275a35f5b7aa60fbfb984499d661ec950320b0"},
{file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ad881edc7ccb9d65b0224f4e4d05a1e85cf62d73aab798943df6d48ab0cd79a1"},
{file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b428ef021242344340460fa4c9185d0b1f66fbdbfecc6c63eff4b7c29fad429d"},
{file = "kiwisolver-1.4.4-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:2e407cb4bd5a13984a6c2c0fe1845e4e41e96f183e5e5cd4d77a857d9693494c"},
{file = "kiwisolver-1.4.4-cp38-cp38-win32.whl", hash = "sha256:75facbe9606748f43428fc91a43edb46c7ff68889b91fa31f53b58894503a191"},
{file = "kiwisolver-1.4.4-cp38-cp38-win_amd64.whl", hash = "sha256:5bce61af018b0cb2055e0e72e7d65290d822d3feee430b7b8203d8a855e78766"},
{file = "kiwisolver-1.4.4-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8c808594c88a025d4e322d5bb549282c93c8e1ba71b790f539567932722d7bd8"},
{file = "kiwisolver-1.4.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f0a71d85ecdd570ded8ac3d1c0f480842f49a40beb423bb8014539a9f32a5897"},
{file = "kiwisolver-1.4.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b533558eae785e33e8c148a8d9921692a9fe5aa516efbdff8606e7d87b9d5824"},
{file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:efda5fc8cc1c61e4f639b8067d118e742b812c930f708e6667a5ce0d13499e29"},
{file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:7c43e1e1206cd421cd92e6b3280d4385d41d7166b3ed577ac20444b6995a445f"},
{file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc8d3bd6c72b2dd9decf16ce70e20abcb3274ba01b4e1c96031e0c4067d1e7cd"},
{file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4ea39b0ccc4f5d803e3337dd46bcce60b702be4d86fd0b3d7531ef10fd99a1ac"},
{file = "kiwisolver-1.4.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:968f44fdbf6dd757d12920d63b566eeb4d5b395fd2d00d29d7ef00a00582aac9"},
{file = "kiwisolver-1.4.4-cp39-cp39-win32.whl", hash = "sha256:da7e547706e69e45d95e116e6939488d62174e033b763ab1496b4c29b76fabea"},
{file = "kiwisolver-1.4.4-cp39-cp39-win_amd64.whl", hash = "sha256:ba59c92039ec0a66103b1d5fe588fa546373587a7d68f5c96f743c3396afc04b"},
{file = "kiwisolver-1.4.4-pp37-pypy37_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:91672bacaa030f92fc2f43b620d7b337fd9a5af28b0d6ed3f77afc43c4a64b5a"},
{file = "kiwisolver-1.4.4-pp37-pypy37_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:787518a6789009c159453da4d6b683f468ef7a65bbde796bcea803ccf191058d"},
{file = "kiwisolver-1.4.4-pp37-pypy37_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:da152d8cdcab0e56e4f45eb08b9aea6455845ec83172092f09b0e077ece2cf7a"},
{file = "kiwisolver-1.4.4-pp37-pypy37_pp73-win_amd64.whl", hash = "sha256:ecb1fa0db7bf4cff9dac752abb19505a233c7f16684c5826d1f11ebd9472b871"},
{file = "kiwisolver-1.4.4-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:28bc5b299f48150b5f822ce68624e445040595a4ac3d59251703779836eceff9"},
{file = "kiwisolver-1.4.4-pp38-pypy38_pp73-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:81e38381b782cc7e1e46c4e14cd997ee6040768101aefc8fa3c24a4cc58e98f8"},
{file = "kiwisolver-1.4.4-pp38-pypy38_pp73-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:2a66fdfb34e05b705620dd567f5a03f239a088d5a3f321e7b6ac3239d22aa286"},
{file = "kiwisolver-1.4.4-pp38-pypy38_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:872b8ca05c40d309ed13eb2e582cab0c5a05e81e987ab9c521bf05ad1d5cf5cb"},
{file = "kiwisolver-1.4.4-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:70e7c2e7b750585569564e2e5ca9845acfaa5da56ac46df68414f29fea97be9f"},
{file = "kiwisolver-1.4.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:9f85003f5dfa867e86d53fac6f7e6f30c045673fa27b603c397753bebadc3008"},
{file = "kiwisolver-1.4.4-pp39-pypy39_pp73-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e307eb9bd99801f82789b44bb45e9f541961831c7311521b13a6c85afc09767"},
{file = "kiwisolver-1.4.4-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b1792d939ec70abe76f5054d3f36ed5656021dcad1322d1cc996d4e54165cef9"},
{file = "kiwisolver-1.4.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6cb459eea32a4e2cf18ba5fcece2dbdf496384413bc1bae15583f19e567f3b2"},
{file = "kiwisolver-1.4.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:36dafec3d6d6088d34e2de6b85f9d8e2324eb734162fba59d2ba9ed7a2043d5b"},
{file = "kiwisolver-1.4.4.tar.gz", hash = "sha256:d41997519fcba4a1e46eb4a2fe31bc12f0ff957b2b81bac28db24744f333e955"},
]
[[package]] [[package]]
name = "markupsafe" name = "markupsafe"
version = "2.1.1" version = "2.1.1"
@@ -578,68 +387,6 @@ files = [
{file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"},
] ]
[[package]]
name = "matplotlib"
version = "3.6.3"
description = "Python plotting package"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
{file = "matplotlib-3.6.3-cp310-cp310-macosx_10_12_universal2.whl", hash = "sha256:80c166a0e28512e26755f69040e6bf2f946a02ffdb7c00bf6158cca3d2b146e6"},
{file = "matplotlib-3.6.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:eb9421c403ffd387fbe729de6d9a03005bf42faba5e8432f4e51e703215b49fc"},
{file = "matplotlib-3.6.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5223affa21050fb6118353c1380c15e23aedfb436bf3e162c26dc950617a7519"},
{file = "matplotlib-3.6.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d00c248ab6b92bea3f8148714837937053a083ff03b4c5e30ed37e28fc0e7e56"},
{file = "matplotlib-3.6.3-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca94f0362f6b6f424b555b956971dcb94b12d0368a6c3e07dc7a40d32d6d873d"},
{file = "matplotlib-3.6.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59400cc9451094b7f08cc3f321972e6e1db4cd37a978d4e8a12824bf7fd2f03b"},
{file = "matplotlib-3.6.3-cp310-cp310-win32.whl", hash = "sha256:57ad1aee29043163374bfa8990e1a2a10ff72c9a1bfaa92e9c46f6ea59269121"},
{file = "matplotlib-3.6.3-cp310-cp310-win_amd64.whl", hash = "sha256:1fcc4cad498533d3c393a160975acc9b36ffa224d15a6b90ae579eacee5d8579"},
{file = "matplotlib-3.6.3-cp311-cp311-macosx_10_12_universal2.whl", hash = "sha256:d2cfaa7fd62294d945b8843ea24228a27c8e7c5b48fa634f3c168153b825a21b"},
{file = "matplotlib-3.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c3f08df2ac4636249b8bc7a85b8b82c983bef1441595936f62c2918370ca7e1d"},
{file = "matplotlib-3.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ff2aa84e74f80891e6bcf292ebb1dd57714ffbe13177642d65fee25384a30894"},
{file = "matplotlib-3.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:11011c97d62c1db7bc20509572557842dbb8c2a2ddd3dd7f20501aa1cde3e54e"},
{file = "matplotlib-3.6.3-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1c235bf9be052347373f589e018988cad177abb3f997ab1a2e2210c41562cc0c"},
{file = "matplotlib-3.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bebcff4c3ed02c6399d47329f3554193abd824d3d53b5ca02cf583bcd94470e2"},
{file = "matplotlib-3.6.3-cp311-cp311-win32.whl", hash = "sha256:d5f18430f5cfa5571ab8f4c72c89af52aa0618e864c60028f11a857d62200cba"},
{file = "matplotlib-3.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:dfba7057609ca9567b9704626756f0142e97ec8c5ba2c70c6e7bd1c25ef99f06"},
{file = "matplotlib-3.6.3-cp38-cp38-macosx_10_12_universal2.whl", hash = "sha256:9fb8fb19d03abf3c5dab89a8677e62c4023632f919a62b6dd1d6d2dbf42cd9f5"},
{file = "matplotlib-3.6.3-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:bbf269e1d24bc25247095d71c7a969813f7080e2a7c6fa28931a603f747ab012"},
{file = "matplotlib-3.6.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:994637e2995b0342699b396a320698b07cd148bbcf2dd2fa2daba73f34dd19f2"},
{file = "matplotlib-3.6.3-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.whl", hash = "sha256:77b384cee7ab8cf75ffccbfea351a09b97564fc62d149827a5e864bec81526e5"},
{file = "matplotlib-3.6.3-cp38-cp38-manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:73b93af33634ed919e72811c9703e1105185cd3fb46d76f30b7f4cfbbd063f89"},
{file = "matplotlib-3.6.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:debeab8e2ab07e5e3dac33e12456da79c7e104270d2b2d1df92b9e40347cca75"},
{file = "matplotlib-3.6.3-cp38-cp38-win32.whl", hash = "sha256:acc3b1a4bddbf56fe461e36fb9ef94c2cb607fc90d24ccc650040bfcc7610de4"},
{file = "matplotlib-3.6.3-cp38-cp38-win_amd64.whl", hash = "sha256:1183877d008c752d7d535396096c910f4663e4b74a18313adee1213328388e1e"},
{file = "matplotlib-3.6.3-cp39-cp39-macosx_10_12_universal2.whl", hash = "sha256:6adc441b5b2098a4b904bbf9d9e92fb816fef50c55aa2ea6a823fc89b94bb838"},
{file = "matplotlib-3.6.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:6d81b11ede69e3a751424b98dc869c96c10256b2206bfdf41f9c720eee86844c"},
{file = "matplotlib-3.6.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:29f17b7f2e068dc346687cbdf80b430580bab42346625821c2d3abf3a1ec5417"},
{file = "matplotlib-3.6.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f56a7252eee8f3438447f75f5e1148a1896a2756a92285fe5d73bed6deebff4"},
{file = "matplotlib-3.6.3-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bbddfeb1495484351fb5b30cf5bdf06b3de0bc4626a707d29e43dfd61af2a780"},
{file = "matplotlib-3.6.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:809119d1cba3ece3c9742eb01827fe7a0e781ea3c5d89534655a75e07979344f"},
{file = "matplotlib-3.6.3-cp39-cp39-win32.whl", hash = "sha256:e0a64d7cc336b52e90f59e6d638ae847b966f68582a7af041e063d568e814740"},
{file = "matplotlib-3.6.3-cp39-cp39-win_amd64.whl", hash = "sha256:79e501eb847f4a489eb7065bb8d3187117f65a4c02d12ea3a19d6c5bef173bcc"},
{file = "matplotlib-3.6.3-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:2787a16df07370dcba385fe20cdd0cc3cfaabd3c873ddabca78c10514c799721"},
{file = "matplotlib-3.6.3-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:68d94a436f62b8a861bf3ace82067a71bafb724b4e4f9133521e4d8012420dd7"},
{file = "matplotlib-3.6.3-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:81b409b2790cf8d7c1ef35920f01676d2ae7afa8241844e7aa5484fdf493a9a0"},
{file = "matplotlib-3.6.3-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:faff486b36530a836a6b4395850322e74211cd81fc17f28b4904e1bd53668e3e"},
{file = "matplotlib-3.6.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:38d38cb1ea1d80ee0f6351b65c6f76cad6060bbbead015720ba001348ae90f0c"},
{file = "matplotlib-3.6.3-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:12f999661589981e74d793ee2f41b924b3b87d65fd929f6153bf0f30675c59b1"},
{file = "matplotlib-3.6.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01b7f521a9a73c383825813af255f8c4485d1706e4f3e2ed5ae771e4403a40ab"},
{file = "matplotlib-3.6.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:9ceebaf73f1a3444fa11014f38b9da37ff7ea328d6efa1652241fe3777bfdab9"},
{file = "matplotlib-3.6.3.tar.gz", hash = "sha256:1f4d69707b1677560cd952544ee4962f68ff07952fb9069ff8c12b56353cb8c9"},
]
[package.dependencies]
contourpy = ">=1.0.1"
cycler = ">=0.10"
fonttools = ">=4.22.0"
kiwisolver = ">=1.0.1"
numpy = ">=1.19"
packaging = ">=20.0"
pillow = ">=6.2.0"
pyparsing = ">=2.2.1"
python-dateutil = ">=2.7"
[[package]] [[package]]
name = "mypy" name = "mypy"
version = "0.991" version = "0.991"
@@ -703,49 +450,11 @@ files = [
{file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"}, {file = "mypy_extensions-0.4.3.tar.gz", hash = "sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"},
] ]
[[package]]
name = "numpy"
version = "1.24.1"
description = "Fundamental package for array computing in Python"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
{file = "numpy-1.24.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:179a7ef0889ab769cc03573b6217f54c8bd8e16cef80aad369e1e8185f994cd7"},
{file = "numpy-1.24.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:b09804ff570b907da323b3d762e74432fb07955701b17b08ff1b5ebaa8cfe6a9"},
{file = "numpy-1.24.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f1b739841821968798947d3afcefd386fa56da0caf97722a5de53e07c4ccedc7"},
{file = "numpy-1.24.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e3463e6ac25313462e04aea3fb8a0a30fb906d5d300f58b3bc2c23da6a15398"},
{file = "numpy-1.24.1-cp310-cp310-win32.whl", hash = "sha256:b31da69ed0c18be8b77bfce48d234e55d040793cebb25398e2a7d84199fbc7e2"},
{file = "numpy-1.24.1-cp310-cp310-win_amd64.whl", hash = "sha256:b07b40f5fb4fa034120a5796288f24c1fe0e0580bbfff99897ba6267af42def2"},
{file = "numpy-1.24.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:7094891dcf79ccc6bc2a1f30428fa5edb1e6fb955411ffff3401fb4ea93780a8"},
{file = "numpy-1.24.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:28e418681372520c992805bb723e29d69d6b7aa411065f48216d8329d02ba032"},
{file = "numpy-1.24.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e274f0f6c7efd0d577744f52032fdd24344f11c5ae668fe8d01aac0422611df1"},
{file = "numpy-1.24.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0044f7d944ee882400890f9ae955220d29b33d809a038923d88e4e01d652acd9"},
{file = "numpy-1.24.1-cp311-cp311-win32.whl", hash = "sha256:442feb5e5bada8408e8fcd43f3360b78683ff12a4444670a7d9e9824c1817d36"},
{file = "numpy-1.24.1-cp311-cp311-win_amd64.whl", hash = "sha256:de92efa737875329b052982e37bd4371d52cabf469f83e7b8be9bb7752d67e51"},
{file = "numpy-1.24.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:b162ac10ca38850510caf8ea33f89edcb7b0bb0dfa5592d59909419986b72407"},
{file = "numpy-1.24.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:26089487086f2648944f17adaa1a97ca6aee57f513ba5f1c0b7ebdabbe2b9954"},
{file = "numpy-1.24.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:caf65a396c0d1f9809596be2e444e3bd4190d86d5c1ce21f5fc4be60a3bc5b36"},
{file = "numpy-1.24.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b0677a52f5d896e84414761531947c7a330d1adc07c3a4372262f25d84af7bf7"},
{file = "numpy-1.24.1-cp38-cp38-win32.whl", hash = "sha256:dae46bed2cb79a58d6496ff6d8da1e3b95ba09afeca2e277628171ca99b99db1"},
{file = "numpy-1.24.1-cp38-cp38-win_amd64.whl", hash = "sha256:6ec0c021cd9fe732e5bab6401adea5a409214ca5592cd92a114f7067febcba0c"},
{file = "numpy-1.24.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:28bc9750ae1f75264ee0f10561709b1462d450a4808cd97c013046073ae64ab6"},
{file = "numpy-1.24.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:84e789a085aabef2f36c0515f45e459f02f570c4b4c4c108ac1179c34d475ed7"},
{file = "numpy-1.24.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e669fbdcdd1e945691079c2cae335f3e3a56554e06bbd45d7609a6cf568c700"},
{file = "numpy-1.24.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ef85cf1f693c88c1fd229ccd1055570cb41cdf4875873b7728b6301f12cd05bf"},
{file = "numpy-1.24.1-cp39-cp39-win32.whl", hash = "sha256:87a118968fba001b248aac90e502c0b13606721b1343cdaddbc6e552e8dfb56f"},
{file = "numpy-1.24.1-cp39-cp39-win_amd64.whl", hash = "sha256:ddc7ab52b322eb1e40521eb422c4e0a20716c271a306860979d450decbb51b8e"},
{file = "numpy-1.24.1-pp38-pypy38_pp73-macosx_10_9_x86_64.whl", hash = "sha256:ed5fb71d79e771ec930566fae9c02626b939e37271ec285e9efaf1b5d4370e7d"},
{file = "numpy-1.24.1-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad2925567f43643f51255220424c23d204024ed428afc5aad0f86f3ffc080086"},
{file = "numpy-1.24.1-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:cfa1161c6ac8f92dea03d625c2d0c05e084668f4a06568b77a25a89111621566"},
{file = "numpy-1.24.1.tar.gz", hash = "sha256:2386da9a471cc00a1f47845e27d916d5ec5346ae9696e01a8a34760858fe9dd2"},
]
[[package]] [[package]]
name = "packaging" name = "packaging"
version = "22.0" version = "22.0"
description = "Core utilities for Python packages" description = "Core utilities for Python packages"
category = "main" category = "dev"
optional = false optional = false
python-versions = ">=3.7" python-versions = ">=3.7"
files = [ files = [
@@ -753,54 +462,6 @@ files = [
{file = "packaging-22.0.tar.gz", hash = "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3"}, {file = "packaging-22.0.tar.gz", hash = "sha256:2198ec20bd4c017b8f9717e00f0c8714076fc2fd93816750ab48e2c41de2cfd3"},
] ]
[[package]]
name = "pandas"
version = "1.5.2"
description = "Powerful data structures for data analysis, time series, and statistics"
category = "main"
optional = false
python-versions = ">=3.8"
files = [
{file = "pandas-1.5.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:e9dbacd22555c2d47f262ef96bb4e30880e5956169741400af8b306bbb24a273"},
{file = "pandas-1.5.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e2b83abd292194f350bb04e188f9379d36b8dfac24dd445d5c87575f3beaf789"},
{file = "pandas-1.5.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2552bffc808641c6eb471e55aa6899fa002ac94e4eebfa9ec058649122db5824"},
{file = "pandas-1.5.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fc87eac0541a7d24648a001d553406f4256e744d92df1df8ebe41829a915028"},
{file = "pandas-1.5.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d0d8fd58df5d17ddb8c72a5075d87cd80d71b542571b5f78178fb067fa4e9c72"},
{file = "pandas-1.5.2-cp310-cp310-win_amd64.whl", hash = "sha256:4aed257c7484d01c9a194d9a94758b37d3d751849c05a0050c087a358c41ad1f"},
{file = "pandas-1.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:375262829c8c700c3e7cbb336810b94367b9c4889818bbd910d0ecb4e45dc261"},
{file = "pandas-1.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc3cd122bea268998b79adebbb8343b735a5511ec14efb70a39e7acbc11ccbdc"},
{file = "pandas-1.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b4f5a82afa4f1ff482ab8ded2ae8a453a2cdfde2001567b3ca24a4c5c5ca0db3"},
{file = "pandas-1.5.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8092a368d3eb7116e270525329a3e5c15ae796ccdf7ccb17839a73b4f5084a39"},
{file = "pandas-1.5.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f6257b314fc14958f8122779e5a1557517b0f8e500cfb2bd53fa1f75a8ad0af2"},
{file = "pandas-1.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:82ae615826da838a8e5d4d630eb70c993ab8636f0eff13cb28aafc4291b632b5"},
{file = "pandas-1.5.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:457d8c3d42314ff47cc2d6c54f8fc0d23954b47977b2caed09cd9635cb75388b"},
{file = "pandas-1.5.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:c009a92e81ce836212ce7aa98b219db7961a8b95999b97af566b8dc8c33e9519"},
{file = "pandas-1.5.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:71f510b0efe1629bf2f7c0eadb1ff0b9cf611e87b73cd017e6b7d6adb40e2b3a"},
{file = "pandas-1.5.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a40dd1e9f22e01e66ed534d6a965eb99546b41d4d52dbdb66565608fde48203f"},
{file = "pandas-1.5.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ae7e989f12628f41e804847a8cc2943d362440132919a69429d4dea1f164da0"},
{file = "pandas-1.5.2-cp38-cp38-win32.whl", hash = "sha256:530948945e7b6c95e6fa7aa4be2be25764af53fba93fe76d912e35d1c9ee46f5"},
{file = "pandas-1.5.2-cp38-cp38-win_amd64.whl", hash = "sha256:73f219fdc1777cf3c45fde7f0708732ec6950dfc598afc50588d0d285fddaefc"},
{file = "pandas-1.5.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:9608000a5a45f663be6af5c70c3cbe634fa19243e720eb380c0d378666bc7702"},
{file = "pandas-1.5.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:315e19a3e5c2ab47a67467fc0362cb36c7c60a93b6457f675d7d9615edad2ebe"},
{file = "pandas-1.5.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e18bc3764cbb5e118be139b3b611bc3fbc5d3be42a7e827d1096f46087b395eb"},
{file = "pandas-1.5.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0183cb04a057cc38fde5244909fca9826d5d57c4a5b7390c0cc3fa7acd9fa883"},
{file = "pandas-1.5.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:344021ed3e639e017b452aa8f5f6bf38a8806f5852e217a7594417fb9bbfa00e"},
{file = "pandas-1.5.2-cp39-cp39-win32.whl", hash = "sha256:e7469271497960b6a781eaa930cba8af400dd59b62ec9ca2f4d31a19f2f91090"},
{file = "pandas-1.5.2-cp39-cp39-win_amd64.whl", hash = "sha256:c218796d59d5abd8780170c937b812c9637e84c32f8271bbf9845970f8c1351f"},
{file = "pandas-1.5.2.tar.gz", hash = "sha256:220b98d15cee0b2cd839a6358bd1f273d0356bf964c1a1aeb32d47db0215488b"},
]
[package.dependencies]
numpy = [
{version = ">=1.21.0", markers = "python_version >= \"3.10\""},
{version = ">=1.23.2", markers = "python_version >= \"3.11\""},
]
python-dateutil = ">=2.8.1"
pytz = ">=2020.1"
[package.extras]
test = ["hypothesis (>=5.5.3)", "pytest (>=6.0)", "pytest-xdist (>=1.31)"]
[[package]] [[package]]
name = "pathspec" name = "pathspec"
version = "0.10.3" version = "0.10.3"
@@ -813,90 +474,6 @@ files = [
{file = "pathspec-0.10.3.tar.gz", hash = "sha256:56200de4077d9d0791465aa9095a01d421861e405b5096955051deefd697d6f6"}, {file = "pathspec-0.10.3.tar.gz", hash = "sha256:56200de4077d9d0791465aa9095a01d421861e405b5096955051deefd697d6f6"},
] ]
[[package]]
name = "pillow"
version = "9.4.0"
description = "Python Imaging Library (Fork)"
category = "main"
optional = false
python-versions = ">=3.7"
files = [
{file = "Pillow-9.4.0-1-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:1b4b4e9dda4f4e4c4e6896f93e84a8f0bcca3b059de9ddf67dac3c334b1195e1"},
{file = "Pillow-9.4.0-1-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:fb5c1ad6bad98c57482236a21bf985ab0ef42bd51f7ad4e4538e89a997624e12"},
{file = "Pillow-9.4.0-1-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:f0caf4a5dcf610d96c3bd32932bfac8aee61c96e60481c2a0ea58da435e25acd"},
{file = "Pillow-9.4.0-1-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:3f4cc516e0b264c8d4ccd6b6cbc69a07c6d582d8337df79be1e15a5056b258c9"},
{file = "Pillow-9.4.0-1-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:b8c2f6eb0df979ee99433d8b3f6d193d9590f735cf12274c108bd954e30ca858"},
{file = "Pillow-9.4.0-1-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b70756ec9417c34e097f987b4d8c510975216ad26ba6e57ccb53bc758f490dab"},
{file = "Pillow-9.4.0-1-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:43521ce2c4b865d385e78579a082b6ad1166ebed2b1a2293c3be1d68dd7ca3b9"},
{file = "Pillow-9.4.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:2968c58feca624bb6c8502f9564dd187d0e1389964898f5e9e1fbc8533169157"},
{file = "Pillow-9.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c5c1362c14aee73f50143d74389b2c158707b4abce2cb055b7ad37ce60738d47"},
{file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd752c5ff1b4a870b7661234694f24b1d2b9076b8bf337321a814c612665f343"},
{file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a3049a10261d7f2b6514d35bbb7a4dfc3ece4c4de14ef5876c4b7a23a0e566d"},
{file = "Pillow-9.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:16a8df99701f9095bea8a6c4b3197da105df6f74e6176c5b410bc2df2fd29a57"},
{file = "Pillow-9.4.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:94cdff45173b1919350601f82d61365e792895e3c3a3443cf99819e6fbf717a5"},
{file = "Pillow-9.4.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:ed3e4b4e1e6de75fdc16d3259098de7c6571b1a6cc863b1a49e7d3d53e036070"},
{file = "Pillow-9.4.0-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d5b2f8a31bd43e0f18172d8ac82347c8f37ef3e0b414431157718aa234991b28"},
{file = "Pillow-9.4.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:09b89ddc95c248ee788328528e6a2996e09eaccddeeb82a5356e92645733be35"},
{file = "Pillow-9.4.0-cp310-cp310-win32.whl", hash = "sha256:f09598b416ba39a8f489c124447b007fe865f786a89dbfa48bb5cf395693132a"},
{file = "Pillow-9.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:f6e78171be3fb7941f9910ea15b4b14ec27725865a73c15277bc39f5ca4f8391"},
{file = "Pillow-9.4.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:3fa1284762aacca6dc97474ee9c16f83990b8eeb6697f2ba17140d54b453e133"},
{file = "Pillow-9.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:eaef5d2de3c7e9b21f1e762f289d17b726c2239a42b11e25446abf82b26ac132"},
{file = "Pillow-9.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a4dfdae195335abb4e89cc9762b2edc524f3c6e80d647a9a81bf81e17e3fb6f0"},
{file = "Pillow-9.4.0-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6abfb51a82e919e3933eb137e17c4ae9c0475a25508ea88993bb59faf82f3b35"},
{file = "Pillow-9.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:451f10ef963918e65b8869e17d67db5e2f4ab40e716ee6ce7129b0cde2876eab"},
{file = "Pillow-9.4.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:6663977496d616b618b6cfa43ec86e479ee62b942e1da76a2c3daa1c75933ef4"},
{file = "Pillow-9.4.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:60e7da3a3ad1812c128750fc1bc14a7ceeb8d29f77e0a2356a8fb2aa8925287d"},
{file = "Pillow-9.4.0-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:19005a8e58b7c1796bc0167862b1f54a64d3b44ee5d48152b06bb861458bc0f8"},
{file = "Pillow-9.4.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f715c32e774a60a337b2bb8ad9839b4abf75b267a0f18806f6f4f5f1688c4b5a"},
{file = "Pillow-9.4.0-cp311-cp311-win32.whl", hash = "sha256:b222090c455d6d1a64e6b7bb5f4035c4dff479e22455c9eaa1bdd4c75b52c80c"},
{file = "Pillow-9.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:ba6612b6548220ff5e9df85261bddc811a057b0b465a1226b39bfb8550616aee"},
{file = "Pillow-9.4.0-cp37-cp37m-macosx_10_10_x86_64.whl", hash = "sha256:5f532a2ad4d174eb73494e7397988e22bf427f91acc8e6ebf5bb10597b49c493"},
{file = "Pillow-9.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5dd5a9c3091a0f414a963d427f920368e2b6a4c2f7527fdd82cde8ef0bc7a327"},
{file = "Pillow-9.4.0-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ef21af928e807f10bf4141cad4746eee692a0dd3ff56cfb25fce076ec3cc8abe"},
{file = "Pillow-9.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:847b114580c5cc9ebaf216dd8c8dbc6b00a3b7ab0131e173d7120e6deade1f57"},
{file = "Pillow-9.4.0-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:653d7fb2df65efefbcbf81ef5fe5e5be931f1ee4332c2893ca638c9b11a409c4"},
{file = "Pillow-9.4.0-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:46f39cab8bbf4a384ba7cb0bc8bae7b7062b6a11cfac1ca4bc144dea90d4a9f5"},
{file = "Pillow-9.4.0-cp37-cp37m-win32.whl", hash = "sha256:7ac7594397698f77bce84382929747130765f66406dc2cd8b4ab4da68ade4c6e"},
{file = "Pillow-9.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:46c259e87199041583658457372a183636ae8cd56dbf3f0755e0f376a7f9d0e6"},
{file = "Pillow-9.4.0-cp38-cp38-macosx_10_10_x86_64.whl", hash = "sha256:0e51f608da093e5d9038c592b5b575cadc12fd748af1479b5e858045fff955a9"},
{file = "Pillow-9.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:765cb54c0b8724a7c12c55146ae4647e0274a839fb6de7bcba841e04298e1011"},
{file = "Pillow-9.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:519e14e2c49fcf7616d6d2cfc5c70adae95682ae20f0395e9280db85e8d6c4df"},
{file = "Pillow-9.4.0-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d197df5489004db87d90b918033edbeee0bd6df3848a204bca3ff0a903bef837"},
{file = "Pillow-9.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0845adc64fe9886db00f5ab68c4a8cd933ab749a87747555cec1c95acea64b0b"},
{file = "Pillow-9.4.0-cp38-cp38-manylinux_2_28_aarch64.whl", hash = "sha256:e1339790c083c5a4de48f688b4841f18df839eb3c9584a770cbd818b33e26d5d"},
{file = "Pillow-9.4.0-cp38-cp38-manylinux_2_28_x86_64.whl", hash = "sha256:a96e6e23f2b79433390273eaf8cc94fec9c6370842e577ab10dabdcc7ea0a66b"},
{file = "Pillow-9.4.0-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:7cfc287da09f9d2a7ec146ee4d72d6ea1342e770d975e49a8621bf54eaa8f30f"},
{file = "Pillow-9.4.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:d7081c084ceb58278dd3cf81f836bc818978c0ccc770cbbb202125ddabec6628"},
{file = "Pillow-9.4.0-cp38-cp38-win32.whl", hash = "sha256:df41112ccce5d47770a0c13651479fbcd8793f34232a2dd9faeccb75eb5d0d0d"},
{file = "Pillow-9.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:7a21222644ab69ddd9967cfe6f2bb420b460dae4289c9d40ff9a4896e7c35c9a"},
{file = "Pillow-9.4.0-cp39-cp39-macosx_10_10_x86_64.whl", hash = "sha256:0f3269304c1a7ce82f1759c12ce731ef9b6e95b6df829dccd9fe42912cc48569"},
{file = "Pillow-9.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:cb362e3b0976dc994857391b776ddaa8c13c28a16f80ac6522c23d5257156bed"},
{file = "Pillow-9.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a2e0f87144fcbbe54297cae708c5e7f9da21a4646523456b00cc956bd4c65815"},
{file = "Pillow-9.4.0-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:28676836c7796805914b76b1837a40f76827ee0d5398f72f7dcc634bae7c6264"},
{file = "Pillow-9.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0884ba7b515163a1a05440a138adeb722b8a6ae2c2b33aea93ea3118dd3a899e"},
{file = "Pillow-9.4.0-cp39-cp39-manylinux_2_28_aarch64.whl", hash = "sha256:53dcb50fbdc3fb2c55431a9b30caeb2f7027fcd2aeb501459464f0214200a503"},
{file = "Pillow-9.4.0-cp39-cp39-manylinux_2_28_x86_64.whl", hash = "sha256:e8c5cf126889a4de385c02a2c3d3aba4b00f70234bfddae82a5eaa3ee6d5e3e6"},
{file = "Pillow-9.4.0-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:6c6b1389ed66cdd174d040105123a5a1bc91d0aa7059c7261d20e583b6d8cbd2"},
{file = "Pillow-9.4.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0dd4c681b82214b36273c18ca7ee87065a50e013112eea7d78c7a1b89a739153"},
{file = "Pillow-9.4.0-cp39-cp39-win32.whl", hash = "sha256:6d9dfb9959a3b0039ee06c1a1a90dc23bac3b430842dcb97908ddde05870601c"},
{file = "Pillow-9.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:54614444887e0d3043557d9dbc697dbb16cfb5a35d672b7a0fcc1ed0cf1c600b"},
{file = "Pillow-9.4.0-pp38-pypy38_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b9b752ab91e78234941e44abdecc07f1f0d8f51fb62941d32995b8161f68cfe5"},
{file = "Pillow-9.4.0-pp38-pypy38_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d3b56206244dc8711f7e8b7d6cad4663917cd5b2d950799425076681e8766286"},
{file = "Pillow-9.4.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aabdab8ec1e7ca7f1434d042bf8b1e92056245fb179790dc97ed040361f16bfd"},
{file = "Pillow-9.4.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:db74f5562c09953b2c5f8ec4b7dfd3f5421f31811e97d1dbc0a7c93d6e3a24df"},
{file = "Pillow-9.4.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:e9d7747847c53a16a729b6ee5e737cf170f7a16611c143d95aa60a109a59c336"},
{file = "Pillow-9.4.0-pp39-pypy39_pp73-macosx_10_10_x86_64.whl", hash = "sha256:b52ff4f4e002f828ea6483faf4c4e8deea8d743cf801b74910243c58acc6eda3"},
{file = "Pillow-9.4.0-pp39-pypy39_pp73-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:575d8912dca808edd9acd6f7795199332696d3469665ef26163cd090fa1f8bfa"},
{file = "Pillow-9.4.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c3c4ed2ff6760e98d262e0cc9c9a7f7b8a9f61aa4d47c58835cdaf7b0b8811bb"},
{file = "Pillow-9.4.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:e621b0246192d3b9cb1dc62c78cfa4c6f6d2ddc0ec207d43c0dedecb914f152a"},
{file = "Pillow-9.4.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:8f127e7b028900421cad64f51f75c051b628db17fb00e099eb148761eed598c9"},
{file = "Pillow-9.4.0.tar.gz", hash = "sha256:a1c2d7780448eb93fbcc3789bf3916aa5720d942e37945f4056680317f1cd23e"},
]
[package.extras]
docs = ["furo", "olefile", "sphinx (>=2.4)", "sphinx-copybutton", "sphinx-inline-tabs", "sphinx-issues (>=3.0.1)", "sphinx-removed-in", "sphinxext-opengraph"]
tests = ["check-manifest", "coverage", "defusedxml", "markdown2", "olefile", "packaging", "pyroma", "pytest", "pytest-cov", "pytest-timeout"]
[[package]] [[package]]
name = "platformdirs" name = "platformdirs"
version = "2.6.2" version = "2.6.2"
@@ -929,21 +506,6 @@ files = [
dev = ["pre-commit", "tox"] dev = ["pre-commit", "tox"]
testing = ["pytest", "pytest-benchmark"] testing = ["pytest", "pytest-benchmark"]
[[package]]
name = "pyparsing"
version = "3.0.9"
description = "pyparsing module - Classes and methods to define and execute parsing grammars"
category = "main"
optional = false
python-versions = ">=3.6.8"
files = [
{file = "pyparsing-3.0.9-py3-none-any.whl", hash = "sha256:5026bae9a10eeaefb61dab2f09052b9f4307d44aee4eda64b309723d8d206bbc"},
{file = "pyparsing-3.0.9.tar.gz", hash = "sha256:2b020ecf7d21b687f219b71ecad3631f644a47f01403fa1d1036b0c6416d70fb"},
]
[package.extras]
diagrams = ["jinja2", "railroad-diagrams"]
[[package]] [[package]]
name = "pytest" name = "pytest"
version = "7.2.0" version = "7.2.0"
@@ -968,33 +530,6 @@ tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""}
[package.extras] [package.extras]
testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"] testing = ["argcomplete", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "xmlschema"]
[[package]]
name = "python-dateutil"
version = "2.8.2"
description = "Extensions to the standard Python datetime module"
category = "main"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7"
files = [
{file = "python-dateutil-2.8.2.tar.gz", hash = "sha256:0123cacc1627ae19ddf3c27a5de5bd67ee4586fbdd6440d9748f8abb483d3e86"},
{file = "python_dateutil-2.8.2-py2.py3-none-any.whl", hash = "sha256:961d03dc3453ebbc59dbdea9e4e11c5651520a876d0f4db161e8674aae935da9"},
]
[package.dependencies]
six = ">=1.5"
[[package]]
name = "pytz"
version = "2022.7.1"
description = "World timezone definitions, modern and historical"
category = "main"
optional = false
python-versions = "*"
files = [
{file = "pytz-2022.7.1-py2.py3-none-any.whl", hash = "sha256:78f4f37d8198e0627c5f1143240bb0206b8691d8d7ac6d78fee88b78733f8c4a"},
{file = "pytz-2022.7.1.tar.gz", hash = "sha256:01a0681c4b9684a28304615eba55d1ab31ae00bf68ec157ec3708a8182dbbcd0"},
]
[[package]] [[package]]
name = "pyyaml" name = "pyyaml"
version = "6.0" version = "6.0"
@@ -1164,7 +699,7 @@ testing-integration = ["build[virtualenv]", "filelock (>=3.4.0)", "jaraco.envs (
name = "six" name = "six"
version = "1.16.0" version = "1.16.0"
description = "Python 2 and 3 compatibility utilities" description = "Python 2 and 3 compatibility utilities"
category = "main" category = "dev"
optional = false optional = false
python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
files = [ files = [
@@ -1297,4 +832,4 @@ testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools"
[metadata] [metadata]
lock-version = "2.0" lock-version = "2.0"
python-versions = "^3.10" python-versions = "^3.10"
content-hash = "bf02e951b8c14fbe6f2709b6df7f9664f99ad5fbf7195d8e7b18c8574d00e683" content-hash = "7b531f96813f56c815a432a814e0aa75600946f37fe8a009d2b5edd8c9ce5d09"
+1 -3
View File
@@ -1,6 +1,6 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "1.0.0" version = "1.3.0"
description = "A simple time tracker." description = "A simple time tracker."
authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"] authors = ["Lukáš Kucharczyk <lukas@kucharczyk.xyz>"]
license = "GPL" license = "GPL"
@@ -12,8 +12,6 @@ python = "^3.10"
django = "^4.1.4" django = "^4.1.4"
gunicorn = "^20.1.0" gunicorn = "^20.1.0"
uvicorn = "^0.20.0" uvicorn = "^0.20.0"
pandas = "^1.5.2"
matplotlib = "^3.6.3"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
black = "^22.12.0" black = "^22.12.0"
+10 -5
View File
@@ -1,14 +1,19 @@
const defaultTheme = require('tailwindcss/defaultTheme')
module.exports = { module.exports = {
darkMode: 'class', darkMode: 'class',
content: ["./**/*.{html,js}"], content: ["./games/**/*.{html,js}"],
theme: { theme: {
fontFamily: { extend: {
sans: ['Inter', 'sans-serif'], fontFamily: {
'sans': ['IBM Plex Sans', ...defaultTheme.fontFamily.sans],
'mono': ['IBM Plex Mono', ...defaultTheme.fontFamily.mono],
'serif': ['IBM Plex Serif', ...defaultTheme.fontFamily.serif],
}
}, },
extend: {},
}, },
plugins: [ plugins: [
require('@tailwindcss/typography'), require('@tailwindcss/typography'),
require('@tailwindcss/forms') require('@tailwindcss/forms')
], ],
} }
+15 -1
View File
@@ -6,7 +6,6 @@ from common.time import format_duration
class FormatDurationTest(unittest.TestCase): class FormatDurationTest(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
return super().setUp() return super().setUp()
def test_only_days(self): def test_only_days(self):
@@ -19,6 +18,21 @@ class FormatDurationTest(unittest.TestCase):
result = format_duration(delta, "%H hours") result = format_duration(delta, "%H hours")
self.assertEqual(result, "1 hours") self.assertEqual(result, "1 hours")
def test_only_hours_fractional(self):
delta = timedelta(hours=1)
result = format_duration(delta, "%.1H hours")
self.assertEqual(result, "1.0 hours")
def test_less_than_hour_with_precision(self):
delta = timedelta(hours=0.5)
result = format_duration(delta, "%.1H hours")
self.assertEqual(result, "0.5 hours")
def test_less_than_hour_without_precision(self):
delta = timedelta(hours=0.5)
result = format_duration(delta, "%H hours")
self.assertEqual(result, "0 hours")
def test_overflow_hours(self): def test_overflow_hours(self):
delta = timedelta(hours=25) delta = timedelta(hours=25)
result = format_duration(delta, "%H hours") result = format_duration(delta, "%H hours")
+6 -3
View File
@@ -40,9 +40,9 @@ INSTALLED_APPS = [
"django.contrib.staticfiles", "django.contrib.staticfiles",
] ]
if DEBUG: # if DEBUG:
INSTALLED_APPS.append("django_extensions") INSTALLED_APPS.append("django_extensions")
INSTALLED_APPS.append("django.contrib.admin") INSTALLED_APPS.append("django.contrib.admin")
MIDDLEWARE = [ MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
@@ -68,6 +68,7 @@ TEMPLATES = [
"django.contrib.auth.context_processors.auth", "django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages", "django.contrib.messages.context_processors.messages",
"games.views.model_counts", "games.views.model_counts",
"games.views.stats_dropdown_year_range",
], ],
}, },
}, },
@@ -149,3 +150,5 @@ if _csrf_trusted_origins:
CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",") CSRF_TRUSTED_ORIGINS = _csrf_trusted_origins.split(",")
else: else:
CSRF_TRUSTED_ORIGINS = [] CSRF_TRUSTED_ORIGINS = []
USE_L10N = False
+4 -3
View File
@@ -19,9 +19,10 @@ from django.urls import include, path
from django.views.generic import RedirectView from django.views.generic import RedirectView
urlpatterns = [ urlpatterns = [
path("", RedirectView.as_view(url="/tracker/list-sessions")), path("", RedirectView.as_view(url="/tracker")),
path("tracker/", include("games.urls")), path("tracker/", include("games.urls")),
] ]
if settings.DEBUG: # if settings.DEBUG:
urlpatterns.append(path("admin/", admin.site.urls)) # urlpatterns.append(path("admin/", admin.site.urls))
urlpatterns.append(path("admin/", admin.site.urls))