122 Commits

Author SHA1 Message Date
f31280c682 Version 1.5.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-14 19:31:17 +01:00
a745d16ec3 Order purchases by date on game view 2023-11-14 19:30:19 +01:00
ae079e36ec Add purchase types 2023-11-14 19:27:00 +01:00
c8a3212b77 CI: run migrations before tests
Some checks reported errors
continuous-integration/drone/push Build was killed
2023-11-12 08:11:22 +01:00
d211326c3f Make sure empty stats are 0
Some checks failed
continuous-integration/drone/push Build is failing
2023-11-12 08:01:12 +01:00
270a291f05 Change stats years to 2000 up to current year 2023-11-12 07:50:12 +01:00
13b750ca92 Add stat for finished this year's games 2023-11-12 07:40:29 +01:00
015b6db2f7 Fix detecting manual durations
Some checks failed
continuous-integration/drone/push Build is failing
2023-11-11 15:02:28 +01:00
667b161fff Remove deprecated USE_L10N
Some checks failed
continuous-integration/drone/push Build is failing
2023-11-10 21:37:13 +01:00
5958cbf4a6 Add more tests 2023-11-10 21:34:36 +01:00
3b37f2c3f0 Fix edge case in format_duration
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #65

```python
def test_specific_precise_if_unncessary(self):
        delta = timedelta(hours=2, minutes=40)
        result = format_duration(delta, "%02.0H:%02.0m")
        self.assertEqual(result, "02:40")
```
This test fails by returning "03:40" instead. The problem is in the way `format_duration` handles fractional hours.
To fix it, we need to switch between using hours and fractional hours
depending on if minutes are present in the formatted string.
2023-11-10 20:07:41 +01:00
4517ff2b5a Fix ordering
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 21:43:17 +01:00
884ce13e26 Also prefill year between game and edition
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 21:20:12 +01:00
dd219bae9d Version 1.4.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 21:11:43 +01:00
60d29090a1 Adding new games is easier 2023-11-09 21:11:28 +01:00
1bc3ca057b Refactor, remove cruft 2023-11-09 19:35:57 +01:00
c2c0886451 Add backlog decrease count 2023-11-09 19:15:49 +01:00
b0be7b5887 Fix sort names getting mangled
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 15:41:46 +01:00
099d989f16 Pre-fill year when adding edition 2023-11-09 15:20:30 +01:00
a879360ebd UX improvements
All checks were successful
continuous-integration/drone/push Build is passing
* ignore English articles when sorting names
  * added a new sort_name field that gets automatically created
* automatically fill certain values in forms:
  * new game: name and sort name after typing
  * new edition: name and sort name when selecting game
  * new purchase: platform when selecting edition
