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:
@@ -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=(today−6)_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.
|
||||||
Reference in New Issue
Block a user