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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3
This commit is contained in:
2026-06-21 11:14:58 +02:00
parent 5a4f6bce45
commit 69f8d441eb
@@ -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).