2023-11-09 14:49:00 +01:00
866f2526e6 Fix hardcoded year 2023-11-09 10:10:44 +01:00
ce3c4b55f0 Order devices alphabetically on new session form
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 10:09:32 +01:00
c52cd822ae Use safe_division in more places 2023-11-09 10:06:14 +01:00
cdc6ca1324 Fix potential division by zero
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-09 09:18:49 +01:00
e7ed349356 Add more stats
All checks were successful
continuous-integration/drone/push Build is passing
* Finished (count)
* Unfinished (count)
* Refunded (count)
2023-11-08 18:13:48 +01:00
5052ca7dbf Add more stats
All checks were successful
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
f408bfd927 stats: change overall stats table layout 2023-11-08 15:48:06 +01:00
666dee33ba Model changes
All checks were successful
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
e0b09e051a simplify playtime range display 2023-11-06 12:05:39 +01:00
4552cf7616 Version 1.3.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-05 15:10:56 +01:00
a614b51d29 Make some pages redirect back instead to session list 2023-11-05 15:09:51 +01:00
e67aa3fda1 Add more stats
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 20:12:32 +01:00
8423fd02b4 Extend stats range to 2018
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 15:32:57 +01:00
2bd07e5f2d Remove cruft
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 15:14:57 +01:00
058b83522c Group by game instead of purchase 2023-11-02 15:14:50 +01:00
f13ed8a078 Reorder imports in views.py
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-02 09:53:28 +01:00
02d5adcb3c Remove hardcoded year 2023-11-02 09:52:59 +01:00
d6fb16bb74 Make navigation more compact 2023-11-02 09:52:42 +01:00
71b90b8202 Add stats link, year selector 2023-11-02 09:20:09 +01:00
3ee36932c3 Limit stats of single year correctly 2023-11-02 09:17:08 +01:00
391fcc79a8 Version 1.2.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-11-01 20:35:58 +01:00
57d4fd7212 Add yearly stats page
Fixes #15
2023-11-01 20:35:52 +01:00
a5b2854bf6 Add a button to start session from game overview
All checks were successful
continuous-integration/drone/push Build is passing
Fix #62
2023-10-13 19:22:43 +02:00
518c0ecd56 Add more time tests for fractional numbers
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-13 17:01:33 +02:00
a6cd7a3430 Do not format as float if no precision specified
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-13 16:58:12 +02:00
dba8414fd9 Version 1.1.2
Some checks failed
continuous-integration/drone/push Build is failing
2023-10-13 16:33:55 +02:00
0e2113eefd Display durations in a consistent manner
Fixes #61
2023-10-13 16:32:12 +02:00
c4b0347f3b Version 1.1.1
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-09 20:56:23 +02:00
c6ed21167c Remove debugging cruft from container 2023-10-09 20:56:13 +02:00
4ce15c44fc Add edit buttons to game overview, notes 2023-10-09 20:55:31 +02:00
c814b4c2cb Version 1.1.0
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-09 00:04:46 +02:00
11b9c602de Improve game overview
- add counts for each section
- add average hours per session
2023-10-09 00:00:45 +02:00
9a332593f4 Fix playtime range not working with manual session
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #55
2023-10-08 21:13:32 +02:00
22935721ca Remove unused chart functionality
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-08 21:08:03 +02:00
a2ecdcf44a ci: automatically redeploy container
Some checks reported errors
continuous-integration/drone/push Build was killed
2023-10-04 20:21:00 +02:00
3c958c4a13 Organize changelog better
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-04 18:14:21 +02:00
3db1724e22 Fix session being wrongly considered in progress
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #58
2023-10-04 18:12:13 +02:00
d2a9630b04 Fix date range on game overview
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-01 21:51:32 +02:00
e3ee832d3f Adjust game edit URL
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-01 21:28:20 +02:00
7467e2732d Add game overview page
Fixes #8
2023-10-01 21:28:02 +02:00
787ee8640f Try to shutdown container gracefully and faster
All checks were successful
continuous-integration/drone/push Build is passing
2023-10-01 19:57:15 +02:00
ab41222f3c Switch fonts to WOFF2 2023-10-01 19:53:07 +02:00
29bf3b1946 Further improve session list
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-30 19:44:35 +02:00
3f7ccea2e2 fix tailwind config to only scan relevant files 2023-09-30 18:13:50 +02:00
b5ffb3586b Update base.css 2023-09-30 16:25:19 +02:00
26d57a238e Update .vscode 2023-09-30 16:25:05 +02:00
2d5ad3182c Update CSS 2023-09-30 16:24:54 +02:00
49cc3ea0cc Improve session list
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #53
2023-09-30 15:55:38 +02:00
440e1cfb71 Focus important fields on forms
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-30 12:40:02 +02:00
1cbd8c5c55 Update generated CSS
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-20 19:06:04 +02:00
bc81a0ee8e Add hacky way to not reload page when starting/ending session
All checks were successful
continuous-integration/drone/push Build is passing
Partially fixes #52
2023-09-20 18:54:54 +02:00
c5653977ff Add time copy button, improve session editing
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-18 20:21:05 +02:00
f151730ab6 Fix bug when filtering only manual sessions (#51)
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-18 19:37:54 +02:00
f469a67d94 add robots.txt
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-17 17:58:22 +02:00
104ffc9d03 disable deleting sessions in code
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-17 17:17:22 +02:00
a4b13eb247 allow admin in prod 2/2
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-17 14:12:23 +02:00
2307fac83a allow admin in prod
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-17 14:03:46 +02:00
6b52c0d4c4 disable deleting sessions
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-17 13:28:15 +02:00
ff5d8c215d install dev dependecies
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-16 18:24:10 +02:00
cdb3b89b08 do not filter out refunded games when adding session
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-16 18:18:03 +02:00
ffa8198540 allow django admit 2023-09-16 18:17:36 +02:00
0b7da3550c Change recent session view to current year
All checks were successful
continuous-integration/drone/push Build is passing
2023-09-14 18:49:16 +02:00
e1655d6cfa input datetime-local needs day to also be two digits
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-02 21:33:07 +01:00
29c41865d0 Exclude refunded purchases from start session form
All checks were successful
continuous-integration/drone/push Build is passing
2023-03-01 22:07:49 +01:00
d21b461726 Update styles
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-21 23:51:07 +01:00
95489cfb78 Add .editorconfig 2023-02-21 23:50:09 +01:00
fa4f1c4810 Improve forms, add helper buttons on add session form 2023-02-21 23:49:57 +01:00
366c25a1ff Register Edition, Device in the admin UI 2023-02-20 22:01:20 +01:00
a3042caa20 Use date and datetime inputs
All checks were successful
continuous-integration/drone/push Build is passing
Properly implements 4d91a76513
2023-02-20 21:33:15 +01:00
7997f9bbb2 Sort games by name on edition form 2023-02-20 20:17:42 +01:00
b78c4ba9c5 Sync game name and edition name fields for QOL
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-20 18:21:48 +01:00
1df889c45d Fix gitignoring root static folder 2023-02-20 18:20:49 +01:00
468d05a9e2 Sort games alphabetically on edition form 2023-02-20 17:37:14 +01:00
2640a49734 Version 1.0.3
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-20 17:18:26 +01:00
65c175afb2 Allow editing filtered entities from session list 2023-02-20 17:16:19 +01:00
0814071a26 Improve display of editions on purchase form 2023-02-20 17:15:21 +01:00
5f845f866e Sort platforms alphabetically on edition form 2023-02-20 17:14:43 +01:00
c3d4697470 Add wikidata ID and year for editions 2023-02-20 17:13:35 +01:00
77293f03e9 Add icons
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-19 17:20:57 +01:00
1fa364e2ec Change timetracker icon 2023-02-19 16:35:04 +01:00
4a6f4a2f9a Add icons for session filters
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-19 16:18:14 +01:00
9590988b6a Fix error when generating charts with less than 2 entries
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-19 14:55:06 +01:00
938c82a395 Allow filtering by game, edition, purchase from the session list 2023-02-19 14:36:12 +01:00
33939f631c Fix form styling
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-18 22:36:26 +01:00
ac8cd6534a Version 1.0.2
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-18 21:48:55 +01:00
51d8e953c0 Allow editing editions 2023-02-18 21:47:25 +01:00
2eec677f41 Allow editing purchases 2023-02-18 21:44:19 +01:00
f2eb14d3ef Fix session starting 2023-02-18 21:43:51 +01:00
c337d2200f Fix ownership display
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-18 21:12:44 +01:00
8a8b05b0bd Add support for device info
Closes #49
2023-02-18 21:12:18 +01:00
9446065271 Add support for purchase ownership information
Closes #48
2023-02-18 20:57:03 +01:00
755093845d Add support for prices on purchases 2023-02-18 20:56:23 +01:00
d4ab0596da Re-introduce index view
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-18 20:50:36 +01:00
8dcbe2f0ad Fix displaying of "filterying by..." text 2023-02-18 20:50:19 +01:00
25bc74eff1 Add support for game editions (#28) 2023-02-18 20:49:46 +01:00
8a7d083fb2 Fix make css 2023-02-18 20:35:59 +01:00
8296ebcf31 Fix redirect to index
All checks were successful
continuous-integration/drone/push Build is passing
2023-02-06 17:23:44 +01:00
04a4f2e0be Order homepage sessions from newest
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-31 16:37:44 +01:00
4070b4e46e Version 1.0.1
All checks were successful
continuous-integration/drone/push Build is passing
2023-01-30 22:17:47 +01:00
4892218c83 Show only last 30 days on homepage
Fixes #47
2023-01-30 22:16:28 +01:00
6b00a950ce Show markers on smaller graphs 2023-01-30 22:01:27 +01:00
feee9d6dac Make it possible to edit sessions
All checks were successful
continuous-integration/drone/push Build is passing
Fixes #46
2023-01-30 17:38:44 +01:00
70 changed files with 1073 additions and 2037 deletions

View File

@ -5,13 +5,4 @@
.venv .venv
.vscode .vscode
node_modules node_modules
static src/timetracker/static/*
.drone.yml
.editorconfig
.gitignore
Caddyfile
CHANGELOG.md
db.sqlite3
docker-compose*
Dockerfile
Makefile

View File

@ -5,7 +5,7 @@ name: default
steps: steps:
- name: test - name: test
image: python:3.12 image: python:3.10
commands: commands:
- python -m pip install poetry - python -m pip install poetry
- poetry install - poetry install
@ -30,7 +30,9 @@ steps:
image: plugins/docker image: plugins/docker
settings: settings:
repo: registry.kucharczyk.xyz/timetracker repo: registry.kucharczyk.xyz/timetracker
auto_tag: true tags:
- ${DRONE_COMMIT_REF}
- ${DRONE_COMMIT_BRANCH}
when: when:
branch: branch:
exclude: exclude:

View File

@ -1,27 +0,0 @@
name: Django CI/CD
on:
push:
branches: [ main ]
pull_request:
branches: [ main ]
jobs:
build-and-push:
needs: test
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build and push
uses: docker/build-push-action@v5
with:
context: .
push: true
tags: |
registry.kucharczyk.xyz/timetracker:latest
registry.kucharczyk.xyz/timetracker:${{ env.VERSION_NUMBER }}
env:
VERSION_NUMBER: 1.5.1

View File

@ -1,10 +0,0 @@
repos:
- repo: https://github.com/psf/black
rev: 22.12.0
hooks:
- id: black
- repo: https://github.com/pycqa/isort
rev: 5.12.0
hooks:
- id: isort
name: isort (python)

View File

@ -1,15 +1,3 @@
## Unreleased
## Improved
* game overview: improve how editions and purchases are displayed
* add purchase: only allow choosing purchases of selected edition
## 1.5.1 / 2023-11-14 21:10+01:00
## Improved
* Disallow choosing non-game purchase as related purchase
* Improve display of purchases
## 1.5.0 / 2023-11-14 19:27+01:00 ## 1.5.0 / 2023-11-14 19:27+01:00
## New ## New

View File

@ -1,45 +1,27 @@
FROM python:3.12.0-slim-bullseye FROM node as css
WORKDIR /app
COPY . /app
RUN npm install && \
npx tailwindcss -i ./common/input.css -o ./static/base.css --minify
ENV VERSION_NUMBER=1.5.1 \ FROM python:3.10.9-slim-bullseye
PROD=1 \
PYTHONUNBUFFERED=1 \
PYTHONFAULTHANDLER=1 \
PYTHONHASHSEED=random \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
PIP_DEFAULT_TIMEOUT=100 \
PIP_ROOT_USER_ACTION=ignore \
POETRY_NO_INTERACTION=1 \
POETRY_VIRTUALENVS_CREATE=false \
POETRY_CACHE_DIR='/var/cache/pypoetry' \
POETRY_HOME='/usr/local'
RUN apt-get update && apt-get upgrade -y \ ENV VERSION_NUMBER 1.5.0
&& apt-get install --no-install-recommends -y \ ENV PROD 1
bash \ ENV PYTHONUNBUFFERED=1
curl \
&& curl -sSL 'https://install.python-poetry.org' | python - \
&& poetry --version \
&& apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \
&& apt-get clean -y && rm -rf /var/lib/apt/lists/*
RUN useradd -m --uid 1000 timetracker \ RUN useradd -m --uid 1000 timetracker
&& mkdir -p '/var/www/django/static' \
&& chown timetracker:timetracker '/var/www/django/static'
WORKDIR /home/timetracker/app WORKDIR /home/timetracker/app
COPY . /home/timetracker/app/ COPY . /home/timetracker/app/
RUN chown -R timetracker:timetracker /home/timetracker/app RUN chown -R timetracker:timetracker /home/timetracker/app
COPY --from=css ./app/static/base.css /home/timetracker/app/static/base.css
COPY entrypoint.sh / COPY entrypoint.sh /
RUN chmod +x /entrypoint.sh RUN chmod +x /entrypoint.sh
RUN --mount=type=cache,target="$POETRY_CACHE_DIR" \
echo "$PROD" \
&& poetry version \
&& poetry run pip install -U pip \
&& poetry install --only main --no-interaction --no-ansi --sync
USER timetracker USER timetracker
ENV PATH="$PATH:/home/timetracker/.local/bin"
RUN pip install --no-cache-dir poetry
RUN poetry install
EXPOSE 8000 EXPOSE 8000
CMD [ "/entrypoint.sh" ] CMD [ "/entrypoint.sh" ]

View File

@ -1,12 +1,18 @@
all: migrate all: css migrate
initialize: npm migrate sethookdir loadplatforms initialize: npm css migrate sethookdir loadplatforms
HTMLFILES := $(shell find games/templates -type f) HTMLFILES := $(shell find games/templates -type f)
npm: npm:
npm install npm install
css: common/input.css
npx tailwindcss -i ./common/input.css -o ./games/static/base.css
css-dev: css
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

109
common/input.css Normal file
View File

@ -0,0 +1,109 @@
@tailwind base;
@tailwind components;
@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 {
@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,
select,
textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
}
form input:disabled,
select:disabled,
textarea:disabled {
@apply dark:bg-slate-700 dark:text-slate-400;
}
@media screen and (min-width: 768px) {
form input,
select,
textarea {
width: 300px;
}
}
@media screen and (max-width: 768px) {
form input,
select,
textarea {
width: 150px;
}
}
#button-container button {
@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;
}

View File

@ -1,5 +1,12 @@
import re import re
from datetime import timedelta from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
from django.conf import settings
def now() -> datetime:
return datetime.now(ZoneInfo(settings.TIME_ZONE))
def _safe_timedelta(duration: timedelta | int | None): def _safe_timedelta(duration: timedelta | int | None):

View File

@ -10,14 +10,13 @@ services:
- CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz" - CSRF_TRUSTED_ORIGINS="https://tracker.kucharczyk.xyz"
user: "1000" user: "1000"
volumes: volumes:
- "static-files:/var/www/django/static" - "static-files:/home/timetracker/app/static"
- "$PWD/db.sqlite3:/home/timetracker/app/db.sqlite3"
restart: unless-stopped restart: unless-stopped
frontend: frontend:
image: caddy image: caddy
volumes: volumes:
- "static-files:/usr/share/caddy:ro" - "static-files:/usr/share/caddy"
- "$PWD/Caddyfile:/etc/caddy/Caddyfile" - "$PWD/Caddyfile:/etc/caddy/Caddyfile"
ports: ports:
- "8000:8000" - "8000:8000"
@ -27,4 +26,3 @@ services:
volumes: volumes:
static-files: static-files:

View File

@ -1,17 +0,0 @@
module.exports = {
env: {
browser: true,
es2021: true
},
extends: ["eslint/recommended", "plugin:react/recommended", "plugin:prettier/recommended"],
overrides: [],
parserOptions: {
ecmaVersion: "latest",
sourceType: "module"
},
plugins: ["react"],
rules: {},
parserOptions: {
ecmaFeatures: { jsx: true }
}
};

24
frontend/.gitignore vendored
View File

@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@ -1,9 +0,0 @@
{
"semi": true,
"tabWidth": 2,
"printWidth": 100,
"singleQuote": true,
"trailingComma": "none",
"bracketSameLine": false,
"singleAttributePerLine": true
}

View File

@ -1,17 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description" content="Self-hosted time-tracker."/>
<meta name="keywords" content="time, tracking, video games, self-hosted"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- TODO: replace with own icon -->
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
<link rel="stylesheet" href="https://rsms.me/inter/inter.css"/>
<title>Timetracker</title>
</head>
<body class="dark">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

View File

@ -1,29 +0,0 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"autoprefixer": "^10.4.13",
"postcss": "^8.4.21",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tailwindcss": "^3.2.4"
},
"devDependencies": {
"@types/react": "^18.0.26",
"@types/react-dom": "^18.0.9",
"@vitejs/plugin-react": "^3.0.0",
"eslint": "^8.32.0",
"eslint-plugin-import": "^2.27.5",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-react": "^7.32.1",
"eslint-plugin-react-hooks": "^4.6.0",
"vite": "^4.0.0"
}
}

View File

@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

View File

@ -1,42 +0,0 @@
import { useState } from 'react'
import './App.css'
function App() {
return (
<>
<div className="dark:bg-gray-800 min-h-screen">
<nav className="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
<div className="container flex flex-wrap items-center justify-between mx-auto">
<a href="{% url 'index' %}" className="flex items-center">
<span className="text-4xl"></span>
<span className="self-center text-xl font-semibold whitespace-nowrap text-white">Timetracker</span>
</a>
<div className="w-full md:block md:w-auto">
<ul
className="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li><a className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New Game</a></li>
<li><a className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_platform' %}">New Platform</a></li>
{/* {% if game_available and platform_available %} */}
<li><a className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_purchase' %}">New Purchase</a></li>
{/* {% endif %} */}
{/* {% if purchase_available %} */}
<li><a className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_session' %}">New Session</a></li>
{/* {% endif %} */}
{/* {% if session_count > 0 %} */}
<li><a className="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
{/* {% endif %} */}
</ul>
</div>
</div>
</nav>
{/* {% block content %}No content here.{% endblock content %} */}
</div>
{/* {% load version %} */}
{/* <span className="fixed left-2 bottom-2 text-xs text-slate-300 dark:text-slate-600">{% version %} ({% version_date %})</span> */}
</>
)
}
export default App;

