docs(spec): programmatic filter links for issue #56

Design for a reverse()-style filter_url() helper plus navbar 'today' /
'last 7 days' links. Stats-table and view_game-table links deferred to a
follow-up issue.

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 07:50:25 +02:00
parent b7d667a07f
commit 260169b521
@@ -0,0 +1,204 @@
# Issue #56 — Programmatic way of defining filters (filter links)
**Date:** 2026-06-21
**Issue:** https://github.com/KucharczykL/timetracker/issues/56
## Problem
Filters can currently only be built through the UI. There is no Python-level way
to construct a link to a filtered list view, so places that *should* link to a
filtered list cannot. The issue cites three call sites:
1. Navbar playtime totals ("today" / "last 7 days") — should link to the matching
filtered session list.
2. Stats-page tables — rows should link to filtered results.
3. `view_game` tables — should link to filtered list views.
The acceptance criteria are:
- A programmatic way in Python to create a link to a combination of filters
(analogous to Django's `reverse()`).
- Clickable links for the navbar playtime statistics.
## Scope
**In scope (this work):**
- The core `filter_url()` helper.
- The date-range filtering capability the navbar links require.
- Wiring the navbar "today" / "last 7 days" totals as links.
**Out of scope — filed as a follow-up issue:**
- Stats-page table links.
- `view_game` table links.
These are deferred deliberately to keep this change small. They both build
directly on `filter_url()`, so they are pure consumers of this work. See
"Follow-up issue" below for the drafted text.
## Current state (as investigated)
- Filters are `@dataclass` subclasses of `OperatorFilter` in `games/filters.py`
(`GameFilter`, `SessionFilter`, `PurchaseFilter`, `PlayEventFilter`,
`DeviceFilter`, `PlatformFilter`). Each is built from typed criterion objects
defined in `common/criteria.py`.
- Serialization helpers in `common/criteria.py`: `filter_to_json(f)`
`json.dumps(f.to_json())`; `filter_from_json(cls, json_str)` → filter instance.
- List views read `request.GET.get("filter")` (a URL-encoded JSON string),
deserialize via `parse_*_filter()`, and apply `.to_q()` to the queryset.
- List view URL names: `games:list_games`, `games:list_sessions`,
`games:list_purchases`, `games:list_playevents`, `games:list_devices`,
`games:list_platforms`.
- `games/views/filter_presets.py` already hand-rolls the URL-building pattern:
`f"{reverse(f'games:list_{mode}')}?filter={quote(filter_json)}"`. There is **no
shared helper** — this work introduces one.
- The navbar playtime totals are produced by the `model_counts` context processor
(`games/views/general.py`) as formatted strings (`today_played`,
`last_7_played`) and rendered by `NavbarPlaytime()` (`common/layout.py`). The
component is also refreshed out-of-band after session changes
(`games/views/session.py:311`), so any new data must flow through both paths.
- `SessionFilter.timestamp_start` / `timestamp_end` are typed as
`StringCriterion`, which supports only EQUALS / NOT_EQUALS / INCLUDES /
EXCLUDES / regex / null — **no `GREATER_THAN` / `LESS_THAN` / `BETWEEN`**. So
date-range filtering on session timestamps is not currently expressible. These
fields are **not** exposed in the filter-bar UI
(`common/components/filters.py` has no reference to them), so their criterion
type can be changed safely.
## Design
### Component 1 — `filter_url()` (the "`reverse()` for filters")
A single helper in `games/filters.py`, symmetric with the existing
`parse_*_filter()` functions. It infers the target list view from the filter
object's *type*, so a filter can never be paired with a mismatched URL.
```python
_FILTER_LIST_URL = {
GameFilter: "games:list_games",
SessionFilter: "games:list_sessions",
PurchaseFilter: "games:list_purchases",
PlayEventFilter: "games:list_playevents",
DeviceFilter: "games:list_devices",
PlatformFilter: "games:list_platforms",
}
def filter_url(filter_obj: OperatorFilter, **extra_params: str) -> str:
"""Build a URL to the filtered list view for ``filter_obj``.
The target view is inferred from the filter's type. ``extra_params`` are
merged into the query string (e.g. ``sort``, ``page``)."""
url_name = _FILTER_LIST_URL[type(filter_obj)]
params = {"filter": filter_to_json(filter_obj), **extra_params}
return f"{reverse(url_name)}?{urlencode(params)}"
```
- Uses `django.utils.http.urlencode` (already imported in `common/utils.py`),
which URL-encodes the JSON value correctly.
- `**extra_params` leaves room for `sort` / `page` later without being required
now.
- `filter_presets.py` can adopt this helper later; not required for this change.
### Component 2 — date-range filtering on session timestamps
**Decision: switch `SessionFilter.timestamp_start` / `timestamp_end` to
`DateCriterion` and apply them via Django's `__date` lookup.**
Rationale (the alternative was a new "relative date" criterion type):
- `DateCriterion` already exists and supports `GREATER_THAN` / `LESS_THAN` /
`BETWEEN` — no new criterion semantics to invent.
- The navbar link is server-rendered and regenerated on every request, so
encoding concrete dates is correct, reproducible, and shareable. A rolling
"relative date" concept is unnecessary machinery for this scope (YAGNI).
- The serialized JSON shape (`value`, `modifier`, optional `value2`) is
backward-compatible with any existing `StringCriterion`-shaped data for these
fields, and the fields are not in the UI, so the type change is low-risk.
Changes in `SessionFilter`:
- Field annotations: `timestamp_start: DateCriterion | None`,
`timestamp_end: DateCriterion | None`.
- In `to_q()`, target the date lookup so a date compares correctly against the
datetime column:
```python
if self.timestamp_start is not None:
q &= self.timestamp_start.to_q("timestamp_start__date")
if self.timestamp_end is not None:
q &= self.timestamp_end.to_q("timestamp_end__date")
```
`DateCriterion.to_q("timestamp_start__date")` produces
`timestamp_start__date__gte=…` etc., which is valid.
The two navbar filters expressed with this:
- **Today:** `SessionFilter(timestamp_start=DateCriterion(value=today_iso,
modifier=Modifier.EQUALS))` → `timestamp_start__date = today`.
- **Last 7 days:** `SessionFilter(timestamp_start=DateCriterion(
value=(today6)_iso, value2=today_iso, modifier=Modifier.BETWEEN))` →
7 calendar days inclusive (today and the previous six).
### Component 3 — navbar wiring (and a consistency fix)
- `model_counts` (`games/views/general.py`) computes `today_url` and
`last_7_url` with `filter_url(SessionFilter(...))` and adds them to its
returned dict alongside the existing formatted totals.
- `NavbarPlaytime()` (`common/layout.py`) gains `today_url` / `last_7_url`
parameters and wraps each total string in an `<a href>`. The out-of-band
refresh call in `games/views/session.py:311` passes the new URLs too.
**Deliberate behavior change — align the "last 7 days" total with its link.**
The displayed "last 7 days" total currently uses a *rolling 168-hour* window
(`timestamp_start__gte = now timedelta(days=7)`), which would not match a
calendar-day link. To keep the number and the list it links to consistent, the
total is changed to the same **calendar-day boundary** used by the link
(`timestamp_start__date >= today 6 days`, i.e. 7 calendar days inclusive). The
"today" total already matches (`[midnight, next midnight)` ≡ `__date = today`),
so only the 7-day computation changes.
## Testing
- **`filter_url()`** (unit): returns the correct path for each filter type; the
`filter` query param is the URL-encoded `filter_to_json(filter_obj)`; extra
params are merged; the produced URL round-trips through `parse_session_filter`
to an equivalent filter.
- **Date filtering** (unit/db): sessions started today / 3 days ago / 10 days ago
fall into the correct buckets for the "today" and "last 7 days" filters via
`SessionFilter.to_q()`.
- **Navbar** (render): `NavbarPlaytime` renders anchors with the expected
`href`s; a smoke test confirms the linked URLs return 200 and apply the filter.
## Follow-up issue (to be filed)
**Title:** Wire programmatic filter links into stats tables and the game-detail page
**Body:**
> Issue #56 introduced `filter_url()` (a `reverse()`-style helper that builds a
> URL to a filtered list view from a filter object) and used it for the navbar
> playtime links. Two of the call sites named in #56 were deferred and should now
> be wired up using that helper:
>
> - **Stats-page tables** (`games/views/stats_content.py`): make table rows link
> to the corresponding filtered list (e.g. a game's playtime row → sessions for
> that game; a finished-games row → that game).
> - **`view_game` tables** (`games/views/game.py`): the sessions / purchases /
> playevents sections should offer "view all … for this game" links to the
> filtered list views.
>
> All of these are pure consumers of `filter_url()`; no new filter machinery is
> required. Decide per table which filter each link should encode.
`gh` is not installed in the working environment, so this is provided as
ready-to-paste text; it can be filed manually or via `gh` once available.
## Notes / risks
- Changing the criterion type of `timestamp_start` / `timestamp_end` affects how
any persisted `FilterPreset` containing those fields deserializes (it will now
resolve to `DateCriterion`). The JSON shape is compatible and these fields are
not surfaced in the UI, so the risk is minimal, but it is worth a grep for any
saved presets referencing them before merge.