From 3b9c05d674611ba5ebfa8c2a86c3be1c2f1ad17c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 7 Jun 2026 10:48:23 +0200 Subject: [PATCH] Improve year picker on stats page --- common/components/__init__.py | 4 ++ common/components/primitives.py | 86 +++++++++++++++++++++++++++++++++ games/static/base.css | 63 +++++++++++++++++++++--- games/views/general.py | 14 +++++- games/views/stats_content.py | 69 +++++++++++++------------- tests/test_rendered_pages.py | 3 +- 6 files changed, 196 insertions(+), 43 deletions(-) diff --git a/common/components/__init__.py b/common/components/__init__.py index a542f7d..d23f226 100644 --- a/common/components/__init__.py +++ b/common/components/__init__.py @@ -20,6 +20,7 @@ from common.components.primitives import ( ButtonGroup, CsrfInput, Div, + ExternalScript, H1, Icon, Input, @@ -35,6 +36,7 @@ from common.components.primitives import ( TableHeader, TableRow, TableTd, + YearPicker, paginated_table_content, ) from common.components.search_select import ( @@ -73,6 +75,7 @@ __all__ = [ "ButtonGroup", "CsrfInput", "Div", + "ExternalScript", "H1", "Icon", "Input", @@ -91,6 +94,7 @@ __all__ = [ "TableHeader", "TableRow", "TableTd", + "YearPicker", "paginated_table_content", "GameLink", "GameStatus", diff --git a/common/components/primitives.py b/common/components/primitives.py index c6efbc8..0625f74 100644 --- a/common/components/primitives.py +++ b/common/components/primitives.py @@ -433,6 +433,92 @@ def ModuleScript(filename: str) -> SafeText: ) +def ExternalScript(url: str) -> SafeText: + """A plain `') + + +def YearPicker( + year: int | None = None, + available_years: tuple[int, ...] = (), + url_template: str = "", +) -> SafeText: + """A Flowbite-datepicker year picker. + + `year` is the selected year, or ``None`` for the all-time view (the empty + state). `available_years` are the years to enable in the popup grid. + `url_template` is a navigation URL containing the literal ``__year__`` + placeholder, substituted with the chosen year in JS (keeps this component + decoupled from the project's URL names). + + The Flowbite-datepicker UMD bundle is *not* loaded here — the view hoists it + via ``render_page(scripts=...)``. + """ + label = str(year) if year is not None else "Choose a year" + selected = str(year) if year is not None else "" + classes = ( + "bg-brand text-white border-transparent hover:bg-brand-strong" + if year is not None + else "bg-neutral-secondary-medium text-heading border border-default-medium " + "hover:bg-neutral-tertiary-medium focus:ring-4 focus:ring-brand-medium" + ) + years_csv = ",".join(str(y) for y in available_years) + return mark_safe(f"""
+ + +
+""") + + def AddForm( form, *, diff --git a/games/static/base.css b/games/static/base.css index f5b7945..e13a3cb 100644 --- a/games/static/base.css +++ b/games/static/base.css @@ -898,9 +898,6 @@ max-width: 96rem; } } - .mx-2 { - margin-inline: calc(var(--spacing) * 2); - } .mx-auto { margin-inline: auto; } @@ -940,6 +937,9 @@ .mt-5 { margin-top: calc(var(--spacing) * 5); } + .mt-6 { + margin-top: calc(var(--spacing) * 6); + } .apexcharts-canvas { & .apexcharts-tooltip { background-color: primary !important; @@ -1199,6 +1199,9 @@ min-width: 4rem; } } + .mr-3 { + margin-right: calc(var(--spacing) * 3); + } .mr-4 { margin-right: calc(var(--spacing) * 4); } @@ -1245,6 +1248,9 @@ .mb-10 { margin-bottom: calc(var(--spacing) * 10); } + .mb-12 { + margin-bottom: calc(var(--spacing) * 12); + } .apexcharts-xaxistooltip { .apexcharts-canvas & { color: var(--color-body) !important; @@ -1577,6 +1583,9 @@ .w-72 { width: calc(var(--spacing) * 72); } + .w-\[300px\] { + width: 300px; + } .w-auto { width: auto; } @@ -1595,6 +1604,9 @@ .max-w-120 { max-width: calc(var(--spacing) * 120); } + .max-w-lg { + max-width: var(--container-lg); + } .max-w-md { max-width: var(--container-md); } @@ -2134,6 +2146,12 @@ .bg-neutral-primary-soft { background-color: var(--color-neutral-primary-soft); } + .bg-neutral-primary-soft\/30 { + background-color: color-mix(in srgb, #fff 30%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-neutral-primary-soft) 30%, transparent); + } + } .bg-neutral-quaternary { background-color: var(--color-neutral-quaternary); } @@ -2161,6 +2179,9 @@ .bg-red-500 { background-color: var(--color-red-500); } + .bg-red-600 { + background-color: var(--color-red-600); + } .bg-red-700 { background-color: var(--color-red-700); } @@ -2346,6 +2367,9 @@ .pb-16 { padding-bottom: calc(var(--spacing) * 16); } + .pl-3 { + padding-left: calc(var(--spacing) * 3); + } .datatable-empty { .datatable-wrapper .datatable-table & { text-align: center; @@ -2384,10 +2408,6 @@ font-size: var(--text-4xl); line-height: var(--tw-leading, var(--text-4xl--line-height)); } - .text-5xl { - font-size: var(--text-5xl); - line-height: var(--tw-leading, var(--text-5xl--line-height)); - } .text-base { font-size: var(--text-base); line-height: var(--tw-leading, var(--text-base--line-height)); @@ -2614,6 +2634,9 @@ .text-red-800 { color: var(--color-red-800); } + .text-slate-200 { + color: var(--color-slate-200); + } .text-slate-300 { color: var(--color-slate-300); } @@ -2862,6 +2885,17 @@ background-color: var(--color-gray-50); } } + .first-of-type\:border-t-0 { + &:first-of-type { + border-top-style: var(--tw-border-style); + border-top-width: 0px; + } + } + .first-of-type\:pt-0 { + &:first-of-type { + padding-top: calc(var(--spacing) * 0); + } + } .hover\:scale-110 { &:hover { @media (hover: hover) { @@ -3209,6 +3243,11 @@ border-bottom-left-radius: var(--radius-lg); } } + .sm\:p-5 { + @media (width >= 40rem) { + padding: calc(var(--spacing) * 5); + } + } .sm\:px-4 { @media (width >= 40rem) { padding-inline: calc(var(--spacing) * 4); @@ -3926,6 +3965,16 @@ border-end-end-radius: var(--radius-lg); } } + .\[\&\>div\]\:w-\[calc\(33\.333\%-0\.67rem\)\] { + &>div { + width: calc(33.333% - 0.67rem); + } + } + .\[\&\>div\]\:w-\[calc\(50\%-0\.5rem\)\] { + &>div { + width: calc(50% - 0.5rem); + } + } } .dark { --color-body: var(--color-gray-400); diff --git a/games/views/general.py b/games/views/general.py index bb592ca..fa02e71 100644 --- a/games/views/general.py +++ b/games/views/general.py @@ -13,12 +13,18 @@ from django.urls import reverse from django.utils.timezone import localtime from django.utils.timezone import now as timezone_now +from common.components import ExternalScript from common.layout import render_page from common.time import format_duration from games.models import Game, Platform, Purchase, Session from games.views.stats_content import stats_content from games.views.stats_data import compute_stats +# Flowbite-datepicker UMD bundle, hoisted into the stats pages for YearPicker. +_STATS_SCRIPTS = ExternalScript( + "https://cdn.jsdelivr.net/npm/flowbite-datepicker@2.0.0/dist/Datepicker.umd.min.js" +) + def model_counts(request: HttpRequest) -> dict[str, bool]: now = timezone_now() @@ -71,7 +77,9 @@ def use_custom_redirect( def stats_alltime(request: HttpRequest) -> HttpResponse: request.session["return_path"] = request.path data = compute_stats(None) - return render_page(request, stats_content(data), title=data["title"]) + return render_page( + request, stats_content(data), title=data["title"], scripts=_STATS_SCRIPTS + ) @login_required @@ -85,7 +93,9 @@ def stats(request: HttpRequest, year: int = 0) -> HttpResponse: return HttpResponseRedirect(reverse("games:stats_alltime")) request.session["return_path"] = request.path data = compute_stats(year) - return render_page(request, stats_content(data), title=data["title"]) + return render_page( + request, stats_content(data), title=data["title"], scripts=_STATS_SCRIPTS + ) @login_required diff --git a/games/views/stats_content.py b/games/views/stats_content.py index 6b8729b..8d6063a 100644 --- a/games/views/stats_content.py +++ b/games/views/stats_content.py @@ -7,10 +7,11 @@ like the old `{% if key %}` blocks: a missing or empty value hides the section. from django.template.defaultfilters import date as date_filter from django.template.defaultfilters import floatformat +from django.urls import reverse from django.utils.html import conditional_escape from django.utils.safestring import SafeText, mark_safe -from common.components import Component, Div, GameLink +from common.components import A, Component, Div, GameLink, YearPicker from common.time import durationformat, format_duration _CELL = "px-2 sm:px-4 md:px-6 md:py-2" @@ -41,7 +42,7 @@ def _kv(label, value) -> SafeText: def _h1(title: str) -> SafeText: return Component( tag_name="h1", - attributes=[("class", "text-5xl text-center my-6")], + attributes=[("class", "text-3xl text-heading text-center my-6")], children=[title], ) @@ -75,39 +76,35 @@ def _purchase_name(purchase) -> SafeText: return GameLink(first_game.id, name) -def _year_dropdown(year, year_range) -> SafeText: - options = [] - for year_item in year_range or []: - attrs = [("value", str(year_item))] - if year == year_item: - attrs.append(("selected", True)) - options.append( - Component(tag_name="option", attributes=attrs, children=[str(year_item)]) - ) - select = Component( - tag_name="select", - attributes=[ - ("name", "year"), - ("id", "yearSelect"), - ("onchange", "this.form.submit();"), - ("class", "mx-2"), - ], - children=options, +def _year_nav(year, year_range, url_template) -> SafeText: + # `year` is an int for a specific year, or "Alltime" (from compute_stats) + # for the all-time view. Normalize to int-or-None so nothing downstream has + # to know about the "Alltime" sentinel. + year_int = year if isinstance(year, int) else None + is_alltime = year_int is None + + alltime_classes = ( + "inline-flex items-center rounded-base px-4 py-2 mr-3 text-sm font-medium " ) - label = Component( - tag_name="label", - attributes=[ - ("class", "text-5xl text-center inline-block mb-10"), - ("for", "yearSelect"), - ], - children=["Stats for:"], + alltime_classes += ( + "bg-brand text-white hover:bg-brand-strong" + if is_alltime + else "text-body hover:text-heading underline decoration-dotted" ) - form = Component( - tag_name="form", - attributes=[("method", "get"), ("class", "text-center")], - children=[label, select], + alltime_btn = A( + url_name="games:stats_alltime", + attributes=[("class", alltime_classes)], + children=["All-time stats"], + ) + picker = YearPicker( + year=year_int, + available_years=tuple(year_range or []), + url_template=url_template, + ) + return Div( + [("class", "flex justify-center items-center mb-12")], + [alltime_btn, picker], ) - return Div([("class", "flex justify-center items-center")], [form]) def _playtime_table(ctx) -> SafeText: @@ -260,8 +257,14 @@ def _priced_table(purchases, currency) -> SafeText: def stats_content(ctx: dict) -> SafeText: year = ctx.get("year") currency = ctx.get("total_spent_currency") + # Build a navigation URL with an `__year__` placeholder the picker's JS + # substitutes. Reverse a sentinel year, then swap it for the placeholder + # (anchored on `stats/0` so the match is unambiguous). + url_template = reverse("games:stats_by_year", args=[0]).replace( + "stats/0", "stats/__year__" + ) sections: list = [ - _year_dropdown(year, ctx.get("stats_dropdown_year_range")), + _year_nav(year, ctx.get("stats_dropdown_year_range"), url_template), _h1("Playtime"), _playtime_table(ctx), ] diff --git a/tests/test_rendered_pages.py b/tests/test_rendered_pages.py index 9c3de7b..5c1aca6 100644 --- a/tests/test_rendered_pages.py +++ b/tests/test_rendered_pages.py @@ -253,7 +253,8 @@ class RenderedPagesTest(TestCase): def test_stats_alltime(self): html = self.get("games:stats_alltime").content.decode() for marker in [ - 'id="yearSelect"', + 'id="year-picker-input"', + "All-time stats", "responsive-table", "Playtime", "Purchases",