View File

@ -1,71 +0,0 @@
import { Link } from 'react-router-dom';
function Nav() {
return (
<nav className="mb-4 bg-white dark:bg-gray-900 border-gray-200 rounded">
<div className="container flex flex-wrap items-center justify-between mx-auto">
<Link
to="/"
className="flex items-center"
>
<span className="text-4xl"></span>
<span className="self-center text-xl font-semibold whitespace-nowrap text-white">
Timetracker
</span>
</Link>
<div className="w-full md:block md:w-auto">
<ul className="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li>
<a
className="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'add_game' %}"
>
New Game
</a>
</li>
<li>
<a
className="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'add_platform' %}"
>
New Platform
</a>
</li>
{/* {% if game_available and platform_available %} */}
<li>
<a
className="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'add_purchase' %}"
>
New Purchase
</a>
</li>
{/* {% endif %} */}
{/* {% if purchase_available %} */}
<li>
<a
className="block py-2 pl-3 pr-4 hover:underline"
href="{% url 'add_session' %}"
>
New Session
</a>
</li>
{/* {% endif %} */}
{/* {% if session_count > 0 %} */}
<li>
<Link
className="block py-2 pl-3 pr-4 hover:underline"
to="/sessions"
>
All Sessions
</Link>
</li>
{/* {% endif %} */}
</ul>
</div>
</div>
</nav>
);
}
export default Nav;

View File

@ -1,162 +0,0 @@
export default function SessionList() {
const data = [
{
"url": "http://localhost:8000/api/sessions/25/",
"timestamp_start": "2020-01-01T00:00:00+01:00",
"timestamp_end": null,
"duration_manual": "12:00:00",
"duration_calculated": "00:00:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/26/",
"timestamp_start": "2022-12-31T15:25:00+01:00",
"timestamp_end": "2022-12-31T17:25:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:00:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/2/"
},
{
"url": "http://localhost:8000/api/sessions/27/",
"timestamp_start": "2023-01-01T23:00:00+01:00",
"timestamp_end": "2023-01-02T00:28:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "01:28:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/28/",
"timestamp_start": "2023-01-02T22:08:00+01:00",
"timestamp_end": "2023-01-03T01:08:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "03:00:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/29/",
"timestamp_start": "2023-01-03T22:36:00+01:00",
"timestamp_end": "2023-01-04T00:12:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "01:36:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/30/",
"timestamp_start": "2023-01-04T20:35:00+01:00",
"timestamp_end": "2023-01-04T22:36:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:01:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/31/",
"timestamp_start": "2023-01-06T18:48:00+01:00",
"timestamp_end": "2023-01-06T23:39:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "04:51:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/32/",
"timestamp_start": "2023-01-07T23:49:00+01:00",
"timestamp_end": "2023-01-08T01:43:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "01:54:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/33/",
"timestamp_start": "2023-01-08T16:21:00+01:00",
"timestamp_end": "2023-01-08T18:27:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:06:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/34/",
"timestamp_start": "2023-01-08T19:04:00+01:00",
"timestamp_end": "2023-01-08T22:03:00+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:59:00",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/35/",
"timestamp_start": "2023-01-09T19:35:48+01:00",
"timestamp_end": "2023-01-09T22:13:20.519058+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "02:37:32.519058",
"note": "",
"purchase": "http://localhost:8000/api/purchases/3/"
},
{
"url": "http://localhost:8000/api/sessions/36/",
"timestamp_start": "2023-01-10T15:50:12+01:00",
"timestamp_end": "2023-01-10T17:03:45.424429+01:00",
"duration_manual": "00:00:00",
"duration_calculated": "01:13:33.424429",
"note": "",
"purchase": "http://localhost:8000/api/purchases/4/"
}
]
const header = ["url", "timestamp_start", "timestamp_end", "duration_manual", "duration_calculated", "note", "purchase"]
// const header = ["Name", "Platform", "Start", "End", "Duration", "Manage"]
return (
<>
<div id="session-table" className="gap-4 shadow rounded-xl max-w-screen-lg mx-auto dark:bg-slate-700 p-2 justify-center">
{header.map(column => {
<div className="dark:border-white dark:text-slate-300 text-lg">{column}</div>
})}
{data.map(session => {
<>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.url }
</a>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.timestamp_start }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.timestamp_end }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.duration_manual }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.duration_calculated }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.note }
</a>
</div>
<div className="dark:text-white overflow-hidden text-ellipsis whitespace-nowrap">
<a className="hover:underline" href="">
{ session.purchase }
</a>
</div>
</div>
</>
})}
</div>
</>
)
}

View File

@ -1,16 +0,0 @@
import { useRouteError } from "react-router-dom";
export default function ErrorPage() {
const error = useRouteError()
console.error(error)
return (
<div className="container text-center">
<h1 className="text-3xl">Oops!</h1>
<p>Sorry, an unexpected error has occurred.</p>
<p>
<i>{error.statusText || error.message}</i>
</p>
</div>
)
}

View File

@ -1,22 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
form label {
@apply dark:text-slate-400;
}
form input,
select,
textarea {
@apply dark:border dark:border-slate-900 dark:bg-slate-500 dark:text-slate-100;
}
#session-table {
display: grid;
grid-template-columns: 3fr 1fr repeat(2, 2fr) 0.5fr 1fr;
}
#button-container button {
@apply mx-1;
}

View File

@ -1,34 +0,0 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App'
import './index.css'
import { createBrowserRouter, RouterProvider } from 'react-router-dom'
// import { loader as sessionLoader } from './routes/sessions'
import ErrorPage from "./error-page"
import SessionList from './components/SessionList'
// import Session from './routes/sessions'
const router = createBrowserRouter([
{
path: "/",
element: <App />,
errorElement: <ErrorPage />,
// loader: sessionLoader,
children: [
{
path: "sessions/",
element: <SessionList />
}
]
},
// {
// path: "sessions",
// element: <SessionList />
// }
])
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<RouterProvider router={router} />
</React.StrictMode>,
)

View File

@ -1,17 +0,0 @@
export async function api(url) {
const response = await fetch(url);
if (response.ok) {
const jsonValue = await response.json();
return Promise.resolve(jsonValue);
} else {
return Promise.reject('Response was not OK.');
}
}
export async function getSession(sessionId) {
return await api(`/api/sessions/${sessionId}/`);
}
export async function getSessionList() {
return await api(`/api/sessions/`);
}

View File

@ -1,12 +0,0 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: "class",
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
theme: {
fontFamily: {
sans: ["Inter", "sans-serif"],
},
extend: {},
},
plugins: [require("@tailwindcss/typography"), require("@tailwindcss/forms")],
};

View File

@ -1,12 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
server: {
proxy: {
"/api": "http://127.0.0.1:8001",
},
},
});

View File

@ -1,6 +1,6 @@
from django.contrib import admin from django.contrib import admin
from games.models import Device, Edition, 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)

View File

@ -1,7 +1,6 @@
from django import forms from django import forms
from django.urls import reverse
from games.models import Device, Edition, Game, Platform, Purchase, Session from games.models import Game, Platform, Purchase, Session, Edition, Device
custom_date_widget = forms.DateInput(attrs={"type": "date"}) custom_date_widget = forms.DateInput(attrs={"type": "date"})
custom_datetime_widget = forms.DateTimeInput( custom_datetime_widget = forms.DateTimeInput(
@ -51,31 +50,13 @@ class IncludePlatformSelect(forms.Select):
class PurchaseForm(forms.ModelForm): class PurchaseForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Automatically update related_purchase <select/>
# to only include purchases of the selected edition.
related_purchase_by_edition_url = reverse("related_purchase_by_edition")
self.fields["edition"].widget.attrs.update(
{
"hx-trigger": "load, click",
"hx-get": related_purchase_by_edition_url,
"hx-target": "#id_related_purchase",
"hx-swap": "outerHTML",
}
)
edition = EditionChoiceField( edition = EditionChoiceField(
queryset=Edition.objects.order_by("sort_name"), queryset=Edition.objects.order_by("sort_name"),
widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}), widget=IncludePlatformSelect(attrs={"autoselect": "autoselect"}),
) )
platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name")) platform = forms.ModelChoiceField(queryset=Platform.objects.order_by("name"))
related_purchase = forms.ModelChoiceField( related_purchase = forms.ModelChoiceField(
queryset=Purchase.objects.filter(type=Purchase.GAME).order_by( queryset=Purchase.objects.order_by("edition__sort_name")
"edition__sort_name"
),
required=False,
) )
class Meta: class Meta:
@ -99,27 +80,6 @@ class PurchaseForm(forms.ModelForm):
"name", "name",
] ]
def clean(self):
cleaned_data = super().clean()
purchase_type = cleaned_data.get("type")
related_purchase = cleaned_data.get("related_purchase")
name = cleaned_data.get("name")
# Set the type on the instance to use get_type_display()
# This is safe because we're not saving the instance.
self.instance.type = purchase_type
if purchase_type != Purchase.GAME:
type_display = self.instance.get_type_display()
if not related_purchase:
self.add_error(
"related_purchase",
f"{type_display} must have a related purchase.",
)
if not name:
self.add_error("name", f"{type_display} must have a name.")
return cleaned_data
class IncludeNameSelect(forms.Select): class IncludeNameSelect(forms.Select):
def create_option(self, name, value, *args, **kwargs): def create_option(self, name, value, *args, **kwargs):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-01-19 18:30 # Generated by Django 4.1.5 on 2023-01-19 18:30
import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-02-18 16:29 # Generated by Django 4.1.5 on 2023-02-18 16:29
import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-02-18 19:06 # Generated by Django 4.1.5 on 2023-02-18 19:06
import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-02-18 19:59 # Generated by Django 4.1.5 on 2023-02-18 19:59
import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-06 11:10 # Generated by Django 4.1.5 on 2023-11-06 11:10
import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-06 18:14 # Generated by Django 4.1.5 on 2023-11-06 18:14
import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
def rename_duplicates(apps, schema_editor): def rename_duplicates(apps, schema_editor):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-06 16:53 # Generated by Django 4.1.5 on 2023-11-06 16:53
import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,7 +1,7 @@
# Generated by Django 4.1.5 on 2023-11-14 08:41 # Generated by Django 4.1.5 on 2023-11-14 08:41
import django.db.models.deletion
from django.db import migrations, models from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration): class Migration(migrations.Migration):

