Merge pull request #80 from KucharczykL/feat/issue-65-stats-filter-links

feat(stats): link stats-page rows and counts to filtered lists (#65)
This commit is contained in:
2026-06-21 17:55:39 +02:00
committed by GitHub
8 changed files with 967 additions and 34 deletions
+13 -3
View File
@@ -65,9 +65,19 @@ def _filter_parse(filter_json: str) -> dict:
return {}
def _extract_labeled(items: list[dict]) -> list[LabeledOption]:
"""Convert a list of ``{id, label}`` dicts to ``(value, label)`` pairs."""
return [(str(item["id"]), str(item["label"])) for item in items]
def _extract_labeled(items: list) -> list[LabeledOption]:
"""Convert filter values to ``(value, label)`` pairs.
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:
@@ -0,0 +1,232 @@
# Issue #65 — Stats-page filtered links
**Date:** 2026-06-21
**Issue:** https://github.com/KucharczykL/timetracker/issues/65 (sub-issue of #61, follow-up to #56)
**Prereq:** #67 — date-range filtering on `PlayEventFilter.ended`/`started`
(**merged**, PR #69). `ended`/`started` are now `DateCriterion`; because they are
`DateField`s (not `DateTimeField`s) the implementation uses a bare lookup, not
`__date`. Tier 2 is therefore unblocked — both tiers can ship together.
## Problem
The stats page (`games/views/stats_content.py`, data from `stats_data.py`) shows
aggregate playtime/purchase metrics and several lists. Rows and counts don't link
to the underlying records. #56 introduced `filter_url()` and
`OperatorFilter.where()`; this wires the stats page to those helpers so a row or
count drills into the filtered list it represents.
The page is scoped either to a single year (`compute_stats(year)`, `data["year"]`
is an int) or all-time (`compute_stats(None)`, `data["year"] == "Alltime"`).
## Scope
Two tiers, split by what the filter system can express **today**:
- **Tier 1 — implemented in this issue** (no new filter machinery).
- **Tier 2 — needs "finished in year" filtering** from #67 (now merged, PR #69),
so it can ship alongside Tier 1.
Also in scope (from design review): shorten long lists to 5 items with a "view
all" link, and remove the "All purchases" list.
## Year scoping
Every link encodes the page's scope:
- **Per-year page**: sessions filtered by `timestamp_start` BETWEEN `{year}-01-01`
and `{year}-12-31`; purchases by `date_purchased` BETWEEN the same bounds.
- **All-time page**: no date constraint.
A single helper computes the year bounds (or `None`) once and is reused by every
link builder so scoping is consistent.
## Design
### A. Per-row entity links
**Game rows** — superlative rows in `_playtime_table` (longest session, most
sessions, highest average, first/last play) and the "Games by playtime" list.
Keep the existing `GameLink` (→ game detail) and **add** a separate affordance (a
small icon link) to that game's filtered session list:
```python
filter_url(SessionFilter.where(game=[game.id], **session_year_bounds))
```
**Platform rows** — "Platforms by playtime". Link each platform to its sessions:
```python
filter_url(SessionFilter.where(
game_filter=GameFilter.where(platform=[platform_id]),
**session_year_bounds,
))
```
Requires a **data change**: `total_playtime_per_platform` rows currently carry
only `platform_name`. Add `platform_id` to the dict in `stats_data.py`.
**Month rows** — "Playtime per month" (per-year only). Link each month to its
sessions:
```python
filter_url(SessionFilter.where(
timestamp_start__between=(month_start_iso, month_end_iso)
))
```
### B. Aggregate count links
In `_playtime_table` (Tier 1 — added per design review item G):
- **Sessions** count → `SessionFilter.where(**session_year_bounds)`.
- **Games** count → games played in scope:
`GameFilter.where(session_filter=SessionFilter.where(**session_year_bounds))`.
In `_purchases_table`:
- **Total purchased** (Tier 1) → `PurchaseFilter.where(**purchase_year_bounds)`.
- **Refunded** (Tier 1) →
`PurchaseFilter.where(is_refunded=True, **purchase_year_bounds)`.
- **Dropped** (Tier 2) → purchases that are abandoned-or-refunded and not
finished; uses the `not_finished` composition (see Tier 2).
- **Unfinished** (Tier 2) → the `purchased_unfinished` set (see Tier 2).
- **Backlog decrease** (Tier 2) → per-year: purchases bought before the year whose
game is finished-in-year (see Tier 2). Expressible with prereq #67 — no extra
machinery.
### C. List capping + "view all"
Cap these lists to **5 rows** and append a **"View all (N) →"** link to the
filtered list (N = the full count):
| List | Cap from | "View all" target |
|------|----------|-------------------|
| Games by playtime | 10 | games played in scope (Tier 1) |
| Finished | all | finished purchases in scope (Tier 2) |
| Finished (YYYY) | all | finished-in-year, released-in-year (Tier 2) |
| Bought & Finished (YYYY) | all | purchased-in-year ∧ finished-in-year (Tier 2) |
| Unfinished purchases | all | `purchased_unfinished` set (Tier 2) |
Capping is done in `stats_content.py` (slice to 5 for display); the full count
for the link label comes from the existing `_count` keys in `StatsData` (or
`len()` where no count key exists). The "Games by playtime" cap also reduces
`top_10_games_by_playtime` usage to 5 (slice at render; the data key may keep its
name or be renamed to `top_games_by_playtime` — implementer's choice, update both
sites).
**Remove** the "All purchases" list (`all_purchased_this_year` rendering). Its
entry point is the "Total purchased" count link. The `StatsData` key may remain
(harmless) or be removed if no other consumer exists.
### D. Tier 2 — finished/dropped/unfinished/backlog (uses #67)
With #67 merged, `PlayEventFilter.ended` is a `DateCriterion` (bare `DateField`
lookup), so "finished in year" is expressible and the chain
`PurchaseFilter.game_filter → GameFilter.playevent_filter → PlayEventFilter`
composes the Tier-2 targets. Reference semantics (from `stats_data.py`):
- **finished** (`Purchase.objects.finished()`) = game `status == FINISHED` **or**
game has a playevent with `ended` set →
`GameFilter.where(OR=...)` of `status=[FINISHED]` and
`playevent_filter=PlayEventFilter.where(ended__notnull=True)`.
- **finished in year** = above **and** `ended` within the year →
add `playevent_filter=PlayEventFilter.where(ended__between=(jan1, dec31))`.
- **finished (YYYY) released-in-year** = finished-in-year **and**
`GameFilter year_released == year`.
- **bought & finished (YYYY)** = `is_refunded=False`, `date_purchased` in year,
game finished-in-year.
- **dropped** = `type in (game, dlc)`, `infinite=False`, **and**
(`game status=ABANDONED` **or** `is_refunded=True`), **and** not finished-in-year.
- **unfinished** = `is_refunded=False`, `infinite=False`, `type in (game, dlc)`,
game `status NOT IN (FINISHED, RETIRED, ABANDONED)`, **and** not finished-in-year.
- **backlog decrease** (per-year) = `date_purchased__lt = {year}-01-01`, game
`status=FINISHED`, **and** finished-in-year →
`PurchaseFilter.where(date_purchased__lt=jan1, game_filter=GameFilter.where(
status=[FINISHED], playevent_filter=PlayEventFilter.where(ended__between=(jan1, dec31))))`.
All-time backlog decrease equals the all-time finished count (matches current
`stats_data.py` behavior) → link to the all-time finished filter.
These are nested AND/OR/NOT compositions of existing fields plus the #67 date
range — no further machinery.
### E. Components / rendering
Reuse existing builders in `stats_content.py` (`_td`, `_tr`, `GameLink`, `A`).
Add small helpers:
- `_session_link_icon(game_id, year_bounds)` → an `A` wrapping an `Icon`, for the
per-row game session affordance.
- `_view_all(count, url)` → the "View all (N) →" row/footer link.
Year-bounds helper (e.g. `_year_bounds(year)`) returns the session/purchase
`where()` kwargs (or empty dict for all-time), so every builder scopes
identically.
## Sorting parity (#68 — merged)
The stats lists are sorted, so a "view all" link must reproduce both the set *and*
the order. #68 (PR #78) added `games/sorting.py` and a `?sort=` query param (a
signed comma-list of public sort keys) honored by the list views, with the keys
this feature needs:
- `GAME_SORTS`: `playtime` (`Sum(sessions__duration_total)`), `finished`
(`Max(playevents__ended)`).
- `PURCHASE_SORTS`: `finished` (`Max(games__playevents__ended)`), `purchased`,
`price`, `name`.
So each "view all" link passes the matching key via `filter_url(..., sort=...)`
**no TODOs**. The annotated keys (`playtime`, `finished`) mirror the aggregates
`stats_data.py` uses, so order matches exactly:
| View all | sort= | matches stats order |
|----------|-------|---------------------|
| Games by playtime | `-playtime` | `top_*_games_by_playtime` (playtime desc) |
| Finished | `finished` / `-finished` | the section's `order_by` in `stats_data.py` |
| Finished (YYYY) | `finished` | `games__playevents__ended` asc |
| Bought & Finished (YYYY) | `finished` | `games__playevents__ended` asc |
| Unfinished purchases | `-purchased` | purchase list default order |
Direction (asc vs desc) per finished section is chosen to match that section's
`order_by` in `stats_data.py`; the rendering test asserts the linked list's order
matches the preview's.
## Exact-match requirement
A count or "view all" link must land on a list whose total equals the displayed
number. The stats queries traverse M2M (`games__…`) joins; the filter layer
resolves cross-entity criteria via `Game.objects.filter(...).values_list("id")`
subqueries, so join/`distinct` semantics can differ. Each linked category gets a
test asserting the linked filter's queryset count equals the corresponding
`StatsData` count, on seeded data. Any category that can't be made to match
exactly is reported (not silently shipped with a wrong number).
## Testing
- **Link builders** (unit): for each linked row/count, `filter_url(...)` produces
the expected path and the `filter` JSON round-trips through the matching
`parse_*_filter`. Year-scoped vs all-time variants both covered.
- **Exact-match** (db): seed games/sessions/purchases/playevents spanning the year
boundary and other years; assert each linked filter's count equals the
`compute_stats(year)` / `compute_stats(None)` value for that category (both
tiers, since #67 is merged).
- **Rendering** (db): stats page renders; capped lists show ≤5 rows + a "View all"
link; the "All purchases" list is gone; smoke-test that the generated link URLs
return 200.
## Implementation order
1. **Tier 1 + capping + removal** (this issue, independent of #67): per-row game /
platform / month session links, sessions/games/total-purchased/refunded count
links, list capping with Tier-1 "view all" links, remove "All purchases",
`platform_id` data change.
2. **Tier 2** (unblocked — #67 is merged): finished/dropped/unfinished/
backlog-decrease count and "view all" links.
Both tiers can ship together now that #67 is merged; Tier 1 remains independently
shippable if a smaller first PR is preferred.
## Out of scope
- The `view_game` table links (#66, the other #61 sub-issue).
- List-view sort support itself (#68, merged) — #65 only *passes* the `sort` param
to the existing keys (see "Sorting parity").
+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")
)
+200
View File
@@ -0,0 +1,200 @@
"""Filter-link builders for the stats page (issue #65).
Each function returns a filter object describing exactly the records behind a
stats row or count; `stats_content` wraps them with `filter_url()` to link to the
matching list view. Keeping these as pure functions (no HTTP, no rendering) lets
the parity tests assert each builder's queryset count equals the stat it links
from.
Scope: `year` is an int for a calendar year, or the "Alltime" sentinel (or any
non-int) for all-time — matching `StatsData["year"]`. For all-time the date
bounds are omitted, so the links cover every record.
"""
from calendar import monthrange
from common.criteria import (
BoolCriterion,
ChoiceCriterion,
DateCriterion,
IntCriterion,
Modifier,
)
from games.filters import (
GameFilter,
PlayEventFilter,
PurchaseFilter,
SessionFilter,
)
from games.models import Game, Purchase
def _is_year(year) -> bool:
return isinstance(year, int)
def _year_range(year: int) -> tuple[str, str]:
return (f"{year}-01-01", f"{year}-12-31")
def _session_bounds(year) -> dict:
"""`where()` kwargs scoping sessions to the year (empty for all-time)."""
if not _is_year(year):
return {}
return {"timestamp_start__between": _year_range(year)}
def _purchase_bounds(year) -> dict:
if not _is_year(year):
return {}
return {"date_purchased__between": _year_range(year)}
# ── Sessions ─────────────────────────────────────────────────────────────────
def all_sessions(year) -> SessionFilter:
return SessionFilter.where(**_session_bounds(year))
def sessions_for_game(game_id: int, year) -> SessionFilter:
return SessionFilter.where(game=[game_id], **_session_bounds(year))
def sessions_for_platform(platform_id: int, year) -> SessionFilter:
session_filter = SessionFilter.where(**_session_bounds(year))
session_filter.game_filter = GameFilter.where(platform=[platform_id])
return session_filter
def sessions_in_month(year: int, month: int) -> SessionFilter:
last_day = monthrange(year, month)[1]
start = f"{year}-{month:02d}-01"
end = f"{year}-{month:02d}-{last_day:02d}"
return SessionFilter.where(timestamp_start__between=(start, end))
# ── Games ────────────────────────────────────────────────────────────────────
def games_played(year) -> GameFilter:
"""Games with at least one session in scope (matches `total_games`)."""
return GameFilter(session_filter=all_sessions(year))
# ── Purchases ────────────────────────────────────────────────────────────────
def purchases_total(year) -> PurchaseFilter:
return PurchaseFilter.where(**_purchase_bounds(year))
def purchases_refunded(year) -> PurchaseFilter:
return PurchaseFilter.where(is_refunded=True, **_purchase_bounds(year))
# ── Tier 2: finished / dropped / unfinished / backlog (uses #67) ─────────────
#
# These mirror the M2M-traversing queries in `stats_data.py`. The project models
# multi-item orders as separate single-game purchases, so on that (dominant) data
# the filter system's id-set semantics match the stats queries exactly; the only
# divergence is unsplittable multi-game bundles, which the stats queries
# themselves count inconsistently. Parity is verified per category in tests.
def _ended_in_scope(year) -> PlayEventFilter:
"""A game's finish: a playevent whose `ended` falls in scope (any, all-time)."""
if _is_year(year):
return PlayEventFilter.where(ended__between=_year_range(year))
return PlayEventFilter.where(ended__notnull=True)
def _not_finished_game(year, excluded_statuses: list) -> GameFilter:
"""Games that are not finished in scope: status not in `excluded_statuses`
(always includes FINISHED) and no finishing playevent in scope.
Mirrors `not_finished_q = ~Q(status=FINISHED) & ~ended_q` plus the extra
status exclusions some categories add."""
game_filter = GameFilter(
status=ChoiceCriterion(value=excluded_statuses, modifier=Modifier.EXCLUDES)
)
game_filter.NOT = GameFilter(playevent_filter=_ended_in_scope(year))
return game_filter
def purchases_finished(year) -> PurchaseFilter:
"""Purchases whose game is finished (in scope)."""
if _is_year(year):
return PurchaseFilter(
game_filter=GameFilter(playevent_filter=_ended_in_scope(year))
)
# All-time `.finished()`: game status FINISHED *or* any ended playevent.
game_filter = GameFilter(status=ChoiceCriterion(value=[Game.Status.FINISHED]))
game_filter.OR = GameFilter(playevent_filter=_ended_in_scope(year))
return PurchaseFilter(game_filter=game_filter)
def purchases_finished_released(year) -> PurchaseFilter:
"""Finished-in-scope purchases whose game was released that year."""
if not _is_year(year):
return purchases_finished(year)
game_filter = GameFilter(
year_released=IntCriterion(value=year, modifier=Modifier.EQUALS),
playevent_filter=_ended_in_scope(year),
)
return PurchaseFilter(game_filter=game_filter)
def purchases_bought_and_finished(year) -> PurchaseFilter:
"""Not-refunded purchases bought in scope whose game finished in scope."""
purchase_filter = PurchaseFilter.where(is_refunded=False, **_purchase_bounds(year))
purchase_filter.game_filter = GameFilter(playevent_filter=_ended_in_scope(year))
return purchase_filter
def _abandoned_or_refunded() -> PurchaseFilter:
purchase_filter = PurchaseFilter(
game_filter=GameFilter(status=ChoiceCriterion(value=[Game.Status.ABANDONED]))
)
purchase_filter.OR = PurchaseFilter(is_refunded=BoolCriterion(value=True))
return purchase_filter
def purchases_dropped(year) -> PurchaseFilter:
purchase_filter = PurchaseFilter.where(
infinite=False,
type=[Purchase.GAME, Purchase.DLC],
**_purchase_bounds(year),
)
purchase_filter.game_filter = _not_finished_game(year, [Game.Status.FINISHED])
purchase_filter.AND = _abandoned_or_refunded()
return purchase_filter
def purchases_unfinished(year) -> PurchaseFilter:
purchase_filter = PurchaseFilter.where(
is_refunded=False,
infinite=False,
type=[Purchase.GAME, Purchase.DLC],
**_purchase_bounds(year),
)
purchase_filter.game_filter = _not_finished_game(
year,
[Game.Status.FINISHED, Game.Status.RETIRED, Game.Status.ABANDONED],
)
return purchase_filter
def purchases_backlog_decrease(year) -> PurchaseFilter:
"""Per-year: bought before the year, game finished in the year. All-time:
equals the all-time finished count (matches `stats_data.py`)."""
if not _is_year(year):
return purchases_finished(year)
purchase_filter = PurchaseFilter(
date_purchased=DateCriterion(value=f"{year}-01-01", modifier=Modifier.LESS_THAN)
)
purchase_filter.game_filter = GameFilter(
status=ChoiceCriterion(value=[Game.Status.FINISHED]),
playevent_filter=_ended_in_scope(year),
)
return purchase_filter
+18
View File
@@ -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")]
+115
View File
@@ -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
+212
View File
@@ -0,0 +1,212 @@
"""Parity tests for stats-page filter-link builders (issue #65).
Each builder returns a filter object; the test asserts the filter's queryset
count equals the value the stats page displays for that category, so a link can
never land on a list whose total differs from the number it was clicked from.
Data is single-game purchases (the project's modeling norm — multi-item orders
are separate single-game purchases), where the filter system's id-set semantics
match the stats queries' M2M traversal exactly.
"""
from datetime import datetime, timezone
import pytest
from games.models import Game, Platform, PlayEvent, Purchase, Session
from games.views import stats_links
from games.views.stats_data import compute_stats
YEAR = 2024
def _dt(year, month=6, day=1):
return datetime(year, month, day, 12, 0, tzinfo=timezone.utc)
@pytest.fixture
def world(db):
pc = Platform.objects.create(name="PC")
switch = Platform.objects.create(name="Switch")
finished_game = Game.objects.create(
name="Finished", platform=pc, status=Game.Status.FINISHED, year_released=YEAR
)
abandoned_game = Game.objects.create(
name="Abandoned", platform=pc, status=Game.Status.ABANDONED
)
playing_game = Game.objects.create(
name="Playing", platform=switch, status=Game.Status.PLAYED
)
# Sessions: in-year on two platforms + one out-of-year (excluded).
Session.objects.create(game=finished_game, timestamp_start=_dt(YEAR, 6, 1))
Session.objects.create(game=finished_game, timestamp_start=_dt(YEAR, 7, 2))
Session.objects.create(game=playing_game, timestamp_start=_dt(YEAR, 6, 3))
Session.objects.create(game=finished_game, timestamp_start=_dt(YEAR - 1, 6, 1))
# PlayEvents: finished_game ended in-year.
PlayEvent.objects.create(game=finished_game, ended=_dt(YEAR, 8, 1))
# Purchases (single-game).
Purchase.objects.create( # finished, bought in-year
date_purchased=_dt(YEAR, 1, 5), type=Purchase.GAME
).games.set([finished_game])
Purchase.objects.create( # abandoned -> dropped
date_purchased=_dt(YEAR, 2, 5), type=Purchase.GAME
).games.set([abandoned_game])
Purchase.objects.create( # refunded
date_purchased=_dt(YEAR, 3, 5),
date_refunded=_dt(YEAR, 4, 5),
type=Purchase.GAME,
).games.set([playing_game])
Purchase.objects.create( # unfinished (playing, not refunded/finished)
date_purchased=_dt(YEAR, 5, 5), type=Purchase.GAME
).games.set([playing_game])
# backlog decrease: bought prior year, game finished, ended in-year
Purchase.objects.create(
date_purchased=_dt(YEAR - 1, 5, 5), type=Purchase.GAME
).games.set([finished_game])
return {
"pc": pc,
"switch": switch,
"finished_game": finished_game,
"playing_game": playing_game,
}
def _count(filter_obj, model):
return model.objects.filter(filter_obj.to_q()).distinct().count()
# ── Per-row session links ────────────────────────────────────────────────────
def test_sessions_for_game_matches_year_scoped_sessions(world):
game = world["finished_game"]
expected = Session.objects.filter(
timestamp_start__year=YEAR, game_id=game.id
).count()
assert expected == 2 # guard: the out-of-year session is excluded
assert _count(stats_links.sessions_for_game(game.id, YEAR), Session) == expected
def test_sessions_for_platform_matches_year_scoped_sessions(world):
platform = world["pc"]
expected = Session.objects.filter(
timestamp_start__year=YEAR, game__platform_id=platform.id
).count()
assert (
_count(stats_links.sessions_for_platform(platform.id, YEAR), Session)
== expected
)
def test_sessions_in_month_matches_that_month(world):
expected = Session.objects.filter(
timestamp_start__year=YEAR, timestamp_start__month=6
).count()
assert expected == 2
assert _count(stats_links.sessions_in_month(YEAR, 6), Session) == expected
def test_all_sessions_matches_total_sessions(world):
stats = compute_stats(YEAR)
assert _count(stats_links.all_sessions(YEAR), Session) == stats["total_sessions"]
# ── Count links ──────────────────────────────────────────────────────────────
def test_games_played_matches_total_games(world):
stats = compute_stats(YEAR)
assert _count(stats_links.games_played(YEAR), Game) == stats["total_games"]
def test_total_purchases_matches_count(world):
stats = compute_stats(YEAR)
assert (
_count(stats_links.purchases_total(YEAR), Purchase)
== stats["all_purchased_this_year_count"]
)
def test_refunded_purchases_matches_count(world):
stats = compute_stats(YEAR)
assert (
_count(stats_links.purchases_refunded(YEAR), Purchase)
== stats["all_purchased_refunded_this_year_count"]
)
# ── Tier 2: finished / dropped / unfinished / backlog (uses #67) ─────────────
def test_dropped_matches_count(world):
stats = compute_stats(YEAR)
assert stats["dropped_count"] == 2 # guard: discriminating, non-zero
assert (
_count(stats_links.purchases_dropped(YEAR), Purchase) == stats["dropped_count"]
)
def test_unfinished_matches_count(world):
stats = compute_stats(YEAR)
assert stats["purchased_unfinished_count"] == 1
assert (
_count(stats_links.purchases_unfinished(YEAR), Purchase)
== stats["purchased_unfinished_count"]
)
def test_finished_matches_count(world):
stats = compute_stats(YEAR)
assert stats["all_finished_this_year_count"] == 2
assert (
_count(stats_links.purchases_finished(YEAR), Purchase)
== stats["all_finished_this_year_count"]
)
def test_finished_released_matches_count(world):
stats = compute_stats(YEAR)
assert (
_count(stats_links.purchases_finished_released(YEAR), Purchase)
== stats["this_year_finished_this_year_count"]
)
def test_bought_and_finished_matches_list(world):
stats = compute_stats(YEAR)
expected = stats["purchased_this_year_finished_this_year"].count()
assert expected == 1
assert _count(stats_links.purchases_bought_and_finished(YEAR), Purchase) == expected
def test_backlog_decrease_matches_count(world):
stats = compute_stats(YEAR)
assert stats["backlog_decrease_count"] == 1
assert (
_count(stats_links.purchases_backlog_decrease(YEAR), Purchase)
== stats["backlog_decrease_count"]
)
# ── All-time scope (no date constraint) ──────────────────────────────────────
def test_all_sessions_alltime_matches(world):
stats = compute_stats(None)
assert (
_count(stats_links.all_sessions("Alltime"), Session) == stats["total_sessions"]
)
def test_finished_alltime_matches_backlog(world):
stats = compute_stats(None)
# all-time backlog_decrease_count == all-time finished count
assert (
_count(stats_links.purchases_backlog_decrease("Alltime"), Purchase)
== stats["backlog_decrease_count"]
)