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",