View File

@ -1,7 +1,6 @@
# Generated by Django 4.1.5 on 2023-11-14 11:05 # Generated by Django 4.1.5 on 2023-11-14 11:05
from django.db import migrations, models from django.db import migrations, models
from games.models import Purchase from games.models import Purchase

View File

@ -1,26 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-14 21:19
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0028_purchase_name"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="related_purchase",
field=models.ForeignKey(
blank=True,
default=None,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="related_purchases",
to="games.purchase",
),
),
]

View File

@ -1,18 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-15 12:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0029_alter_purchase_related_purchase"),
]
operations = [
migrations.AlterField(
model_name="purchase",
name="name",
field=models.CharField(blank=True, default="", max_length=255, null=True),
),
]

View File

@ -1,44 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-15 13:51
import django.utils.timezone
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0030_alter_purchase_name"),
]
operations = [
migrations.AddField(
model_name="device",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="edition",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="game",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="platform",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="purchase",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
migrations.AddField(
model_name="session",
name="created_at",
field=models.DateTimeField(default=django.utils.timezone.now),
),
]

View File

@ -1,52 +0,0 @@
# Generated by Django 4.1.5 on 2023-11-15 18:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("games", "0031_device_created_at_edition_created_at_game_created_at_and_more"),
]
operations = [
migrations.AlterModelOptions(
name="session",
options={"get_latest_by": "timestamp_start"},
),
migrations.AddField(
model_name="session",
name="modified_at",
field=models.DateTimeField(auto_now=True),
),
migrations.AlterField(
model_name="device",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="edition",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="game",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="platform",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="purchase",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
migrations.AlterField(
model_name="session",
name="created_at",
field=models.DateTimeField(auto_now_add=True),
),
]

View File

@ -1,11 +1,11 @@
from datetime import timedelta from datetime import datetime, timedelta
from typing import Any
from django.core.exceptions import ValidationError from zoneinfo import ZoneInfo
from django.db import models
from django.db.models import F, Manager, Sum
from django.utils import timezone
from common.time import format_duration from common.time import format_duration
from django.conf import settings
from django.db import models
from django.db.models import F, Manager, Sum
class Game(models.Model): class Game(models.Model):
@ -13,7 +13,6 @@ class Game(models.Model):
sort_name = models.CharField(max_length=255, null=True, blank=True, default=None) sort_name = models.CharField(max_length=255, null=True, blank=True, default=None)
year_released = models.IntegerField(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) wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return self.name return self.name
@ -44,7 +43,6 @@ class Edition(models.Model):
) )
year_released = models.IntegerField(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) wikidata = models.CharField(max_length=50, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return self.sort_name return self.sort_name
@ -122,27 +120,18 @@ class Purchase(models.Model):
max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL max_length=2, choices=OWNERSHIP_TYPES, default=DIGITAL
) )
type = models.CharField(max_length=255, choices=TYPES, default=GAME) type = models.CharField(max_length=255, choices=TYPES, default=GAME)
name = models.CharField(max_length=255, default="", null=True, blank=True) name = models.CharField(
related_purchase = models.ForeignKey( max_length=255, default="Unknown Name", null=True, blank=True
"Purchase", )
on_delete=models.SET_NULL, related_purchase = models.ForeignKey(
default=None, "Purchase", on_delete=models.SET_NULL, default=None, null=True, blank=True
null=True,
blank=True,
related_name="related_purchases",
) )
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
additional_info = [ platform_info = self.platform
self.get_type_display() if self.type != Purchase.GAME else "", if self.platform != self.edition.platform:
f"{self.edition.platform} version on {self.platform}" platform_info = f"{self.edition.platform} version on {self.platform}"
if self.platform != self.edition.platform return f"{self.edition} ({platform_info}, {self.edition.year_released}, {self.get_ownership_type_display()})"
else self.platform,
self.edition.year_released,
self.get_ownership_type_display(),
]
return f"{self.edition} ({', '.join(filter(None, map(str, additional_info)))})"
def is_game(self): def is_game(self):
return self.type == self.GAME return self.type == self.GAME
@ -150,17 +139,12 @@ class Purchase(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
if self.type == Purchase.GAME: if self.type == Purchase.GAME:
self.name = "" self.name = ""
elif self.type != Purchase.GAME and not self.related_purchase:
raise ValidationError(
f"{self.get_type_display()} must have a related purchase."
)
super().save(*args, **kwargs) super().save(*args, **kwargs)
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, null=True, blank=True, default=None) group = models.CharField(max_length=255, null=True, blank=True, default=None)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return self.name return self.name
@ -178,9 +162,6 @@ class SessionQuerySet(models.QuerySet):
class Session(models.Model): class Session(models.Model):
class Meta:
get_latest_by = "timestamp_start"
purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE) purchase = models.ForeignKey("Purchase", on_delete=models.CASCADE)
timestamp_start = models.DateTimeField() timestamp_start = models.DateTimeField()
timestamp_end = models.DateTimeField(blank=True, null=True) timestamp_end = models.DateTimeField(blank=True, null=True)
@ -194,8 +175,6 @@ class Session(models.Model):
default=None, default=None,
) )
note = models.TextField(blank=True, null=True) note = models.TextField(blank=True, null=True)
created_at = models.DateTimeField(auto_now_add=True)
modified_at = models.DateTimeField(auto_now=True)
objects = SessionQuerySet.as_manager() objects = SessionQuerySet.as_manager()
@ -204,10 +183,10 @@ class Session(models.Model):
return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})" return f"{str(self.purchase)} {str(self.timestamp_start.date())} ({self.duration_formatted()}{mark})"
def finish_now(self): def finish_now(self):
self.timestamp_end = timezone.now() self.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
def start_now(): def start_now():
self.timestamp_start = timezone.now() self.timestamp_start = datetime.now(ZoneInfo(settings.TIME_ZONE))
def duration_seconds(self) -> timedelta: def duration_seconds(self) -> timedelta:
manual = timedelta(0) manual = timedelta(0)
@ -260,7 +239,6 @@ class Device(models.Model):
] ]
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN) type = models.CharField(max_length=3, choices=DEVICE_TYPES, default=UNKNOWN)
created_at = models.DateTimeField(auto_now_add=True)
def __str__(self): def __str__(self):
return f"{self.name} ({self.get_type_display()})" return f"{self.name} ({self.get_type_display()})"

View File

View File

