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:
@@ -65,9 +65,19 @@ def _filter_parse(filter_json: str) -> dict:
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
def _extract_labeled(items: list[dict]) -> list[LabeledOption]:
|
def _extract_labeled(items: list) -> list[LabeledOption]:
|
||||||
"""Convert a list of ``{id, label}`` dicts to ``(value, label)`` pairs."""
|
"""Convert filter values to ``(value, label)`` pairs.
|
||||||
return [(str(item["id"]), str(item["label"])) for item in items]
|
|
||||||
|
UI-built filters carry ``{id, label}`` dicts; programmatically-built ones
|
||||||
|
(e.g. stats_links) carry bare ids/choices. A bare value uses itself as its
|
||||||
|
own label so the bar renders any valid filter instead of crashing."""
|
||||||
|
pairs: list[LabeledOption] = []
|
||||||
|
for item in items:
|
||||||
|
if isinstance(item, dict):
|
||||||
|
pairs.append((str(item["id"]), str(item["label"])))
|
||||||
|
else:
|
||||||
|
pairs.append((str(item), str(item)))
|
||||||
|
return pairs
|
||||||
|
|
||||||
|
|
||||||
def _filter_get_choice(existing: dict, field: str) -> FilterChoice:
|
def _filter_get_choice(existing: dict, field: str) -> FilterChoice:
|
||||||
|
|||||||
+168
-27
@@ -14,7 +14,9 @@ from common.components import (
|
|||||||
A,
|
A,
|
||||||
Div,
|
Div,
|
||||||
Element,
|
Element,
|
||||||
|
Fragment,
|
||||||
GameLink,
|
GameLink,
|
||||||
|
Icon,
|
||||||
Node,
|
Node,
|
||||||
Safe,
|
Safe,
|
||||||
Td,
|
Td,
|
||||||
@@ -23,11 +25,46 @@ from common.components import (
|
|||||||
YearPicker,
|
YearPicker,
|
||||||
)
|
)
|
||||||
from common.time import durationformat, format_duration
|
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 = "px-2 sm:px-4 md:px-6 md:py-2"
|
||||||
_CELL_MONO = f"{_CELL} font-mono"
|
_CELL_MONO = f"{_CELL} font-mono"
|
||||||
_NAME_TH = f"{_CELL} purchase-name truncate max-w-20char"
|
_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:
|
def _td(children, cls: str = _CELL_MONO) -> Node:
|
||||||
if not isinstance(children, list):
|
if not isinstance(children, list):
|
||||||
@@ -119,14 +156,28 @@ def _playtime_table(ctx) -> Node:
|
|||||||
year = ctx.get("year")
|
year = ctx.get("year")
|
||||||
rows = [
|
rows = [
|
||||||
_kv("Hours", ctx.get("total_hours")),
|
_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(
|
_kv(
|
||||||
"Days",
|
"Days",
|
||||||
f"{ctx.get('unique_days')} ({ctx.get('unique_days_percent')}%)",
|
f"{ctx.get('unique_days')} ({ctx.get('unique_days_percent')}%)",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
if ctx.get("total_games"):
|
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")))
|
rows.append(_kv(f"Games ({year})", ctx.get("total_year_games")))
|
||||||
if ctx.get("all_finished_this_year_count"):
|
if ctx.get("all_finished_this_year_count"):
|
||||||
rows.append(_kv("Finished", 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(
|
return _tr(
|
||||||
[
|
[
|
||||||
_td(label, _CELL),
|
_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),
|
GameLink(first_game.id, first_game.name),
|
||||||
f" ({ctx.get('first_play_date')})",
|
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),
|
GameLink(last_game.id, last_game.name),
|
||||||
f" ({ctx.get('last_play_date')})",
|
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:
|
def _purchases_table(ctx) -> Node:
|
||||||
|
year = ctx.get("year")
|
||||||
rows = [
|
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(
|
_kv(
|
||||||
"Refunded",
|
"Refunded",
|
||||||
f"{ctx.get('all_purchased_refunded_this_year_count')} "
|
_count_link(
|
||||||
f"({ctx.get('refunded_percent')}%)",
|
f"{ctx.get('all_purchased_refunded_this_year_count')} "
|
||||||
|
f"({ctx.get('refunded_percent')}%)",
|
||||||
|
filter_url(stats_links.purchases_refunded(year)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
_kv(
|
_kv(
|
||||||
"Dropped",
|
"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(
|
_kv(
|
||||||
"Unfinished",
|
"Unfinished",
|
||||||
f"{ctx.get('purchased_unfinished_count')} "
|
_count_link(
|
||||||
f"({ctx.get('unfinished_purchases_percent')}%)",
|
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(
|
_kv(
|
||||||
f"Spendings ({ctx.get('total_spent_currency')})",
|
f"Spendings ({ctx.get('total_spent_currency')})",
|
||||||
f"{floatformat(ctx.get('total_spent'))} "
|
f"{floatformat(ctx.get('total_spent'))} "
|
||||||
@@ -221,34 +304,45 @@ def _purchases_table(ctx) -> Node:
|
|||||||
return _table(rows)
|
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 = Element(
|
||||||
"thead",
|
"thead",
|
||||||
children=[_tr([_th(header), _th("Playtime")])],
|
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)
|
return _table(rows, thead)
|
||||||
|
|
||||||
|
|
||||||
def _finished_table(purchases) -> Node:
|
def _finished_table(purchases, view_all_url=None, total=None) -> Node:
|
||||||
thead = Element(
|
thead = Element(
|
||||||
"thead",
|
"thead",
|
||||||
children=[_tr([_th("Name", _NAME_TH), _th("Date")])],
|
children=[_tr([_th("Name", _NAME_TH), _th("Date")])],
|
||||||
)
|
)
|
||||||
|
purchases = list(purchases)
|
||||||
|
display = purchases[:_LIST_CAP] if view_all_url else purchases
|
||||||
rows = [
|
rows = [
|
||||||
_tr([_td(_purchase_name(p)), _td(date_filter(p.date_finished, "d/m/Y"))])
|
_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)
|
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 = Element(
|
||||||
"thead",
|
"thead",
|
||||||
children=[
|
children=[
|
||||||
_tr([_th("Name", _NAME_TH), _th(f"Price ({currency})"), _th("Date")])
|
_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 = [
|
rows = [
|
||||||
_tr(
|
_tr(
|
||||||
[
|
[
|
||||||
@@ -257,8 +351,11 @@ def _priced_table(purchases, currency) -> Node:
|
|||||||
_td(date_filter(p.date_purchased, "d/m/Y")),
|
_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)
|
return _table(rows, thead)
|
||||||
|
|
||||||
|
|
||||||
@@ -281,7 +378,21 @@ def stats_content(ctx: dict) -> Node:
|
|||||||
if months:
|
if months:
|
||||||
sections.append(_h1("Playtime per month"))
|
sections.append(_h1("Playtime per month"))
|
||||||
month_rows = [
|
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))
|
sections.append(_table(month_rows))
|
||||||
|
|
||||||
@@ -292,44 +403,74 @@ def stats_content(ctx: dict) -> Node:
|
|||||||
_two_col_table(
|
_two_col_table(
|
||||||
"Name",
|
"Name",
|
||||||
ctx.get("top_10_games_by_playtime") or [],
|
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),
|
lambda g: _dur(g.total_playtime),
|
||||||
|
view_all_url=filter_url(stats_links.games_played(year), sort="-playtime"),
|
||||||
),
|
),
|
||||||
_h1("Platforms by playtime"),
|
_h1("Platforms by playtime"),
|
||||||
_two_col_table(
|
_two_col_table(
|
||||||
"Platform",
|
"Platform",
|
||||||
ctx.get("total_playtime_per_platform") or [],
|
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"]),
|
lambda item: _dur(item["playtime"]),
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
all_finished = list(ctx.get("all_finished_this_year") or [])
|
all_finished = list(ctx.get("all_finished_this_year") or [])
|
||||||
if all_finished:
|
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 [])
|
year_finished = list(ctx.get("this_year_finished_this_year") or [])
|
||||||
if year_finished:
|
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 [])
|
bought_finished = list(ctx.get("purchased_this_year_finished_this_year") or [])
|
||||||
if bought_finished:
|
if bought_finished:
|
||||||
sections += [
|
sections += [
|
||||||
_h1(f"Bought and Finished ({year})"),
|
_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 [])
|
unfinished = list(ctx.get("purchased_unfinished") or [])
|
||||||
if unfinished:
|
if unfinished:
|
||||||
sections += [
|
sections += [
|
||||||
_h1("Unfinished Purchases"),
|
_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(
|
return Div(
|
||||||
[("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
|
[("class", "dark:text-white max-w-sm sm:max-w-xl lg:max-w-3xl mx-auto")],
|
||||||
sections,
|
sections,
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ def compute_stats(year: int | None = None) -> StatsData:
|
|||||||
.annotate(total_playtime=Sum("sessions__duration_total"))
|
.annotate(total_playtime=Sum("sessions__duration_total"))
|
||||||
.filter(total_playtime__gt=timedelta(0))
|
.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:
|
else:
|
||||||
games_with_playtime = (
|
games_with_playtime = (
|
||||||
Game.objects.filter(sessions__timestamp_start__year=year)
|
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")
|
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 = (
|
total_playtime_per_platform = (
|
||||||
sessions.values("game__platform__name")
|
sessions.values("game__platform__name", "game__platform__id")
|
||||||
.annotate(playtime=Sum(F("duration_total")))
|
.annotate(playtime=Sum(F("duration_total")))
|
||||||
.annotate(platform_name=F("game__platform__name"))
|
.annotate(
|
||||||
.values("platform_name", "playtime")
|
platform_name=F("game__platform__name"),
|
||||||
|
platform_id=F("game__platform__id"),
|
||||||
|
)
|
||||||
|
.values("platform_name", "platform_id", "playtime")
|
||||||
.order_by("-playtime")
|
.order_by("-playtime")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
"""The filter bar must render a filter whose choice/multi values are bare
|
||||||
|
(no embedded {id, label}) — e.g. a programmatically built filter from
|
||||||
|
stats_links — without crashing (#65)."""
|
||||||
|
|
||||||
|
from common.components.filters import _extract_labeled
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_labeled_handles_labeled_dicts():
|
||||||
|
assert _extract_labeled([{"id": "game", "label": "Game"}]) == [("game", "Game")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_labeled_handles_bare_values():
|
||||||
|
# bare scalars (ids/choices) fall back to using the value as its own label
|
||||||
|
assert _extract_labeled(["game", "dlc"]) == [("game", "game"), ("dlc", "dlc")]
|
||||||
|
|
||||||
|
|
||||||
|
def test_extract_labeled_handles_bare_ints():
|
||||||
|
assert _extract_labeled([3, 7]) == [("3", "3"), ("7", "7")]
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
"""Rendering tests: stats page wires rows/counts to filtered-list links (#65)."""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.utils.html import escape
|
||||||
|
|
||||||
|
from games.filters import filter_url
|
||||||
|
from games.models import Game, Platform, PlayEvent, Purchase, Session
|
||||||
|
from games.views import stats_links
|
||||||
|
from games.views.stats_content import stats_content
|
||||||
|
from games.views.stats_data import compute_stats
|
||||||
|
|
||||||
|
YEAR = 2024
|
||||||
|
|
||||||
|
|
||||||
|
def _dt(month, day, hour=12):
|
||||||
|
return datetime(YEAR, month, day, hour, 0, tzinfo=timezone.utc)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def rendered(db):
|
||||||
|
pc = Platform.objects.create(name="PC")
|
||||||
|
# 6 games each played in-year → games-by-playtime exceeds the cap of 5.
|
||||||
|
games = []
|
||||||
|
for index in range(6):
|
||||||
|
game = Game.objects.create(
|
||||||
|
name=f"Game {index}", platform=pc, status=Game.Status.PLAYED
|
||||||
|
)
|
||||||
|
start = _dt(6, index + 1)
|
||||||
|
Session.objects.create(
|
||||||
|
game=game,
|
||||||
|
timestamp_start=start,
|
||||||
|
timestamp_end=start + timedelta(hours=index + 1),
|
||||||
|
)
|
||||||
|
games.append(game)
|
||||||
|
|
||||||
|
abandoned = Game.objects.create(
|
||||||
|
name="Abandoned", platform=pc, status=Game.Status.ABANDONED
|
||||||
|
)
|
||||||
|
Purchase.objects.create(date_purchased=_dt(1, 5), type=Purchase.GAME).games.set(
|
||||||
|
[games[0]]
|
||||||
|
)
|
||||||
|
Purchase.objects.create(date_purchased=_dt(2, 5), type=Purchase.GAME).games.set(
|
||||||
|
[abandoned]
|
||||||
|
) # dropped
|
||||||
|
Purchase.objects.create(
|
||||||
|
date_purchased=_dt(3, 5), date_refunded=_dt(4, 5), type=Purchase.GAME
|
||||||
|
).games.set([games[1]]) # refunded
|
||||||
|
Purchase.objects.create(date_purchased=_dt(5, 5), type=Purchase.GAME).games.set(
|
||||||
|
[games[2]]
|
||||||
|
) # unfinished
|
||||||
|
|
||||||
|
finished_game = games[0]
|
||||||
|
PlayEvent.objects.create(game=finished_game, ended=_dt(8, 1))
|
||||||
|
|
||||||
|
ctx = compute_stats(YEAR)
|
||||||
|
return {"html": str(stats_content(ctx)), "pc": pc, "games": games}
|
||||||
|
|
||||||
|
|
||||||
|
def _href(builder_filter, **extra):
|
||||||
|
return escape(filter_url(builder_filter, **extra))
|
||||||
|
|
||||||
|
|
||||||
|
def test_total_count_links_to_purchases(rendered):
|
||||||
|
assert _href(stats_links.purchases_total(YEAR)) in rendered["html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_refunded_count_links_to_refunded_purchases(rendered):
|
||||||
|
assert _href(stats_links.purchases_refunded(YEAR)) in rendered["html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_dropped_count_links_to_dropped_purchases(rendered):
|
||||||
|
assert _href(stats_links.purchases_dropped(YEAR)) in rendered["html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_unfinished_count_links_to_unfinished_purchases(rendered):
|
||||||
|
assert _href(stats_links.purchases_unfinished(YEAR)) in rendered["html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_platform_row_links_to_platform_sessions(rendered):
|
||||||
|
url = _href(stats_links.sessions_for_platform(rendered["pc"].id, YEAR))
|
||||||
|
assert url in rendered["html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_game_row_has_session_link(rendered):
|
||||||
|
# at least one games-by-playtime game links to its sessions
|
||||||
|
any_game = rendered["games"][0]
|
||||||
|
url = _href(stats_links.sessions_for_game(any_game.id, YEAR))
|
||||||
|
assert url in rendered["html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_games_by_playtime_capped_with_view_all(rendered):
|
||||||
|
# 6 games played, capped to 5 → a "View all" link to games_played
|
||||||
|
assert "View all" in rendered["html"]
|
||||||
|
view_all = filter_url(stats_links.games_played(YEAR), sort="-playtime")
|
||||||
|
# the filter portion (before &sort) must be present even after attr-escaping
|
||||||
|
assert escape(view_all.split("&")[0]) in rendered["html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_all_purchases_section_removed(rendered):
|
||||||
|
assert "All Purchases" not in rendered["html"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_generated_links_resolve_to_200(rendered, client, django_user_model):
|
||||||
|
"""A stats link, when visited, returns 200 with its filter applied."""
|
||||||
|
user = django_user_model.objects.create_user(username="u", password="p")
|
||||||
|
client.force_login(user)
|
||||||
|
for builder in (
|
||||||
|
stats_links.purchases_total(YEAR),
|
||||||
|
stats_links.purchases_dropped(YEAR),
|
||||||
|
stats_links.sessions_for_platform(rendered["pc"].id, YEAR),
|
||||||
|
):
|
||||||
|
response = client.get(filter_url(builder), follow=True)
|
||||||
|
assert response.status_code == 200
|
||||||
Reference in New Issue
Block a user