From 69f8d441eb95e75594e2c678e81f5a50d915f222 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 11:14:58 +0200 Subject: [PATCH 1/7] docs(spec): stats-page filtered links for issue #65 Tier 1 (game/platform/month row links, session/games/purchased/refunded count links, list capping + view-all, remove All purchases list) ships independently. Tier 2 (finished/dropped/unfinished links) is gated on the PlayEventFilter date-range prereq (#67). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3 --- ...issue-65-stats-page-filter-links-design.md | 195 ++++++++++++++++++ 1 file changed, 195 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md diff --git a/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md b/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md new file mode 100644 index 0000000..8528c8d --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md @@ -0,0 +1,195 @@ +# 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` + +## 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 — gated on prereq #67** (needs "finished in year" filtering). Specified + here, implemented once #67 lands. + +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** — not linked (its definition — purchased in a prior year, + finished this year — has no clean single-filter expression; out of scope). + +### 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 — gated on prereq #67 + +After #67 makes `PlayEventFilter.ended` a `DateCriterion` applied via `__date`, +"finished in year" becomes 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. + +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. + +## 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 (Tier 1 + now; Tier 2 after #67). +- **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** (after #67 merges): finished/dropped/unfinished count and "view all" + links. + +If #67 lands first, both tiers can ship together. + +## Out of scope + +- Backlog-decrease count link. +- The `view_game` table links (#66, the other #61 sub-issue). +- Sorting the "Games by playtime" view-all target by playtime (the filtered list + is correct; sort order is best-effort polish via `filter_url(..., sort=...)` if + the list view supports it). From b26a92248bdedc3f6281f7d0d4f1ee44879ffb65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 11:19:42 +0200 Subject: [PATCH 2/7] docs(spec): scope backlog-decrease into #65 Tier 2; add sorting parity (#68) Backlog decrease is expressible with the existing prereq #67 (no new machinery), so it moves into Tier 2. List-view sort support is filed as non-prereq #68; view-all links carry a TODO at each site until it lands. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3 --- ...issue-65-stats-page-filter-links-design.md | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md b/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md index 8528c8d..509bc75 100644 --- a/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md +++ b/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md @@ -87,8 +87,9 @@ In `_purchases_table`: - **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** — not linked (its definition — purchased in a prior year, - finished this year — has no clean single-filter expression; out of scope). +- **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" @@ -135,6 +136,12 @@ composes the Tier-2 targets. Reference semantics (from `stats_data.py`): (`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. @@ -152,6 +159,23 @@ 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) + +The stats lists are sorted (Games by playtime → playtime desc; the Finished lists +→ finish date; etc.), but the list views hardcode their `order_by` and ignore any +sort param (`game.py:59`, `session.py:122`, `purchase.py:122`). So a "view all" +link lands on the right *set* but not the same *order*. + +This is handled by a separate, **non-prereq** issue (#68: honor a `sort` query +param on the list views). #65 ships without waiting on it: + +- Each "view all" link that has a non-default sort on the stats page is built + with the intended `filter_url(..., sort=...)` param **and** carries a `TODO` + comment referencing #68, stating the linked list won't preserve the stats + order until #68 lands. Sites: Games by playtime, Finished, Finished (YYYY), + Bought & Finished (YYYY), Unfinished purchases. +- The links are correct as *filtered sets* regardless; only ordering differs. + ## Exact-match requirement A count or "view all" link must land on a list whose total equals the displayed @@ -181,15 +205,13 @@ exactly is reported (not silently shipped with a wrong number). 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** (after #67 merges): finished/dropped/unfinished count and "view all" - links. +2. **Tier 2** (after #67 merges): finished/dropped/unfinished/backlog-decrease + count and "view all" links. If #67 lands first, both tiers can ship together. ## Out of scope -- Backlog-decrease count link. - The `view_game` table links (#66, the other #61 sub-issue). -- Sorting the "Games by playtime" view-all target by playtime (the filtered list - is correct; sort order is best-effort polish via `filter_url(..., sort=...)` if - the list view supports it). +- Implementing list-view sort support (#68) — #65 only passes the `sort` param and + leaves a `TODO` at each affected link site (see "Sorting parity"). From 4508a6ecbb2422256a0c783277fe95652e84e8cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 13:01:41 +0200 Subject: [PATCH 3/7] docs(spec): mark #67 prereq merged; Tier 2 unblocked #67 (PR #69) landed: PlayEventFilter.ended/started are now DateCriterion (bare DateField lookup, not __date). Tier 2 can ship alongside Tier 1. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3 --- ...-issue-65-stats-page-filter-links-design.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md b/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md index 509bc75..b16bdd0 100644 --- a/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md +++ b/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md @@ -3,6 +3,9 @@ **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 @@ -20,8 +23,8 @@ is an int) or all-time (`compute_stats(None)`, `data["year"] == "Alltime"`). Two tiers, split by what the filter system can express **today**: - **Tier 1 — implemented in this issue** (no new filter machinery). -- **Tier 2 — gated on prereq #67** (needs "finished in year" filtering). Specified - here, implemented once #67 lands. +- **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. @@ -117,8 +120,8 @@ entry point is the "Total purchased" count link. The `StatsData` key may remain ### D. Tier 2 — gated on prereq #67 -After #67 makes `PlayEventFilter.ended` a `DateCriterion` applied via `__date`, -"finished in year" becomes expressible and the chain +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`): @@ -205,10 +208,11 @@ exactly is reported (not silently shipped with a wrong number). 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** (after #67 merges): finished/dropped/unfinished/backlog-decrease - count and "view all" links. +2. **Tier 2** (unblocked — #67 is merged): finished/dropped/unfinished/ + backlog-decrease count and "view all" links. -If #67 lands first, both tiers can ship together. +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 From d81e2691cd61ee276fd41d9eaf32cd911023ebe9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 13:05:30 +0200 Subject: [PATCH 4/7] docs(spec): tidy remaining 'gated on #67' phrasing Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3 --- .../2026-06-21-issue-65-stats-page-filter-links-design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md b/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md index b16bdd0..e19611a 100644 --- a/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md +++ b/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md @@ -118,7 +118,7 @@ sites). 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 — gated on prereq #67 +### 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 @@ -196,8 +196,8 @@ exactly is reported (not silently shipped with a wrong number). `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 (Tier 1 - now; Tier 2 after #67). + `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. From 9bc69b939bc90a03ccc052dbbc3c4310113d3e3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 15:05:27 +0200 Subject: [PATCH 5/7] docs(spec): #68 merged; view-all links pass sort= for parity (no TODOs) #68 (PR #78) added games/sorting.py with playtime/finished sort keys, so the stats 'view all' links achieve order parity by passing ?sort= instead of carrying TODOs. Both prereqs (#67, #68) are now merged. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3 --- ...issue-65-stats-page-filter-links-design.md | 41 ++++++++++++------- 1 file changed, 26 insertions(+), 15 deletions(-) diff --git a/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md b/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md index e19611a..01635b5 100644 --- a/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md +++ b/docs/superpowers/specs/2026-06-21-issue-65-stats-page-filter-links-design.md @@ -162,22 +162,33 @@ 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) +## Sorting parity (#68 — merged) -The stats lists are sorted (Games by playtime → playtime desc; the Finished lists -→ finish date; etc.), but the list views hardcode their `order_by` and ignore any -sort param (`game.py:59`, `session.py:122`, `purchase.py:122`). So a "view all" -link lands on the right *set* but not the same *order*. +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: -This is handled by a separate, **non-prereq** issue (#68: honor a `sort` query -param on the list views). #65 ships without waiting on it: +- `GAME_SORTS`: `playtime` (`Sum(sessions__duration_total)`), `finished` + (`Max(playevents__ended)`). +- `PURCHASE_SORTS`: `finished` (`Max(games__playevents__ended)`), `purchased`, + `price`, `name`. -- Each "view all" link that has a non-default sort on the stats page is built - with the intended `filter_url(..., sort=...)` param **and** carries a `TODO` - comment referencing #68, stating the linked list won't preserve the stats - order until #68 lands. Sites: Games by playtime, Finished, Finished (YYYY), - Bought & Finished (YYYY), Unfinished purchases. -- The links are correct as *filtered sets* regardless; only ordering differs. +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 @@ -217,5 +228,5 @@ shippable if a smaller first PR is preferred. ## Out of scope - The `view_game` table links (#66, the other #61 sub-issue). -- Implementing list-view sort support (#68) — #65 only passes the `sort` param and - leaves a `TODO` at each affected link site (see "Sorting parity"). +- List-view sort support itself (#68, merged) — #65 only *passes* the `sort` param + to the existing keys (see "Sorting parity"). From 57980c407f72dea7045bf598fdcfb7cb1f808186 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 15:15:22 +0200 Subject: [PATCH 6/7] feat(stats): filter-link builders with exact-match parity tests (#65) stats_links.py: pure functions returning a filter per stats category (sessions for game/platform/month, games played, total/refunded/dropped/ unfinished/finished/finished-released/bought-and-finished/backlog purchases). Each is verified to produce a queryset whose count equals the stat it links from, on single-game data (the modeling norm). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3 --- games/views/stats_links.py | 200 ++++++++++++++++++++++++++++++++++ tests/test_stats_links.py | 212 +++++++++++++++++++++++++++++++++++++ 2 files changed, 412 insertions(+) create mode 100644 games/views/stats_links.py create mode 100644 tests/test_stats_links.py diff --git a/games/views/stats_links.py b/games/views/stats_links.py new file mode 100644 index 0000000..0211c97 --- /dev/null +++ b/games/views/stats_links.py @@ -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 diff --git a/tests/test_stats_links.py b/tests/test_stats_links.py new file mode 100644 index 0000000..69fd2ed --- /dev/null +++ b/tests/test_stats_links.py @@ -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"] + ) From 90c611377208103905625e7682067cb03416feb6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 15:26:33 +0200 Subject: [PATCH 7/7] 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) Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3 --- common/components/filters.py | 16 ++- games/views/stats_content.py | 195 +++++++++++++++++++++++++----- games/views/stats_data.py | 13 +- tests/test_filter_bar_labels.py | 18 +++ tests/test_stats_content_links.py | 115 ++++++++++++++++++ 5 files changed, 323 insertions(+), 34 deletions(-) create mode 100644 tests/test_filter_bar_labels.py create mode 100644 tests/test_stats_content_links.py diff --git a/common/components/filters.py b/common/components/filters.py index dc8784f..e8527ea 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -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: diff --git a/games/views/stats_content.py b/games/views/stats_content.py index dd84aec..8a59c83 100644 --- a/games/views/stats_content.py +++ b/games/views/stats_content.py @@ -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, diff --git a/games/views/stats_data.py b/games/views/stats_data.py index 6c635f9..82c3793 100644 --- a/games/views/stats_data.py +++ b/games/views/stats_data.py @@ -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") ) diff --git a/tests/test_filter_bar_labels.py b/tests/test_filter_bar_labels.py new file mode 100644 index 0000000..ba29cac --- /dev/null +++ b/tests/test_filter_bar_labels.py @@ -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")] diff --git a/tests/test_stats_content_links.py b/tests/test_stats_content_links.py new file mode 100644 index 0000000..3252867 --- /dev/null +++ b/tests/test_stats_content_links.py @@ -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