@ -808,10 +808,6 @@ select {
margin-bottom: 2.5rem; margin-bottom: 2.5rem;
} }
.mb-2 {
margin-bottom: 0.5rem;
}
.mb-4 { .mb-4 {
margin-bottom: 1rem; margin-bottom: 1rem;
} }
@ -1235,19 +1231,6 @@ textarea:disabled) {
color: rgb(148 163 184 / var(--tw-text-opacity)); color: rgb(148 163 184 / var(--tw-text-opacity));
} }
.errorlist {
margin-top: 1rem;
margin-bottom: 0.25rem;
width: 300px;
--tw-bg-opacity: 1;
background-color: rgb(220 38 38 / var(--tw-bg-opacity));
padding-top: 0.5rem;
padding-bottom: 0.5rem;
padding-left: 0.75rem;
--tw-text-opacity: 1;
color: rgb(226 232 240 / var(--tw-text-opacity));
}
@media screen and (min-width: 768px) { @media screen and (min-width: 768px) {
form input, form input,
select, select,
@ -1409,11 +1392,6 @@ th label {
background-color: rgb(17 24 39 / var(--tw-bg-opacity)); background-color: rgb(17 24 39 / var(--tw-bg-opacity));
} }
:is(.dark .dark\:text-slate-400) {
--tw-text-opacity: 1;
color: rgb(148 163 184 / var(--tw-text-opacity));
}
:is(.dark .dark\:text-slate-500) { :is(.dark .dark\:text-slate-500) {
--tw-text-opacity: 1; --tw-text-opacity: 1;
color: rgb(100 116 139 / var(--tw-text-opacity)); color: rgb(100 116 139 / var(--tw-text-opacity));
@ -1451,10 +1429,6 @@ th label {
padding-right: 1rem; padding-right: 1rem;
} }
.sm\:pl-12 {
padding-left: 3rem;
}
.sm\:pl-2 { .sm\:pl-2 {
padding-left: 0.5rem; padding-left: 0.5rem;
} }

View File

@ -1,9 +1,4 @@
import { import { syncSelectInputUntilChanged, getEl, conditionalElementHandler } from "./utils.js";
syncSelectInputUntilChanged,
getEl,
disableElementsWhenTrue,
disableElementsWhenFalse,
} from "./utils.js";
let syncData = [ let syncData = [
{ {
@ -16,28 +11,21 @@ let syncData = [
syncSelectInputUntilChanged(syncData, "form"); syncSelectInputUntilChanged(syncData, "form");
function setupElementHandlers() {
disableElementsWhenTrue("#id_type", "game", [
"#id_name",
"#id_related_purchase",
]);
disableElementsWhenFalse("#id_type", "game", ["#id_date_finished"]);
}
document.addEventListener("DOMContentLoaded", setupElementHandlers); let myConfig = [
document.addEventListener("htmx:afterSwap", setupElementHandlers); () => {
getEl("#id_type").onchange = () => { return getEl("#id_type").value == "game";
setupElementHandlers(); },
}; ["#id_name", "#id_related_purchase"],
(el) => {
document.body.addEventListener('htmx:beforeRequest', function(event) { el.disabled = "disabled";
// Assuming 'Purchase1' is the element that triggers the HTMX request },
if (event.target.id === 'id_edition') { (el) => {
var idEditionValue = document.getElementById('id_edition').value; el.disabled = "";
// Condition to check - replace this with your actual logic
if (idEditionValue != '') {
event.preventDefault(); // This cancels the HTMX request
}
} }
}); ]
document.DOMContentLoaded = conditionalElementHandler(...myConfig)
getEl("#id_type").onchange = () => {
conditionalElementHandler(...myConfig)
}

View File

@ -99,72 +99,37 @@ function getEl(selector) {
return document.getElementsByClassName(selector) return document.getElementsByClassName(selector)
} }
else { else {
return document.getElementsByTagName(selector) return document.getElementsByName(selector)
} }
} }
/** /**
* @description Applies different behaviors to elements based on multiple conditional configurations. * @description Does something to elements when something happens.
* Each configuration is an array containing a condition function, an array of target element selectors, * @param {() => boolean} condition The condition that is being tested.
* and two callback functions for handling matched and unmatched conditions. * @param {string[]} targetElements
* @param {...Array} configs Each configuration is an array of the form: * @param {(elementName: HTMLElement) => void} callbackfn1 Called when the condition matches.
* - 0: {function(): boolean} condition - Function that returns true or false based on a condition. * @param {(elementName: HTMLElement) => void} callbackfn2 Called when the condition doesn't match.
* - 1: {string[]} targetElements - Array of CSS selectors for target elements.
* - 2: {function(HTMLElement): void} callbackfn1 - Function to execute when condition is true.
* - 3: {function(HTMLElement): void} callbackfn2 - Function to execute when condition is false.
*/ */
function conditionalElementHandler(...configs) { function conditionalElementHandler(condition, targetElements, callbackfn1, callbackfn2) {
configs.forEach(([condition, targetElements, callbackfn1, callbackfn2]) => { if (condition()) {
if (condition()) { targetElements.forEach((elementName) => {
targetElements.forEach(elementName => { let el = getEl(elementName);
let el = getEl(elementName); if (el === null) {
if (el === null) { console.error("Element ${elementName} doesn't exist.");
console.error(`Element ${elementName} doesn't exist.`); } else {
} else { callbackfn1(el);
callbackfn1(el); }
} });
}); } else {
} else { targetElements.forEach((elementName) => {
targetElements.forEach(elementName => { let el = getEl(elementName);
let el = getEl(elementName); if (el === null) {
if (el === null) { console.error("Element ${elementName} doesn't exist.");
console.error(`Element ${elementName} doesn't exist.`); } else {
} else { callbackfn2(el);
callbackfn2(el); }
} });
}); }
}
});
} }
function disableElementsWhenFalse(targetSelect, targetValue, elementList) { export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler };
return conditionalElementHandler([
() => {
return getEl(targetSelect).value != targetValue;
},
elementList,
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
},
]);
}
function disableElementsWhenTrue(targetSelect, targetValue, elementList) {
return conditionalElementHandler([
() => {
return getEl(targetSelect).value == targetValue;
},
elementList,
(el) => {
el.disabled = "disabled";
},
(el) => {
el.disabled = "";
},
]);
}
export { toISOUTCString, syncSelectInputUntilChanged, getEl, conditionalElementHandler, disableElementsWhenFalse, disableElementsWhenTrue, getValueFromProperty };

View File

@ -1,24 +1,25 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% block title %}
{{ title }} {% block title %}{{ title }}{% endblock title %}
{% endblock title %}
{% block content %} {% block content %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<table class="mx-auto"> <table class="mx-auto">
{% csrf_token %} {% csrf_token %}
{{ form.as_table }}
<tr> {{ form.as_table }}
<td></td> <tr>
<td> <td></td>
<input type="submit" value="Submit" /> <td><input type="submit" value="Submit"/></td>
</td> </tr>
</tr>
</table> </table>
</form> </form>
{% endblock content %} {% endblock content %}
{% block scripts %} {% block scripts %}
{% if script_name %} {% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script> <script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %} {% endif %}
{% endblock scripts %} {% endblock scripts %}

View File

@ -1,32 +1,29 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% block title %}
{{ title }} {% block title %}{{ title }}{% endblock title %}
{% endblock title %}
{% block content %} {% block content %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<table class="mx-auto"> <table class="mx-auto">
{% csrf_token %} {% csrf_token %}
{{ form.as_table }}
<tr> {{ form.as_table }}
<td></td> <tr>
<td> <td></td>
<input type="submit" name="submit" value="Submit" /> <td><input type="submit" name="submit" value="Submit"/></td>
</td> </tr>
</tr> <tr>
<tr> <td></td>
<td></td> <td><input type="submit" name="submit_and_redirect" value="Submit & Create Purchase"/></td>
<td> </tr>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Purchase" />
</td>
</tr>
</table> </table>
</form> </form>
{% endblock content %} {% endblock content %}
{% block scripts %} {% block scripts %}
{% if script_name %} {% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script> <script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %} {% endif %}
{% endblock scripts %} {% endblock scripts %}

View File

@ -1,32 +1,29 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% block title %}
{{ title }} {% block title %}{{ title }}{% endblock title %}
{% endblock title %}
{% block content %} {% block content %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<table class="mx-auto"> <table class="mx-auto">
{% csrf_token %} {% csrf_token %}
{{ form.as_table }}
<tr> {{ form.as_table }}
<td></td> <tr>
<td> <td></td>
<input type="submit" name="submit" value="Submit" /> <td><input type="submit" name="submit" value="Submit"/></td>
</td> </tr>
</tr> <tr>
<tr> <td></td>
<td></td> <td><input type="submit" name="submit_and_redirect" value="Submit & Create Edition"/></td>
<td> </tr>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Edition" />
</td>
</tr>
</table> </table>
</form> </form>
{% endblock content %} {% endblock content %}
{% block scripts %} {% block scripts %}
{% if script_name %} {% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script> <script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %} {% endif %}
{% endblock scripts %} {% endblock scripts %}

View File

@ -1,32 +1,29 @@
{% extends "base.html" %} {% extends "base.html" %}
{% load static %} {% load static %}
{% block title %}
{{ title }} {% block title %}{{ title }}{% endblock title %}
{% endblock title %}
{% block content %} {% block content %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<table class="mx-auto"> <table class="mx-auto">
{% csrf_token %} {% csrf_token %}
{{ form.as_table }}
<tr> {{ form.as_table }}
<td></td> <tr>
<td> <td></td>
<input type="submit" name="submit" value="Submit" /> <td><input type="submit" name="submit" value="Submit"/></td>
</td> </tr>
</tr> <tr>
<tr> <td></td>
<td></td> <td><input type="submit" name="submit_and_redirect" value="Submit & Create Session"/></td>
<td> </tr>
<input type="submit"
name="submit_and_redirect"
value="Submit & Create Session" />
</td>
</tr>
</table> </table>
</form> </form>
{% endblock content %} {% endblock content %}
{% block scripts %} {% block scripts %}
{% if script_name %} {% if script_name %}
<script type="module" src="{% static 'js/'|add:script_name %}"></script> <script type="module" src="{% static 'js/'|add:script_name %}"></script>
{% endif %} {% endif %}
{% endblock scripts %} {% endblock scripts %}

View File

@ -1,38 +1,35 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}
{{ title }} {% block title %}{{ title }}{% endblock title %}
{% endblock title %}
{% block content %} {% block content %}
<form method="post" enctype="multipart/form-data"> <form method="post" enctype="multipart/form-data">
<table class="mx-auto"> <table class="mx-auto">
{% csrf_token %} {% csrf_token %}
{% for field in form %}
<tr> {% for field in form %}
<th>{{ field.label_tag }}</th> <tr>
{% if field.name == "note" %} <th>{{ field.label_tag }}</th>
<td>{{ field }}</td> {% if field.name == "note" %}
{% else %} <td>{{ field }}</td>
<td>{{ field }}</td> {% else %}
{% endif %} <td>{{ field }}</td>
{% if field.name == "timestamp_start" or field.name == "timestamp_end" %} {% endif %}
<td> {% if field.name == "timestamp_start" or field.name == "timestamp_end" %}
<div class="basic-button-container"> <td>
<button class="basic-button" data-target="{{ field.name }}" data-type="now">Set to now</button> <div class="basic-button-container">
<button class="basic-button" <button class="basic-button" data-target="{{field.name}}" data-type="now">Set to now</button>
data-target="{{ field.name }}" <button class="basic-button" data-target="{{field.name}}" data-type="toggle">Toggle text</button>
data-type="toggle">Toggle text</button> <button class="basic-button" data-target="{{field.name}}" data-type="copy">Copy</button>
<button class="basic-button" data-target="{{ field.name }}" data-type="copy">Copy</button> </div>
</div> </td>
</td> {% endif %}
{% endif %} </tr>
</tr> {% endfor %}
{% endfor %} <tr>
<tr> <td></td>
<td></td> <td><input type="submit" value="Submit"/></td>
<td> </tr>
<input type="submit" value="Submit" />
</td>
</tr>
</table> </table>
</form> </form>
{% load static %} {% load static %}

View File

@ -1,101 +1,72 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
{% load static %} {% load static %}
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8"/>
<meta name="description" content="Self-hosted time-tracker." /> <meta name="description" content="Self-hosted time-tracker."/>
<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 - <title>Timetracker - {% block title %}Untitled{% endblock title %}</title>
{% block title %}
Untitled
{% endblock title %}
</title>
<script src="{% static 'js/htmx.min.js' %}"></script> <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" hx-indicator="#indicator" hx-boost="true">
<img id="indicator" <body class="dark">
src="{% static 'icons/loading.png' %}" <img id="indicator" src="{% static 'icons/loading.png' %}" class="absolute right-3 top-3 animate-spin htmx-indicator" />
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 'list_sessions_recent' %}" class="flex items-center"> <a href="{% url 'list_sessions_recent' %}" class="flex items-center">
<span class="text-4xl"> <span class="text-4xl"><img src="{% static 'icons/schedule.png' %}" width="48" class="mr-4" /></span>
<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 class="flex flex-col md:flex-row p-4 mt-4 dark:text-white"> <ul
class="flex flex-col md:flex-row p-4 mt-4 dark:text-white">
<li class="relative group"> <li class="relative group">
<a class="block py-2 pl-3 pr-4 hover:underline" <a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'add_game' %}">New</a>
href="{% url 'add_game' %}">New</a>
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block w-auto whitespace-nowrap"> <ul class="absolute hidden text-gray-700 pt-1 group-hover:block w-auto whitespace-nowrap">
{% if purchase_available %} {% if purchase_available %}
<li> <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>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_device' %}">Device</a>
</li>
{% endif %} {% endif %}
<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>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap"
href="{% url 'add_game' %}">Game</a>
</li>
{% if game_available and platform_available %} {% if game_available and platform_available %}
<li> <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>
<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 %} {% endif %}
<li> <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>
<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 %} {% if edition_available %}
<li> <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>
<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 %} {% endif %}
{% if purchase_available %} {% if purchase_available %}
<li> <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>
<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 %} {% endif %}
</ul> </ul>
</li> </li>
{% if session_count > 0 %} {% if session_count > 0 %}
<li class="relative group"> <li class="relative group">
<a class="block py-2 pl-3 pr-4 hover:underline" <a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'stats_current_year' %}">Stats</a>
href="{% url 'stats_current_year' %}">Stats</a> <ul class="absolute hidden text-gray-700 pt-1 group-hover:block">
<ul class="absolute hidden text-gray-700 pt-1 group-hover:block"> {% for year in stats_dropdown_year_range %}
{% for year in stats_dropdown_year_range %} <li>
<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>
<a class="bg-gray-200 hover:bg-gray-400 py-2 px-4 block whitespace-no-wrap" </li>
href="{% url 'stats_by_year' year %}">{{ year }}</a> {% endfor %}
</li> </ul>
{% endfor %} </li>
</ul> <li><a class="block py-2 pl-3 pr-4 hover:underline" href="{% url 'list_sessions' %}">All Sessions</a></li>
</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>
</div> </div>
</div> </div>
</nav> </nav>
{% block content %} {% block content %}No content here.{% endblock content %}
No content here.
{% endblock content %}
</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 %} {% block scripts %}{% endblock scripts %}
{% endblock scripts %}
</body> </body>
</html> </html>

