From d19afdb024aa5f95bc227503d10c23185e4ce159 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Fri, 16 Jan 2026 13:13:11 +0100 Subject: [PATCH 1/5] Fill up --- games/views/playevent.py | 52 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/games/views/playevent.py b/games/views/playevent.py index 579fbae..fcdb330 100644 --- a/games/views/playevent.py +++ b/games/views/playevent.py @@ -1,4 +1,5 @@ import logging +from datetime import datetime from typing import Any, Callable, TypedDict from django.contrib.auth.decorators import login_required @@ -11,9 +12,9 @@ from django.template.loader import render_to_string from django.urls import reverse from common.components import A, Button, Icon -from common.time import dateformat, local_strftime +from common.time import dateformat, format_duration, local_strftime from games.forms import PlayEventForm -from games.models import Game, PlayEvent +from games.models import Game, PlayEvent, Session logger = logging.getLogger("games") @@ -83,6 +84,39 @@ def create_playevent_tabledata( } +def _get_formatted_playtime_for_game_sessions_in_range( + game: Game, + start_timestamp: datetime | None = None, + end_timestamp: datetime | None = None, +) -> str: + """ + Calculates and formats the total playtime for a game's sessions + between specified start and end timestamps. If timestamps are not provided, + it uses the earliest and latest session start times for the game. + Returns "0h 00m" if no sessions exist for the game or if the range is invalid. + """ + sessions_queryset = game.sessions.all() + + if not sessions_queryset.exists(): + return "0h 00m" + + actual_start_ts = ( + start_timestamp + if start_timestamp is not None + else sessions_queryset.earliest("timestamp_start").timestamp_start + ) + actual_end_ts = ( + end_timestamp + if end_timestamp is not None + else sessions_queryset.latest("timestamp_start").timestamp_start + ) + + sessions_in_range = sessions_queryset.filter( + timestamp_start__gte=actual_start_ts, timestamp_start__lte=actual_end_ts + ) + return format_duration(sessions_in_range.total_duration_unformatted(), "%Hh %mm") + + @login_required def list_playevents(request: HttpRequest) -> HttpResponse: page_number = request.GET.get("page", 1) @@ -115,8 +149,18 @@ def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse: # coming from add_playevent_for_game url path game = get_object_or_404(Game, id=game_id) initial["game"] = game - initial["started"] = game.sessions.earliest().timestamp_start - initial["ended"] = game.sessions.latest().timestamp_start + try: + started_ts = game.sessions.earliest("timestamp_start").timestamp_start + ended_ts = game.sessions.latest("timestamp_start").timestamp_start + initial["started"] = started_ts + initial["ended"] = ended_ts + initial["note"] = _get_formatted_playtime_for_game_sessions_in_range( + game, started_ts, ended_ts + ) + except Session.DoesNotExist: + initial["started"] = None + initial["ended"] = None + initial["note"] = "0h 00m" form = PlayEventForm(request.POST or None, initial=initial) if form.is_valid(): form.save() -- 2.52.0 From eb6b6bccefc7c8ef7c4300ab85e64549057b72a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Fri, 16 Jan 2026 13:46:15 +0100 Subject: [PATCH 2/5] Pre-calculate playevent time from last playevent --- games/views/playevent.py | 39 +++++++++++++++++++++++++++++++++------ 1 file changed, 33 insertions(+), 6 deletions(-) diff --git a/games/views/playevent.py b/games/views/playevent.py index fcdb330..b16f65b 100644 --- a/games/views/playevent.py +++ b/games/views/playevent.py @@ -1,5 +1,5 @@ import logging -from datetime import datetime +from datetime import datetime, timedelta from typing import Any, Callable, TypedDict from django.contrib.auth.decorators import login_required @@ -150,12 +150,39 @@ def add_playevent(request: HttpRequest, game_id: int = 0) -> HttpResponse: game = get_object_or_404(Game, id=game_id) initial["game"] = game try: - started_ts = game.sessions.earliest("timestamp_start").timestamp_start - ended_ts = game.sessions.latest("timestamp_start").timestamp_start - initial["started"] = started_ts - initial["ended"] = ended_ts + # First, try to get the latest session. If no sessions, then no playtime. + latest_session = game.sessions.latest("timestamp_start") + latest_session_ts = latest_session.timestamp_start + + # Now, determine the start date for the new playevent. + # This will be either the day after the last playevent ended, or the earliest session. + try: + latest_playevent = game.playevents.latest("ended") + # Start date for the new PlayEvent form + new_playevent_form_start_date = latest_playevent.ended + timedelta( + days=1 + ) + initial["started"] = new_playevent_form_start_date + + # Start timestamp for playtime calculation + playtime_calc_start_ts = datetime.combine( + new_playevent_form_start_date, datetime.min.time() + ) + + except PlayEvent.DoesNotExist: + # No previous playevents, so the new playevent starts from the earliest session. + earliest_session_ts = game.sessions.earliest( + "timestamp_start" + ).timestamp_start + initial["started"] = earliest_session_ts.date() + playtime_calc_start_ts = earliest_session_ts + + # The end date for the new PlayEvent form and playtime calculation is the latest session's start date. + initial["ended"] = latest_session_ts.date() + playtime_calc_end_ts = latest_session_ts + initial["note"] = _get_formatted_playtime_for_game_sessions_in_range( - game, started_ts, ended_ts + game, playtime_calc_start_ts, playtime_calc_end_ts ) except Session.DoesNotExist: initial["started"] = None -- 2.52.0 From 1ba204fbdc4021a9804b693a8900f02922eea9ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Fri, 16 Jan 2026 20:25:50 +0100 Subject: [PATCH 3/5] Improve light/dark theme toggle --- Dockerfile | 8 ++ Makefile | 20 ++++- common/input.css | 4 +- frontend/components/CrownIcon.svelte | 6 ++ frontend/main.js | 13 +++ games/static/base.css | 25 ++++-- games/templates/cotton/icon/arrowdown.html | 3 + games/templates/cotton/icon/play.html | 2 +- games/templates/cotton/layouts/base.html | 89 +++++++++++-------- games/templates/navbar.html | 16 +++- .../partials/gamestatus_selector.html | 8 +- games/templates/partials/history.html | 2 +- games/templates/view_game.html | 30 +++++-- vite.config.js | 29 ++++++ 14 files changed, 190 insertions(+), 65 deletions(-) create mode 100644 frontend/components/CrownIcon.svelte create mode 100644 frontend/main.js create mode 100644 games/templates/cotton/icon/arrowdown.html create mode 100644 vite.config.js diff --git a/Dockerfile b/Dockerfile index f727609..b3ad1cb 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,8 @@ RUN apt-get update && apt-get upgrade -y \ && apt-get install --no-install-recommends -y \ bash \ curl \ + nodejs \ + npm \ && curl -sSL 'https://install.python-poetry.org' | python - \ && poetry --version \ && apt-get purge -y --auto-remove -o APT::AutoRemove::RecommendsImportant=false \ @@ -33,6 +35,12 @@ RUN chown -R timetracker:timetracker /home/timetracker/app COPY entrypoint.sh / RUN chmod +x /entrypoint.sh +USER timetracker + +# Install Node.js dependencies and build Svelte app +RUN npm install +RUN npm run build + RUN --mount=type=cache,target="$POETRY_CACHE_DIR" \ echo "$PROD" \ && poetry version \ diff --git a/Makefile b/Makefile index 069514e..bfb691d 100644 --- a/Makefile +++ b/Makefile @@ -4,6 +4,7 @@ initialize: npm css migrate sethookdir loadplatforms HTMLFILES := $(shell find games/templates -type f) PYTHON_VERSION = 3.12 +.PHONY: build frontend-dev frontend-build npm: npm install @@ -24,13 +25,26 @@ init: poetry install npm install +# Run Django, Tailwind, and Vite development servers concurrently dev: @npx concurrently \ - --names "Django,Tailwind" \ - --prefix-colors "blue,green" \ + --names "Django,Tailwind,Vite" \ + --prefix-colors "blue,green,yellow" \ "poetry run python -Wa manage.py runserver" \ - "npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch" + "npx tailwindcss -i ./common/input.css -o ./games/static/base.css --watch" \ + "npm run dev" +# Only start the Vite development server +frontend-dev: + npm run dev + +# Build frontend assets for production +build: frontend-build collectstatic + +# Only build frontend assets +frontend-build: + @echo "Building frontend assets with Vite..." + npm run build caddy: caddy run --watch diff --git a/common/input.css b/common/input.css index 9499c59..78ab8e1 100644 --- a/common/input.css +++ b/common/input.css @@ -53,11 +53,11 @@ } .responsive-table tr:nth-child(even) { - @apply bg-slate-800 + @apply bg-indigo-100 dark:bg-slate-800 } .responsive-table tbody tr:nth-child(odd) { - @apply bg-slate-900 + @apply bg-indigo-200 dark:bg-slate-900 } .responsive-table thead th { diff --git a/frontend/components/CrownIcon.svelte b/frontend/components/CrownIcon.svelte new file mode 100644 index 0000000..5ddb6ab --- /dev/null +++ b/frontend/components/CrownIcon.svelte @@ -0,0 +1,6 @@ + +{#if mastered} + 👑 +{/if} diff --git a/frontend/main.js b/frontend/main.js new file mode 100644 index 0000000..60282b1 --- /dev/null +++ b/frontend/main.js @@ -0,0 +1,13 @@ +import CrownIcon from './components/CrownIcon.svelte'; + +// Expose a function to mount the CrownIcon component globally +// This allows Django templates to easily initialize Svelte components. +window.mountCrownIcon = (selector, props) => { + const target = document.querySelector(selector); + if (target) { + new CrownIcon({ + target: target, + props: props, + }); + } +}; diff --git a/games/static/base.css b/games/static/base.css index bdb24db..1724da8 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -2268,6 +2268,11 @@ input:checked + .toggle-bg { color: rgb(107 114 128 / var(--tw-text-opacity, 1)); } +.text-gray-600 { + --tw-text-opacity: 1; + color: rgb(75 85 99 / var(--tw-text-opacity, 1)); +} + .text-gray-700 { --tw-text-opacity: 1; color: rgb(55 65 81 / var(--tw-text-opacity, 1)); @@ -2288,11 +2293,6 @@ input:checked + .toggle-bg { color: rgb(203 213 225 / var(--tw-text-opacity, 1)); } -.text-slate-400 { - --tw-text-opacity: 1; - color: rgb(148 163 184 / var(--tw-text-opacity, 1)); -} - .text-slate-500 { --tw-text-opacity: 1; color: rgb(100 116 139 / var(--tw-text-opacity, 1)); @@ -2491,11 +2491,21 @@ input:checked + .toggle-bg { } .responsive-table tr:nth-child(even) { + --tw-bg-opacity: 1; + background-color: rgb(229 237 255 / var(--tw-bg-opacity, 1)); +} + +.responsive-table tr:nth-child(even):is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(30 41 59 / var(--tw-bg-opacity, 1)); } .responsive-table tbody tr:nth-child(odd) { + --tw-bg-opacity: 1; + background-color: rgb(205 219 254 / var(--tw-bg-opacity, 1)); +} + +.responsive-table tbody tr:nth-child(odd):is(.dark *) { --tw-bg-opacity: 1; background-color: rgb(15 23 42 / var(--tw-bg-opacity, 1)); } @@ -3081,6 +3091,11 @@ div [type="submit"] { color: rgb(75 85 99 / var(--tw-text-opacity, 1)); } +.dark\:text-slate-300:is(.dark *) { + --tw-text-opacity: 1; + color: rgb(203 213 225 / var(--tw-text-opacity, 1)); +} + .dark\:text-slate-400:is(.dark *) { --tw-text-opacity: 1; color: rgb(148 163 184 / var(--tw-text-opacity, 1)); diff --git a/games/templates/cotton/icon/arrowdown.html b/games/templates/cotton/icon/arrowdown.html new file mode 100644 index 0000000..26ede59 --- /dev/null +++ b/games/templates/cotton/icon/arrowdown.html @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/games/templates/cotton/icon/play.html b/games/templates/cotton/icon/play.html index 7f4bdc9..e51f307 100644 --- a/games/templates/cotton/icon/play.html +++ b/games/templates/cotton/icon/play.html @@ -2,7 +2,7 @@ x="0px" y="0px" viewBox="0 0 48 48" - class="text-black dark:text-white w-4 h-4"> + class="w-4 h-4"> diff --git a/games/templates/cotton/layouts/base.html b/games/templates/cotton/layouts/base.html index b438e6a..d39b35d 100644 --- a/games/templates/cotton/layouts/base.html +++ b/games/templates/cotton/layouts/base.html @@ -39,46 +39,59 @@ {% version %} ({% version_date %}) {{ scripts }} - diff --git a/games/templates/navbar.html b/games/templates/navbar.html index 552e3b0..128df2e 100644 --- a/games/templates/navbar.html +++ b/games/templates/navbar.html @@ -26,9 +26,19 @@