feat(stats): link stats rows and counts to filtered lists (#65)

Wire the stats page to filter_url()/stats_links:
- Per-row session links on game superlatives, games-by-playtime, platform
  and month rows (game rows keep their detail GameLink, add a session icon).
- Count links: sessions, games, total/refunded/dropped/unfinished/backlog
  purchases.
- Cap preview lists to 5 with a 'View all (N)' link passing ?sort= for order
  parity; remove the redundant 'All Purchases' list.
- stats_data: carry platform_id for platform links; drop the all-time
  games-by-playtime [:10] slice so the view-all count is honest (rendering
  caps the preview).

Also make the filter bar's _extract_labeled tolerate bare choice/multi values
so a programmatically-built filter URL renders instead of crashing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3
This commit is contained in:
2026-06-21 15:26:33 +02:00
parent 57980c407f
commit 90c6113772
5 changed files with 323 additions and 34 deletions
+168 -27
View File
@@ -14,7 +14,9 @@ from common.components import (
A,
Div,
Element,
Fragment,
GameLink,
Icon,
Node,
Safe,
Td,
@@ -23,11 +25,46 @@ from common.components import (
YearPicker,
)
from common.time import durationformat, format_duration
from games.filters import filter_url
from games.views import stats_links
_CELL = "px-2 sm:px-4 md:px-6 md:py-2"
_CELL_MONO = f"{_CELL} font-mono"
_NAME_TH = f"{_CELL} purchase-name truncate max-w-20char"
# Stats lists are previews: capped to this many rows, with a "View all" link to
# the full filtered list (#65).
_LIST_CAP = 5
def _session_link(game_id, year) -> Node:
"""Small affordance linking a game row to its (year-scoped) session list.
Sits next to the existing GameLink (which goes to the game detail page)."""
return A(
href=filter_url(stats_links.sessions_for_game(game_id, year)),
class_="ml-1 inline-block align-middle hover:text-heading",
title="View sessions",
)[Icon("play")]
def _count_link(value, url: str) -> Node:
return A(href=url, class_="hover:underline decoration-dotted")[str(value)]
def _view_all_row(count: int, url: str) -> Node:
return _tr(
[
Td(
attributes=[("class", _CELL), ("colspan", "3")],
children=[
A(href=url, class_="underline decoration-dotted")[
f"View all ({count}) →"
]
],
)
]
)
def _td(children, cls: str = _CELL_MONO) -> Node:
if not isinstance(children, list):
@@ -119,14 +156,28 @@ def _playtime_table(ctx) -> Node:
year = ctx.get("year")
rows = [
_kv("Hours", ctx.get("total_hours")),
_kv("Sessions", ctx.get("total_sessions")),
_kv(
"Sessions",
_count_link(
ctx.get("total_sessions"),
filter_url(stats_links.all_sessions(year)),
),
),
_kv(
"Days",
f"{ctx.get('unique_days')} ({ctx.get('unique_days_percent')}%)",
),
]
if ctx.get("total_games"):
rows.append(_kv("Games", ctx.get("total_games")))
rows.append(
_kv(
"Games",
_count_link(
ctx.get("total_games"),
filter_url(stats_links.games_played(year)),
),
)
)
rows.append(_kv(f"Games ({year})", ctx.get("total_year_games")))
if ctx.get("all_finished_this_year_count"):
rows.append(_kv("Finished", ctx.get("all_finished_this_year_count")))
@@ -138,7 +189,15 @@ def _playtime_table(ctx) -> Node:
return _tr(
[
_td(label, _CELL),
_td([str(value), " (", GameLink(game.id, game.name), ")"]),
_td(
[
str(value),
" (",
GameLink(game.id, game.name),
")",
_session_link(game.id, year),
]
),
]
)
@@ -171,6 +230,7 @@ def _playtime_table(ctx) -> Node:
[
GameLink(first_game.id, first_game.name),
f" ({ctx.get('first_play_date')})",
_session_link(first_game.id, year),
]
),
]
@@ -186,6 +246,7 @@ def _playtime_table(ctx) -> Node:
[
GameLink(last_game.id, last_game.name),
f" ({ctx.get('last_play_date')})",
_session_link(last_game.id, year),
]
),
]
@@ -195,23 +256,45 @@ def _playtime_table(ctx) -> Node:
def _purchases_table(ctx) -> Node:
year = ctx.get("year")
rows = [
_kv("Total", ctx.get("all_purchased_this_year_count")),
_kv(
"Total",
_count_link(
ctx.get("all_purchased_this_year_count"),
filter_url(stats_links.purchases_total(year)),
),
),
_kv(
"Refunded",
f"{ctx.get('all_purchased_refunded_this_year_count')} "
f"({ctx.get('refunded_percent')}%)",
_count_link(
f"{ctx.get('all_purchased_refunded_this_year_count')} "
f"({ctx.get('refunded_percent')}%)",
filter_url(stats_links.purchases_refunded(year)),
),
),
_kv(
"Dropped",
f"{ctx.get('dropped_count')} ({ctx.get('dropped_percentage')}%)",
_count_link(
f"{ctx.get('dropped_count')} ({ctx.get('dropped_percentage')}%)",
filter_url(stats_links.purchases_dropped(year)),
),
),
_kv(
"Unfinished",
f"{ctx.get('purchased_unfinished_count')} "
f"({ctx.get('unfinished_purchases_percent')}%)",
_count_link(
f"{ctx.get('purchased_unfinished_count')} "
f"({ctx.get('unfinished_purchases_percent')}%)",
filter_url(stats_links.purchases_unfinished(year)),
),
),
_kv(
"Backlog Decrease",
_count_link(
ctx.get("backlog_decrease_count"),
filter_url(stats_links.purchases_backlog_decrease(year)),
),
),
_kv("Backlog Decrease", ctx.get("backlog_decrease_count")),
_kv(
f"Spendings ({ctx.get('total_spent_currency')})",
f"{floatformat(ctx.get('total_spent'))} "
@@ -221,34 +304,45 @@ def _purchases_table(ctx) -> Node:
return _table(rows)
def _two_col_table(header: str, items, name_key, value_fn) -> Node:
def _two_col_table(header: str, items, name_key, value_fn, view_all_url=None) -> Node:
thead = Element(
"thead",
children=[_tr([_th(header), _th("Playtime")])],
)
rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in items]
items = list(items)
display = items[:_LIST_CAP] if view_all_url else items
rows = [_tr([_td(name_key(item)), _td(value_fn(item))]) for item in display]
if view_all_url and len(items) > _LIST_CAP:
rows.append(_view_all_row(len(items), view_all_url))
return _table(rows, thead)
def _finished_table(purchases) -> Node:
def _finished_table(purchases, view_all_url=None, total=None) -> Node:
thead = Element(
"thead",
children=[_tr([_th("Name", _NAME_TH), _th("Date")])],
)
purchases = list(purchases)
display = purchases[:_LIST_CAP] if view_all_url else purchases
rows = [
_tr([_td(_purchase_name(p)), _td(date_filter(p.date_finished, "d/m/Y"))])
for p in purchases
for p in display
]
total = total if total is not None else len(purchases)
if view_all_url and total > _LIST_CAP:
rows.append(_view_all_row(total, view_all_url))
return _table(rows, thead)
def _priced_table(purchases, currency) -> Node:
def _priced_table(purchases, currency, view_all_url=None, total=None) -> Node:
thead = Element(
"thead",
children=[
_tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")])
],
)
purchases = list(purchases)
display = purchases[:_LIST_CAP] if view_all_url else purchases
rows = [
_tr(
[
@@ -257,8 +351,11 @@ def _priced_table(purchases, currency) -> Node:
_td(date_filter(p.date_purchased, "d/m/Y")),
]
)
for p in purchases
for p in display
]
total = total if total is not None else len(purchases)
if view_all_url and total > _LIST_CAP:
rows.append(_view_all_row(total, view_all_url))
return _table(rows, thead)
@@ -281,7 +378,21 @@ def stats_content(ctx: dict) -> Node:
if months:
sections.append(_h1("Playtime per month"))
month_rows = [
_kv(date_filter(m["month"], "F"), _dur(m["playtime"])) for m in months
_tr(
[
_td(
A(
href=filter_url(
stats_links.sessions_in_month(year, m["month"].month)
),
class_="hover:underline decoration-dotted",
)[date_filter(m["month"], "F")],
_CELL,
),
_td(_dur(m["playtime"])),
]
)
for m in months
]
sections.append(_table(month_rows))
@@ -292,44 +403,74 @@ def stats_content(ctx: dict) -> Node:
_two_col_table(
"Name",
ctx.get("top_10_games_by_playtime") or [],
lambda g: GameLink(g.id, g.name),
lambda g: Fragment(GameLink(g.id, g.name), _session_link(g.id, year)),
lambda g: _dur(g.total_playtime),
view_all_url=filter_url(stats_links.games_played(year), sort="-playtime"),
),
_h1("Platforms by playtime"),
_two_col_table(
"Platform",
ctx.get("total_playtime_per_platform") or [],
lambda item: item["platform_name"],
lambda item: A(
href=filter_url(
stats_links.sessions_for_platform(item["platform_id"], year)
),
class_="hover:underline decoration-dotted",
)[item["platform_name"] or "Unspecified"],
lambda item: _dur(item["playtime"]),
),
]
all_finished = list(ctx.get("all_finished_this_year") or [])
if all_finished:
sections += [_h1("Finished"), _finished_table(all_finished)]
sections += [
_h1("Finished"),
_finished_table(
all_finished,
view_all_url=filter_url(
stats_links.purchases_finished(year), sort="finished"
),
total=ctx.get("all_finished_this_year_count"),
),
]
year_finished = list(ctx.get("this_year_finished_this_year") or [])
if year_finished:
sections += [_h1(f"Finished ({year} games)"), _finished_table(year_finished)]
sections += [
_h1(f"Finished ({year} games)"),
_finished_table(
year_finished,
view_all_url=filter_url(
stats_links.purchases_finished_released(year), sort="finished"
),
total=ctx.get("this_year_finished_this_year_count"),
),
]
bought_finished = list(ctx.get("purchased_this_year_finished_this_year") or [])
if bought_finished:
sections += [
_h1(f"Bought and Finished ({year})"),
_finished_table(bought_finished),
_finished_table(
bought_finished,
view_all_url=filter_url(
stats_links.purchases_bought_and_finished(year), sort="finished"
),
),
]
unfinished = list(ctx.get("purchased_unfinished") or [])
if unfinished:
sections += [
_h1("Unfinished Purchases"),
_priced_table(unfinished, currency),
_priced_table(
unfinished,
currency,
view_all_url=filter_url(stats_links.purchases_unfinished(year)),
total=ctx.get("purchased_unfinished_count"),
),
]
all_purchased = list(ctx.get("all_purchased_this_year") or [])
if all_purchased:
sections += [_h1("All Purchases"), _priced_table(all_purchased, currency)]
return Div(
[("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
sections,
+9 -4
View File
@@ -247,7 +247,7 @@ def compute_stats(year: int | None = None) -> StatsData:
.annotate(total_playtime=Sum("sessions__duration_total"))
.filter(total_playtime__gt=timedelta(0))
)
top_games = games_with_playtime.order_by("-total_playtime")[:10]
top_games = games_with_playtime.order_by("-total_playtime")
else:
games_with_playtime = (
Game.objects.filter(sessions__timestamp_start__year=year)
@@ -256,11 +256,16 @@ def compute_stats(year: int | None = None) -> StatsData:
)
top_games = games_with_playtime.order_by("-total_playtime")
# platform_id is carried alongside the name so the stats row can link to a
# platform-scoped session list (#65).
total_playtime_per_platform = (
sessions.values("game__platform__name")
sessions.values("game__platform__name", "game__platform__id")
.annotate(playtime=Sum(F("duration_total")))
.annotate(platform_name=F("game__platform__name"))
.values("platform_name", "playtime")
.annotate(
platform_name=F("game__platform__name"),
platform_id=F("game__platform__id"),
)
.values("platform_name", "platform_id", "playtime")
.order_by("-playtime")
)