View File

@ -2,12 +2,25 @@
title title
text text
{% endcomment %} {% endcomment %}
<a href="{{ link }}" <a
title="{{ title }}" href="{{ link }}"
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"> title="{{ title }}"
{% 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"> 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"
<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" /> >
{% 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> </svg>
{% endcomment %} {% endcomment %}
{{ text }} {{ text }}
</a> </a>

View File

@ -2,17 +2,25 @@
title title
text text
{% endcomment %} {% endcomment %}
<button type="button" <button
title="{{ title }}" type="button"
autofocus title="{{ title }}"
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"> autofocus
<svg xmlns="http://www.w3.org/2000/svg" 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"
fill="none" >
viewBox="0 0 24 24" <svg
stroke-width="1.5" xmlns="http://www.w3.org/2000/svg"
stroke="currentColor" fill="none"
class="self-center w-6 h-6 inline"> viewBox="0 0 24 24"
<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" /> stroke-width="1.5"
</svg> stroke="currentColor"
{{ text }} 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> </button>

View File

@ -1,13 +1,21 @@
<a href="{{ edit_url }}"> <a href="{{ edit_url }}">
<button type="button" <button
title="Edit" type="button"
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"> title="Edit"
<svg xmlns="http://www.w3.org/2000/svg" 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"
viewBox="0 0 20 20" >
fill="currentColor" <svg
class="w-5 h-5"> xmlns="http://www.w3.org/2000/svg"
<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" /> viewBox="0 0 20 20"
<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" /> fill="currentColor"
</svg> class="w-5 h-5"
</button> >
<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> </a>

View File

@ -1,7 +1,7 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}
{{ title }} {% block title %}{{ title }}{% endblock title %}
{% endblock title %}
{% 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 %}

View File

@ -1,68 +1,77 @@
{% extends 'base.html' %} {% extends 'base.html' %}
{% load static %} {% load static %}
{% block title %}
{{ title }} {% block title %}{{ title }}{% endblock title %}
{% endblock title %}
{% block content %} {% block content %}
{% if dataset.count >= 1 %}
<div class="mx-auto text-center my-4"> {% if dataset.count >= 1 %}
<a id="last-session-start" <div class="mx-auto text-center my-4">
href="{% url 'start_session_same_as_last' last.id %}" <a
hx-get="{% url 'start_session_same_as_last' last.id %}" id="last-session-start"
hx-swap="afterbegin" href="{% url 'start_session_same_as_last' last.id %}"
hx-target=".responsive-table tbody" hx-get="{% url 'start_session_same_as_last' last.id %}"
hx-select=".responsive-table tbody tr:first-child" hx-indicator="#indicator"
onClick="document.querySelector('#last-session-start').classList.add('invisible')" hx-swap="afterbegin"
class="{% if last.timestamp_end == null %}invisible{% endif %}"> hx-target=".responsive-table tbody"
{% include 'components/button_start.html' with text=last.purchase title="Start session of last played game" only %} hx-select=".responsive-table tbody tr:first-child"
</a> onClick="document.querySelector('#last-session-start').classList.add('invisible')"
</div> class="{% if last.timestamp_end == null %}invisible{% endif %}"
{% endif %} >
{% if dataset.count != 0 %} {% include 'components/button_start.html' with text=last.purchase title="Start session of last played game" only %}
<table class="responsive-table"> </a>
<thead> </div>
<tr> {% endif %}
<th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
<th class="hidden sm:table-cell px-2 sm:px-4 md:px-6 md:py-2">Start</th> <table class="responsive-table">
<th class="hidden lg:table-cell px-2 sm:px-4 md:px-6 md:py-2">End</th> <thead>
<th class="px-2 sm:px-4 md:px-6 md:py-2">Duration</th> <tr>
</tr> <th class="px-2 sm:px-4 md:px-6 md:py-2">Name</th>
</thead> <th class="hidden sm:table-cell px-2 sm:px-4 md:px-6 md:py-2">Start</th>
<tbody> <th class="hidden lg:table-cell px-2 sm:px-4 md:px-6 md:py-2">End</th>
{% for data in dataset %} <th class="px-2 sm:px-4 md:px-6 md:py-2">Duration</th>
<tr> </tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char"> </thead>
<a class="underline decoration-slate-500 sm:decoration-2" <tbody>
href="{% url 'view_game' data.purchase.edition.game.id %}"> {% for data in dataset %}
{{ data.purchase.edition }} <tr>
</a> <td
</td> class="px-2 sm:px-4 md:px-6 md:py-2 purchase-name truncate max-w-20char md:max-w-40char"
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell"> >
{{ data.timestamp_start | date:"d/m/Y H:i" }} <a
</td> class="underline decoration-slate-500 sm:decoration-2"
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell"> href="{% url 'view_game' data.purchase.edition.game.id %}">
{% if data.unfinished %} {{ data.purchase.edition }}
<a href="{% url 'update_session' data.id %}" </a>
hx-get="{% url 'update_session' data.id %}" </td>
hx-swap="outerHTML" <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden sm:table-cell">
hx-target=".responsive-table tbody tr:first-child" {{ data.timestamp_start | date:"d/m/Y H:i" }}
hx-select=".responsive-table tbody tr:first-child" </td>
hx-indicator="#indicator" <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono hidden lg:table-cell">
onClick="document.querySelector('#last-session-start').classList.remove('invisible')"> {% if data.unfinished %}
<span class="text-yellow-300">Finish now?</span> <a
</a> href="{% url 'update_session' data.id %}"
{% elif data.duration_manual %} hx-get="{% url 'update_session' data.id %}"
-- hx-swap="outerHTML"
{% else %} hx-target=".responsive-table tbody tr:first-child"
{{ data.timestamp_end | date:"d/m/Y H:i" }} hx-select=".responsive-table tbody tr:first-child"
{% endif %} hx-indicator="#indicator"
</td> onClick="document.querySelector('#last-session-start').classList.remove('invisible')"
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ data.duration_formatted }}</td> >
</tr> <span class="text-yellow-300">Finish now?</span>
{% endfor %} </a>
</tbody> {% elif data.duration_manual %}
</table> --
{% else %} {% else %}
<div class="mx-auto text-center text-slate-300 text-xl">No sessions found.</div> {{ data.timestamp_end | date:"d/m/Y H:i" }}
{% endif %} {% endif %}
</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
{{ data.duration_formatted }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock content %} {% endblock content %}

View File

@ -1 +0,0 @@
{{ form.related_purchase }}

View File

@ -1,19 +1,17 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}
{{ title }} {% block title %}{{ title }}{% endblock title %}
{% endblock title %}
{% load static %} {% load static %}
{% block content %} {% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <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"> <div class="flex justify-center items-center">
<form method="get" class="text-center"> <form method="get" class="text-center">
<label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label> <label class="text-5xl text-center inline-block mb-10" for="yearSelect">Stats for:</label>
<select name="year" <select name="year" id="yearSelect" onchange="this.form.submit();" class="mx-2">
id="yearSelect"
onchange="this.form.submit();"
class="mx-2">
{% for year_item in stats_dropdown_year_range %} {% for year_item in stats_dropdown_year_range %}
<option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option> <option value="{{ year_item }}" {% if year == year_item %}selected{% endif %}>{{ year_item }}</option>
{% endfor %} {% endfor %}
</select> </select>
</form> </form>
@ -64,15 +62,11 @@
</tr> </tr>
<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">Refunded</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ all_purchased_refunded_this_year.count }} ({{ refunded_percent }}%)</td>
{{ all_purchased_refunded_this_year.count }} ({{ refunded_percent }}%)
</td>
</tr> </tr>
<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">Unfinished</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)</td>
{{ purchased_unfinished.count }} ({{ unfinished_purchases_percent }}%)
</td>
</tr> </tr>
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td> <td class="px-2 sm:px-4 md:px-6 md:py-2">Backlog Decrease</td>
@ -96,13 +90,14 @@
</thead> </thead>
<tbody> <tbody>
{% for game in top_10_games_by_playtime %} {% for game in top_10_games_by_playtime %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2" <a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'view_game' game.id %}">{{ game.name }}
href="{% url 'view_game' game.id %}">{{ game.name }}</a>
</td> </a>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td> </td>
</tr> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ game.formatted_playtime }}</td>
</tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@ -116,10 +111,10 @@
</thead> </thead>
<tbody> <tbody>
{% for item in total_playtime_per_platform %} {% for item in total_playtime_per_platform %}
<tr> <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.platform_name }}</td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.formatted_playtime }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ item.formatted_playtime }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
@ -133,13 +128,10 @@
</thead> </thead>
<tbody> <tbody>
{% for purchase in all_finished_this_year %} {% for purchase in all_finished_this_year %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <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>
<a class="underline decoration-slate-500 sm:decoration-2" <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a> </tr>
</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 %} {% endfor %}
</tbody> </tbody>
</table> </table>
@ -153,13 +145,10 @@
</thead> </thead>
<tbody> <tbody>
{% for purchase in this_year_finished_this_year %} {% for purchase in this_year_finished_this_year %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <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>
<a class="underline decoration-slate-500 sm:decoration-2" <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a> </tr>
</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 %} {% endfor %}
</tbody> </tbody>
</table> </table>
@ -173,13 +162,10 @@
</thead> </thead>
<tbody> <tbody>
{% for purchase in purchased_this_year_finished_this_year %} {% for purchase in purchased_this_year_finished_this_year %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <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>
<a class="underline decoration-slate-500 sm:decoration-2" <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_finished | date:"d/m/Y" }}</td>
href="{% url 'edit_purchase' purchase.id %}">{{ purchase.edition.name }}</a> </tr>
</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 %} {% endfor %}
</tbody> </tbody>
</table> </table>
@ -194,17 +180,18 @@
</thead> </thead>
<tbody> <tbody>
{% for purchase in all_purchased_this_year %} {% for purchase in all_purchased_this_year %}
<tr> <tr>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono"> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">
<a class="underline decoration-slate-500 sm:decoration-2" <a class="underline decoration-slate-500 sm:decoration-2" href="{% url 'edit_purchase' purchase.id %}">
href="{% url 'edit_purchase' purchase.id %}"> {{ purchase.edition.name }}
{{ purchase.edition.name }} {% if purchase.type != "game" %}
{% if purchase.type != "game" %}({{ purchase.name }}, {{ purchase.get_type_display }}){% endif %} ({{ purchase.name }}, {{ purchase.get_type_display }})
</a> {% endif %}
</td> </a>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td> </td>
<td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.date_purchased | date:"d/m/Y" }}</td> <td class="px-2 sm:px-4 md:px-6 md:py-2 font-mono">{{ purchase.price }}</td>
</tr> <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 %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@ -1,8 +1,9 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}
{{ title }} {% block title %}{{ title }}{% endblock title %}
{% endblock title %}
{% load static %} {% load static %}
{% block content %} {% block content %}
<div class="dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto"> <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"> <h1 class="text-4xl flex items-center">
@ -12,67 +13,88 @@
{% include 'components/edit_button.html' with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</h1> </h1>
<h2 class="text-lg my-2 ml-2"> <h2 class="text-lg my-2 ml-2">
{{ hours_sum }} <span class="dark:text-slate-500">total</span> {{ total_hours }} <span class="dark:text-slate-500">total</span>
{{ session_average }} <span class="dark:text-slate-500">avg</span> {{ session_average }} <span class="dark:text-slate-500">avg</span>
({{ playrange }}) ({{ playrange }}) </h2>
</h2>
<hr class="border-slate-500"> <hr class="border-slate-500">
<h1 class="text-3xl mt-4 mb-1"> <h1 class="text-3xl mt-4 mb-1">Editions <span class="dark:text-slate-500">({{ editions.count }})</span></h1>
Editions <span class="dark:text-slate-500">({{ edition_count }})</span> and Purchases <span class="dark:text-slate-500">({{ purchase_count }})</span>
</h1>
<ul> <ul>
{% for edition in editions %} {% for edition in editions %}
<li class="sm:pl-2 flex items-center"> <li class="sm:pl-2 flex items-center">
{{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }}) {{ edition.name }} ({{ edition.platform }}, {{ edition.year_released }})
{% if edition.wikidata %} {% if edition.wikidata %}
<span class="hidden sm:inline"> <span class="hidden sm:inline">
<a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}"> <a href="https://www.wikidata.org/wiki/{{ edition.wikidata }}">
<img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}" /> <img class="inline mx-2 w-6" src="{% static 'icons/wikidata.png' %}"/>
</a> </a>
</span> </span>
{% endif %} {% endif %}
{% url 'edit_edition' edition.id as edit_url %} {% url 'edit_edition' edition.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</li> </li>
<ul> {% endfor %}
{% for purchase in edition.game_purchases %} </ul>
<li class="sm:pl-6 flex items-center"> <h1 class="text-3xl mt-4 mb-1">Purchases <span class="dark:text-slate-500">({{ purchases.count }})</span></h1>
{{ purchase.get_ownership_type_display }}, {{ purchase.date_purchased | date:"Y" }} <ul>
{% if purchase.price != 0 %}({{ purchase.price }} {{ purchase.price_currency }}){% endif %} {% for purchase in purchases %}
{% url 'edit_purchase' purchase.id as edit_url %} <li class="sm:pl-2 flex items-center">
{% include 'components/edit_button.html' with edit_url=edit_url %} {{ purchase.platform }}
</li> ({{ 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 %}
{% if purchase.related_purchases %}
<li>
<ul> <ul>
{% for related_purchase in purchase.nongame_related_purchases %} {% for related_purchase in purchase.related_purchases %}
<li class="sm:pl-12 flex items-center"> <li class="sm:pl-6 flex items-center">
{{ related_purchase.name }} ({{ related_purchase.get_type_display }}, {{ purchase.platform }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency }}) {{ related_purchase.name}} ({{ related_purchase.get_type_display }}, {{ related_purchase.date_purchased | date:"Y" }}, {{ related_purchase.price }} {{ related_purchase.price_currency}})
{% url 'edit_purchase' related_purchase.id as edit_url %} {% url 'edit_purchase' related_purchase.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
{% endfor %} </li>
</ul> {% endif %}
</li>
{% endfor %} {% endfor %}
</ul> </ul>
<h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center"> <h1 class="text-3xl mt-4 mb-1 flex gap-2 items-center">
Sessions Sessions
<span class="dark:text-slate-500">({{ sessions.count }})</span> <span class="dark:text-slate-500">
({{ sessions.count }})
</span>
{% url 'start_game_session' game.id as add_session_link %} {% 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 %} {% include 'components/button.html' with title="Start new session" text="New" link=add_session_link %}
and Notes <span class="dark:text-slate-500">({{ sessions_with_notes_count }})</span>
</h1> </h1>
<ul> <ul>
{% for session in sessions %} {% for session in sessions %}
<li class="sm:pl-2 mt-4 mb-2 dark:text-slate-400 flex items-center"> <li class="sm:pl-2 flex items-center">
{{ session.timestamp_start | date:"d/m/Y H:m" }} {{ session.timestamp_start | date:"d/m/Y" }}
({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }}) ({{ session.device.get_type_display | default:"Unknown" }}, {{ session.duration_formatted }})
{% url 'edit_session' session.id as edit_url %} {% url 'edit_session' session.id as edit_url %}
{% include 'components/edit_button.html' with edit_url=edit_url %} {% include 'components/edit_button.html' with edit_url=edit_url %}
</li> </li>
<li class="sm:pl-4 italic">{{ session.note|linebreaks }}</li>
{% endfor %} {% endfor %}
</ul> </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> </div>
{% endblock content %} {% endblock content %}

View File

@ -44,11 +44,6 @@ urlpatterns = [
views.add_purchase, views.add_purchase,
name="add_purchase_for_edition", name="add_purchase_for_edition",
), ),
path(
"related-purchase-by-edition",
views.related_purchase_by_edition,
name="related_purchase_by_edition",
),
path("add-edition/", views.add_edition, name="add_edition"), path("add-edition/", views.add_edition, name="add_edition"),
path( path(
"add-edition-for-game/<int:game_id>", "add-edition-for-game/<int:game_id>",

View File

@ -1,31 +1,24 @@
from common.time import format_duration, now as now_with_tz
from common.utils import safe_division
from datetime import datetime, timedelta from datetime import datetime, timedelta
from typing import Any, Callable from django.conf import settings
from django.db.models import Sum, F, Count
from django.core.exceptions import ObjectDoesNotExist
from django.db.models import Count, F, Prefetch, Sum
from django.db.models.functions import TruncDate from django.db.models.functions import TruncDate
from django.http import ( from django.http import HttpRequest, HttpResponse, HttpResponseRedirect
HttpRequest,
HttpResponse,
HttpResponseBadRequest,
HttpResponseRedirect,
)
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from typing import Callable, Any
from zoneinfo import ZoneInfo
from common.time import format_duration
from common.utils import safe_division
from .forms import ( from .forms import (
DeviceForm,
EditionForm,
GameForm, GameForm,
PlatformForm, PlatformForm,
PurchaseForm, PurchaseForm,
SessionForm, SessionForm,
EditionForm,
DeviceForm,
) )
from .models import Edition, Game, Platform, Purchase, Session from .models import Game, Platform, Purchase, Session, Edition
def model_counts(request): def model_counts(request):
@ -39,15 +32,19 @@ def model_counts(request):
def stats_dropdown_year_range(request): def stats_dropdown_year_range(request):
result = {"stats_dropdown_year_range": range(timezone.now().year, 1999, -1)} result = {
"stats_dropdown_year_range": range(
datetime.now(ZoneInfo(settings.TIME_ZONE)).year, 1999, -1
)
}
return result return result
def add_session(request, purchase_id=None): def add_session(request, purchase_id=None):
context = {} context = {}
initial = {"timestamp_start": timezone.now()} initial = {"timestamp_start": now_with_tz()}
last = Session.objects.last() last = Session.objects.all().last()
if last != None: if last != None:
initial["purchase"] = last.purchase initial["purchase"] = last.purchase
@ -139,52 +136,44 @@ def edit_game(request, game_id=None):
def view_game(request, game_id=None): def view_game(request, game_id=None):
context = {}
game = Game.objects.get(id=game_id) game = Game.objects.get(id=game_id)
nongame_related_purchases_prefetch = Prefetch( context["title"] = "View Game"
"related_purchases", context["game"] = game
queryset=Purchase.objects.exclude(type=Purchase.GAME), context["editions"] = Edition.objects.filter(game_id=game_id)
to_attr="nongame_related_purchases", game_purchases = (
Purchase.objects.filter(edition__game_id=game_id)
.filter(type=Purchase.GAME)
.order_by("date_purchased")
) )
game_purchases_prefetch = Prefetch( for purchase in game_purchases:
"purchase_set", purchase.related_purchases = Purchase.objects.exclude(
queryset=Purchase.objects.filter(type=Purchase.GAME).prefetch_related( type=Purchase.GAME
nongame_related_purchases_prefetch ).filter(related_purchase=purchase.id)
),
to_attr="game_purchases", context["purchases"] = game_purchases
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")
) )
editions = ( context["session_average"] = round(
Edition.objects.filter(game=game) (context["total_hours"]) / int(context["sessions"].count()), 1
.prefetch_related(game_purchases_prefetch)
.order_by("year_released")
) )
# 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")
sessions = Session.objects.filter(purchase__edition__game=game) context["playrange"] = (
session_count = sessions.count()
playrange_start = sessions.earliest().timestamp_start.strftime("%b %Y")
playrange_end = sessions.latest().timestamp_start.strftime("%b %Y")
playrange = (
playrange_start playrange_start
if playrange_start == playrange_end if playrange_start == playrange_end
else f"{playrange_start}{playrange_end}" else f"{playrange_start}{playrange_end}"
) )
total_hours = float(format_duration(sessions.total_duration_unformatted(), "%2.1H"))
context = {
"edition_count": editions.count(),
"editions": editions,
"game": game,
"playrange": playrange,
"purchase_count": Purchase.objects.filter(edition__game=game).count(),
"session_average": round(total_hours / int(session_count), 1),
"session_count": session_count,
"sessions_with_notes_count": sessions.exclude(note="").count(),
"sessions": sessions.order_by("-timestamp_start"),
"title": f"Game Overview - {game.name}",
"hours_sum": total_hours,
}
context["sessions_with_notes"] = context["sessions"].exclude(note="")
request.session["return_path"] = request.path request.session["return_path"] = request.path
return render(request, "view_game.html", context) return render(request, "view_game.html", context)
@ -215,24 +204,17 @@ def edit_edition(request, edition_id=None):
return render(request, "add.html", context) return render(request, "add.html", context)
def related_purchase_by_edition(request):
edition_id = request.GET.get("edition")
if not edition_id:
return HttpResponseBadRequest("Invalid edition_id")
form = PurchaseForm()
form.fields["related_purchase"].queryset = Purchase.objects.filter(
edition_id=edition_id, type=Purchase.GAME
).order_by("edition__sort_name")
return render(request, "partials/related_purchase_field.html", {"form": form})
@use_custom_redirect @use_custom_redirect
def start_game_session(request, game_id: int): def start_game_session(request, game_id: int):
last_session = Session.objects.filter(purchase__edition__game_id=game_id).latest() last_session = (
Session.objects.filter(purchase__edition__game_id=game_id)
.order_by("-timestamp_start")
.first()
)
session = SessionForm( session = SessionForm(
{ {
"purchase": last_session.purchase.id, "purchase": last_session.purchase.id,
"timestamp_start": timezone.now(), "timestamp_start": now_with_tz(),
"device": last_session.device, "device": last_session.device,
} }
) )
@ -245,7 +227,7 @@ def start_session_same_as_last(request, last_session_id: int):
session = SessionForm( session = SessionForm(
{ {
"purchase": last_session.purchase.id, "purchase": last_session.purchase.id,
"timestamp_start": timezone.now(), "timestamp_start": now_with_tz(),
"device": last_session.device, "device": last_session.device,
} }
) )
@ -287,29 +269,27 @@ def list_sessions(
dataset = Session.objects.filter(purchase__ownership_type=ownership_type) dataset = Session.objects.filter(purchase__ownership_type=ownership_type)
context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type] context["ownership_type"] = dict(Purchase.OWNERSHIP_TYPES)[ownership_type]
elif filter == "recent": elif filter == "recent":
current_year = timezone.now().year current_year = datetime.now().year
first_day_of_year = timezone.make_aware(datetime(current_year, 1, 1)) first_day_of_year = datetime(current_year, 1, 1)
dataset = Session.objects.filter( dataset = Session.objects.filter(
timestamp_start__gte=first_day_of_year timestamp_start__gte=first_day_of_year
).order_by("-timestamp_start") ).order_by("-timestamp_start")
context["title"] = "This year" context["title"] = "This year"
else: else:
# by default, sort from newest to oldest # by default, sort from newest to oldest
dataset = Session.objects.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 == timedelta( if session.timestamp_end == None and session.duration_manual == timedelta(
seconds=0 seconds=0
): ):
session.timestamp_end = timezone.now() session.timestamp_end = datetime.now(ZoneInfo(settings.TIME_ZONE))
session.unfinished = True session.unfinished = True
context["total_duration"] = dataset.total_duration_formatted() context["total_duration"] = dataset.total_duration_formatted()
context["dataset"] = dataset context["dataset"] = dataset
try: # cannot use dataset[0] here because that might be only partial QuerySet
context["last"] = Session.objects.latest() context["last"] = Session.objects.all().order_by("timestamp_start").last()
except ObjectDoesNotExist:
context["last"] = None
return render(request, "list_sessions.html", context) return render(request, "list_sessions.html", context)
@ -319,7 +299,7 @@ def stats(request, year: int = 0):
if selected_year: if selected_year:
return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year])) return HttpResponseRedirect(reverse("stats_by_year", args=[selected_year]))
if year == 0: if year == 0:
year = timezone.now().year year = now_with_tz().year
this_year_sessions = Session.objects.filter(timestamp_start__year=year) this_year_sessions = Session.objects.filter(timestamp_start__year=year)
selected_currency = "CZK" selected_currency = "CZK"
unique_days = ( unique_days = (
@ -452,7 +432,7 @@ def stats(request, year: int = 0):
def add_purchase(request, edition_id=None): def add_purchase(request, edition_id=None):
context = {} context = {}
initial = {"date_purchased": timezone.now()} initial = {"date_purchased": now_with_tz()}
if request.method == "POST": if request.method == "POST":
form = PurchaseForm(request.POST or None, initial=initial) form = PurchaseForm(request.POST or None, initial=initial)

7
package.json Normal file
View File

@ -0,0 +1,7 @@
{
"devDependencies": {
"@tailwindcss/forms": "^0.5.6",
"@tailwindcss/typography": "^0.5.10",
"tailwindcss": "^3.3.3"
}
}

918
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -1,21 +1,17 @@
[tool.poetry] [tool.poetry]
name = "timetracker" name = "timetracker"
version = "1.5.1" version = "1.5.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"
readme = "README.md" readme = "README.md"
packages = [{include = "timetracker"}] packages = [{include = "timetracker"}]
[tool.poetry.group.main.dependencies] [tool.poetry.dependencies]
python = "^3.12" python = "^3.10"
django = "^4.2.0" 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"
django-rest-framework = "^0.1.0"
django-cors-headers = "^3.13.0"
[tool.poetry.group.dev.dependencies] [tool.poetry.group.dev.dependencies]
black = "^22.12.0" black = "^22.12.0"
@ -27,10 +23,6 @@ werkzeug = "^2.2.2"
djhtml = "^1.5.2" djhtml = "^1.5.2"
djlint = "^1.19.11" djlint = "^1.19.11"
isort = "^5.11.4" isort = "^5.11.4"
pre-commit = "^3.5.0"
[tool.isort]
profile = "black"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]

19
tailwind.config.js Normal file
View File

@ -0,0 +1,19 @@
const defaultTheme = require('tailwindcss/defaultTheme')
module.exports = {
darkMode: 'class',
content: ["./games/**/*.{html,js}"],
theme: {
extend: {
fontFamily: {
'sans': ['IBM Plex Sans', ...defaultTheme.fontFamily.sans],
'mono': ['IBM Plex Mono', ...defaultTheme.fontFamily.mono],
'serif': ['IBM Plex Serif', ...defaultTheme.fontFamily.serif],
}
},
},
plugins: [
require('@tailwindcss/typography'),
require('@tailwindcss/forms')
],
}

View File

@ -1,16 +1,15 @@
import os
from datetime import datetime
from zoneinfo import ZoneInfo
import django import django
import os
from django.test import TestCase from django.test import TestCase
from django.urls import reverse from django.urls import reverse
from datetime import datetime
from zoneinfo import ZoneInfo
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup() django.setup()
from django.conf import settings from django.conf import settings
from games.models import Edition, Game, Platform, Purchase, Session from games.models import Game, Edition, Purchase, Session, Platform
ZONEINFO = ZoneInfo(settings.TIME_ZONE) ZONEINFO = ZoneInfo(settings.TIME_ZONE)

View File

@ -1,16 +1,14 @@
import django
import os import os
from django.test import TestCase
from django.db import models
from datetime import datetime from datetime import datetime
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import django
from django.db import models
from django.test import TestCase
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings") os.environ.setdefault("DJANGO_SETTINGS_MODULE", "timetracker.settings")
django.setup() django.setup()
from django.conf import settings from django.conf import settings
from games.models import Game, Edition, Purchase, Session
from games.models import Edition, Game, Purchase, Session
ZONEINFO = ZoneInfo(settings.TIME_ZONE) ZONEINFO = ZoneInfo(settings.TIME_ZONE)

View File

@ -12,7 +12,6 @@ https://docs.djangoproject.com/en/4.1/ref/settings/
import os import os
from pathlib import Path from pathlib import Path
from corsheaders.defaults import default_headers, default_methods
# Build paths inside the project like this: BASE_DIR / 'subdir'. # Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent BASE_DIR = Path(__file__).resolve().parent.parent
@ -39,16 +38,13 @@ INSTALLED_APPS = [
"django.contrib.sessions", "django.contrib.sessions",
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"rest_framework",
"corsheaders",
] ]
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 = [
"corsheaders.middleware.CorsMiddleware",
"django.middleware.security.SecurityMiddleware", "django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware", "django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware", "django.middleware.common.CommonMiddleware",
@ -127,7 +123,7 @@ USE_TZ = True
# https://docs.djangoproject.com/en/4.1/howto/static-files/ # https://docs.djangoproject.com/en/4.1/howto/static-files/
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "static" if DEBUG else "/var/www/django/static" STATIC_ROOT = BASE_DIR / "static"
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field
@ -154,24 +150,3 @@ 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 = []
REST_FRAMEWORK = {
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly"
]
}
FRONTEND_ROOT = os.path.abspath(os.path.join(BASE_DIR, "..", "frontend", "dist"))
CORS_ORIGIN_ALLOW_ALL = True
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_HEADERS = list(default_headers) + [
"Accept-Language",
"Connection",
"Host",
"Origin",
"Referer",
"Sec-Fetch-Dest",
"Sec-Fetch-Mode",
"Sec-Fetch-Site",
]

View File

@ -17,65 +17,12 @@ from django.conf import settings
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from django.views.generic import RedirectView from django.views.generic import RedirectView
from rest_framework import routers, serializers, viewsets
from games.models import Game, Purchase, Platform, Session
class GameSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Game
fields = "__all__"
class PlatformSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Platform
fields = "__all__"
class PurchaseSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Purchase
fields = "__all__"
class SessionSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = Session
fields = "__all__"
class GameViewSet(viewsets.ModelViewSet):
queryset = Game.objects.all()
serializer_class = GameSerializer
class PlatformViewSet(viewsets.ModelViewSet):
queryset = Platform.objects.all()
serializer_class = PlatformSerializer
class PurchaseViewSet(viewsets.ModelViewSet):
queryset = Purchase.objects.all()
serializer_class = PurchaseSerializer
class SessionViewSet(viewsets.ModelViewSet):
queryset = Session.objects.all()
serializer_class = SessionSerializer
router = routers.DefaultRouter()
router.register(r"games", GameViewSet)
router.register(r"platforms", PlatformViewSet)
router.register(r"purchases", PurchaseViewSet)
router.register(r"sessions", SessionViewSet)
urlpatterns = [ urlpatterns = [
path("api/", include(router.urls)), path("", RedirectView.as_view(url="/tracker")),
path("api-auth/", include("rest_framework.urls", namespace="rest_framework")), 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))