From 260169b5213f3f4ffd0dfa6c24113e426fa01586 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 07:50:25 +0200 Subject: [PATCH 1/3] 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) Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3 --- ...sue-56-programmatic-filter-links-design.md | 204 ++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-21-issue-56-programmatic-filter-links-design.md diff --git a/docs/superpowers/specs/2026-06-21-issue-56-programmatic-filter-links-design.md b/docs/superpowers/specs/2026-06-21-issue-56-programmatic-filter-links-design.md new file mode 100644 index 0000000..c095831 --- /dev/null +++ b/docs/superpowers/specs/2026-06-21-issue-56-programmatic-filter-links-design.md @@ -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 ``. 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. From b9545a780bdaf7f0cb6a40670b54c7e5ad25097b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 08:38:36 +0200 Subject: [PATCH 2/3] docs(spec): add OperatorFilter.where() ergonomic constructor Adds a Django-.filter()-style where(**lookups) classmethod (Component 1b) to issue #56 spec. Additive: keeps the typed explicit constructor intact. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3 --- ...sue-56-programmatic-filter-links-design.md | 88 ++++++++++++++++++- 1 file changed, 86 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-06-21-issue-56-programmatic-filter-links-design.md b/docs/superpowers/specs/2026-06-21-issue-56-programmatic-filter-links-design.md index c095831..152610e 100644 --- a/docs/superpowers/specs/2026-06-21-issue-56-programmatic-filter-links-design.md +++ b/docs/superpowers/specs/2026-06-21-issue-56-programmatic-filter-links-design.md @@ -25,6 +25,7 @@ The acceptance criteria are: **In scope (this work):** - The core `filter_url()` helper. +- The `OperatorFilter.where()` ergonomic constructor. - The date-range filtering capability the navbar links require. - Wiring the navbar "today" / "last 7 days" totals as links. @@ -100,6 +101,81 @@ def filter_url(filter_obj: OperatorFilter, **extra_params: str) -> str: now. - `filter_presets.py` can adopt this helper later; not required for this change. +### Component 1b — `OperatorFilter.where()` (ergonomic construction) + +Building filters via the explicit constructor is verbose, because each criterion +must be wrapped and a `Modifier` imported: + +```python +GameFilter( + purchase_count=IntCriterion(value=1, modifier=Modifier.GREATER_THAN), + playtime_hours=IntCriterion(modifier=Modifier.IS_NULL), +) +``` + +Add a `where(**lookups)` classmethod on `OperatorFilter` (so every filter type +inherits it) accepting Django-`QuerySet.filter()`-style `field__modifier=value` +lookups: + +```python +GameFilter.where(purchase_count__gt=1, playtime_hours__isnull=True) + +# combined with filter_url(): +filter_url(GameFilter.where(purchase_count__gt=1, playtime_hours__isnull=True)) + +# the navbar filters become: +filter_url(SessionFilter.where(timestamp_start=today_iso)) +filter_url(SessionFilter.where(timestamp_start__between=(week_ago_iso, today_iso))) +``` + +How it works (no new architecture — it builds the same dataclass instances): + +1. Split each kwarg into `(field_name, suffix)` on the last `__`. +2. Resolve the field's criterion class from its dataclass annotation, reusing the + logic `from_json` already has (`common/criteria.py:439-473`). Factor that + resolution into a shared helper (e.g. `_criterion_class_for(cls, field)`) so + `from_json` and `where()` cannot drift — a small de-duplication bonus. +3. Map the suffix to a `Modifier` (see table); no suffix → the criterion type's + natural default (`EQUALS` for scalar/string/bool, `INCLUDES` for the set + criteria `MultiCriterion` / `ChoiceCriterion`). +4. Build the concrete criterion: scalar → `value`; a 2-tuple → `value` / `value2` + (for `between` / `not_between`); a list → the set criterion's `value`. +5. Return a normal filter instance. + +Suffix → `Modifier` map: + +| suffix | Modifier | +|---------------|-----------------| +| *(none)* | `EQUALS` (scalar/string/bool) / `INCLUDES` (set) | +| `gt` | `GREATER_THAN` | +| `lt` | `LESS_THAN` | +| `ne` | `NOT_EQUALS` | +| `between` | `BETWEEN` (value is a 2-tuple) | +| `not_between` | `NOT_BETWEEN` (value is a 2-tuple) | +| `in` | `INCLUDES` (set) | +| `exclude` | `EXCLUDES` (set) | +| `all` | `INCLUDES_ALL` (set) | +| `contains` | `INCLUDES` (string `icontains`) | +| `regex` | `MATCHES_REGEX` | +| `isnull` | `IS_NULL` (value ignored) | +| `notnull` | `NOT_NULL` (value ignored) | + +Design decisions: + +- **Purely additive.** The explicit constructor, `to_q()`, and serialization are + unchanged. `where()` is chosen over a custom `__init__` precisely to keep the + explicit `GameFilter(name=StringCriterion(...))` form **fully statically typed** + (a `@dataclass(init=False)` + `**kwargs` constructor would have erased that). +- **Real field names, no aliasing.** Lookups use the actual dataclass field names + (e.g. `playtime_hours`, not a prettier `playtime`); no alias layer to maintain. +- **Fail loud.** An unknown field name or an unknown/!type-incompatible suffix + raises a clear `ValueError`/`TypeError` rather than silently producing an empty + filter. The lookup form is dynamic (like Django's `.filter()`), so this runtime + validation replaces static checking for that form only. +- **Scope.** `where()` covers the common flat-AND case (the verbose pain point). + `AND` / `OR` / `NOT` nesting continues to use the explicit constructor, which + reads fine and is rare. + ### Component 2 — date-range filtering on session timestamps **Decision: switch `SessionFilter.timestamp_start` / `timestamp_end` to @@ -144,8 +220,8 @@ The two navbar filters expressed with this: ### 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. + `last_7_url` with `filter_url(SessionFilter.where(...))` (see Component 1b) 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 ``. The out-of-band refresh call in `games/views/session.py:311` passes the new URLs too. @@ -165,6 +241,14 @@ so only the 7-day computation changes. `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. +- **`where()`** (unit): each suffix maps to the right `Modifier`; the resolved + criterion class matches the field annotation across criterion types + (`Int`/`String`/`Bool`/`Date`/`Multi`/`Choice`); `between` consumes a 2-tuple + into `value`/`value2`; no-suffix defaults to `EQUALS` for scalars and + `INCLUDES` for set criteria; `isnull`/`notnull` ignore the value; + `GameFilter.where(purchase_count__gt=1, playtime_hours__isnull=True)` produces a + filter equal to the explicit construction and yields the same `to_q()`; an + unknown field or suffix raises. - **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()`. From 3fd02bbcf99f2d5ab808d0a0c16b782e64d68f61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 08:53:06 +0200 Subject: [PATCH 3/3] feat(filters): programmatic filter links + navbar playtime links (#56) Add filter_url(), a reverse()-style helper that builds a URL to a filtered list view from a filter object (target inferred from the filter type). Add OperatorFilter.where(**lookups), a Django-.filter()-style ergonomic constructor that resolves each field's criterion class from its annotation (shared with from_json via _criterion_class_for, removing duplication). Make SessionFilter.timestamp_start/timestamp_end DateCriterion applied via the __date lookup, so date ranges over the timestamp columns are expressible. Wire the navbar 'today' / 'last 7 days' totals as links to the matching filtered session lists, and align the 'last 7 days' total to the same calendar-day window so the number matches the list it links to. Stats-table and game-detail links remain a follow-up (see spec). Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RF5L4HtbcykTfY9YUYGds3 --- common/criteria.py | 108 +++++++++++++++++++++++++++--- common/layout.py | 34 ++++++++-- games/filters.py | 45 +++++++++++-- games/views/general.py | 23 ++++++- games/views/session.py | 8 ++- tests/test_filter_url.py | 38 +++++++++++ tests/test_filter_where.py | 79 ++++++++++++++++++++++ tests/test_navbar_playtime.py | 35 ++++++++++ tests/test_session_date_filter.py | 54 +++++++++++++++ 9 files changed, 402 insertions(+), 22 deletions(-) create mode 100644 tests/test_filter_url.py create mode 100644 tests/test_filter_where.py create mode 100644 tests/test_session_date_filter.py diff --git a/common/criteria.py b/common/criteria.py index 9df7081..b0d17ac 100644 --- a/common/criteria.py +++ b/common/criteria.py @@ -379,6 +379,57 @@ class ChoiceCriterion(_SetCriterion): F = TypeVar("F", bound="OperatorFilter") +# Maps criterion class names (as they appear in dataclass annotations) to the +# concrete class. Shared by from_json() and where() so the two construction +# paths resolve field types identically and cannot drift. +_CRITERION_TYPES: dict[str, type[_Criterion]] = { + "StringCriterion": StringCriterion, + "IntCriterion": IntCriterion, + "FloatCriterion": FloatCriterion, + "DateCriterion": DateCriterion, + "BoolCriterion": BoolCriterion, + "MultiCriterion": MultiCriterion, + "ChoiceCriterion": ChoiceCriterion, +} + + +def _criterion_class_for( + cls: type["OperatorFilter"], field_name: str +) -> type[_Criterion] | None: + """Resolve the criterion class declared for ``field_name`` on a filter, or + None if the field is absent or isn't a criterion field.""" + for dataclass_field in dc_fields(cls): + if dataclass_field.name != field_name: + continue + field_type = dataclass_field.type + if isinstance(field_type, str): + # e.g. "StringCriterion | None" → "StringCriterion" + field_type = field_type.split("|")[0].strip() + return _CRITERION_TYPES.get(field_type) + if isinstance(field_type, type) and issubclass(field_type, _Criterion): + return field_type + return None + return None + + +# Lookup suffix → Modifier. A missing suffix defaults per criterion type +# (EQUALS for scalars, INCLUDES for set criteria) and is handled in where(). +_SUFFIX_MODIFIER: dict[str, Modifier] = { + "gt": Modifier.GREATER_THAN, + "lt": Modifier.LESS_THAN, + "ne": Modifier.NOT_EQUALS, + "between": Modifier.BETWEEN, + "not_between": Modifier.NOT_BETWEEN, + "in": Modifier.INCLUDES, + "exclude": Modifier.EXCLUDES, + "all": Modifier.INCLUDES_ALL, + "contains": Modifier.INCLUDES, + "regex": Modifier.MATCHES_REGEX, + "isnull": Modifier.IS_NULL, + "notnull": Modifier.NOT_NULL, +} + + @dataclass class OperatorFilter: """Mixin providing AND/OR/NOT composition for entity filter types. @@ -394,6 +445,53 @@ class OperatorFilter: ... """ + @classmethod + def where(cls: type[F], **lookups: Any) -> F: + """Build a filter from Django-``QuerySet.filter()``-style lookups. + + Each keyword is ``field__suffix=value`` (or ``field=value`` for the + default modifier). The criterion class is resolved from the field's + annotation, so the same value can target an int / string / date / set + field without naming the criterion type:: + + GameFilter.where(year_released__gt=2010, status=["f", "p"]) + + Suffix → modifier follows ``_SUFFIX_MODIFIER``; a missing suffix means + EQUALS for scalars and INCLUDES for set criteria. ``between`` / + ``not_between`` consume a 2-tuple; ``isnull`` / ``notnull`` ignore the + value. Unknown fields or suffixes raise ``TypeError``. + """ + field_criteria: dict[str, Any] = {} + for lookup, value in lookups.items(): + field_name, _, suffix = lookup.rpartition("__") + if not field_name: + field_name, suffix = lookup, "" + + criterion_class = _criterion_class_for(cls, field_name) + if criterion_class is None: + raise TypeError(f"{cls.__name__} has no filter field {field_name!r}") + + is_set_criterion = issubclass(criterion_class, _SetCriterion) + if suffix == "": + modifier = Modifier.INCLUDES if is_set_criterion else Modifier.EQUALS + elif suffix in _SUFFIX_MODIFIER: + modifier = _SUFFIX_MODIFIER[suffix] + else: + raise TypeError(f"Unknown lookup suffix {suffix!r} on {field_name!r}") + + criterion_arguments: dict[str, Any] = {"modifier": modifier} + if suffix in ("isnull", "notnull"): + pass # presence test ignores the value + elif modifier in (Modifier.BETWEEN, Modifier.NOT_BETWEEN): + lower_bound, upper_bound = value + criterion_arguments["value"] = lower_bound + criterion_arguments["value2"] = upper_bound + else: + criterion_arguments["value"] = value + + field_criteria[field_name] = criterion_class(**criterion_arguments) + return cls(**field_criteria) + def sub_filter(self) -> OperatorFilter | None: """Return the first non-None of AND / OR / NOT.""" for attr in ("AND", "OR", "NOT"): @@ -436,15 +534,7 @@ class OperatorFilter: if data is None or not isinstance(data, dict): return None # Resolve criterion class names to actual types - criterion_types: dict[str, type[_Criterion]] = { - "StringCriterion": StringCriterion, - "IntCriterion": IntCriterion, - "FloatCriterion": FloatCriterion, - "DateCriterion": DateCriterion, - "BoolCriterion": BoolCriterion, - "MultiCriterion": MultiCriterion, - "ChoiceCriterion": ChoiceCriterion, - } + criterion_types = _CRITERION_TYPES kwargs: dict[str, Any] = {} for f in dc_fields(cls): if f.name not in data: diff --git a/common/layout.py b/common/layout.py index 8a0f255..191f298 100644 --- a/common/layout.py +++ b/common/layout.py @@ -188,12 +188,25 @@ def _main_script(mastered: bool) -> str: def NavbarPlaytime( - today_played: str, last_7_played: str, *, oob: bool = False + today_played: str, + last_7_played: str, + *, + today_url: str | None = None, + last_7_url: str | None = None, + oob: bool = False, ) -> "Node": """The navbar 'Today · Last 7 days' totals. Carries a stable id so - htmx endpoints can refresh it out-of-band after a session change.""" + htmx endpoints can refresh it out-of-band after a session change. + + When ``today_url`` / ``last_7_url`` are given, each total links to the + matching filtered session list.""" from common.components import Safe + def total(text: str, url: str | None) -> str: + if not url: + return text + return f'{text}' + oob_attr = ' hx-swap-oob="true"' if oob else "" return Safe( f'" + f"{total(today_played, today_url)}" + '·' + f"{total(last_7_played, last_7_url)}" ) def Navbar( - *, today_played: str, last_7_played: str, current_year: int, csrf_token: str + *, + today_played: str, + last_7_played: str, + today_url: str | None = None, + last_7_url: str | None = None, + current_year: int, + csrf_token: str, ) -> "Node": """Top navigation bar. @@ -244,7 +264,7 @@ def Navbar( - {NavbarPlaytime(today_played, last_7_played)} + {NavbarPlaytime(today_played, last_7_played, today_url=today_url, last_7_url=last_7_url)}
  • Home
  • @@ -330,6 +350,8 @@ def Page( navbar = Navbar( today_played=counts["today_played"], last_7_played=counts["last_7_played"], + today_url=counts["today_url"], + last_7_url=counts["last_7_url"], current_year=year, csrf_token=get_token(request), ) diff --git a/games/filters.py b/games/filters.py index 8b6d25e..8bd22e9 100644 --- a/games/filters.py +++ b/games/filters.py @@ -14,6 +14,8 @@ from __future__ import annotations from dataclasses import dataclass from django.db.models import Q +from django.urls import reverse +from django.utils.http import urlencode from common.criteria import ( BoolCriterion, @@ -26,6 +28,7 @@ from common.criteria import ( OperatorFilter, StringCriterion, filter_from_json, + filter_to_json, ) # ── FindFilter (sort / pagination) ───────────────────────────────────────── @@ -438,8 +441,8 @@ class SessionFilter(OperatorFilter): duration_manual_hours: IntCriterion | None = None duration_calculated_hours: IntCriterion | None = None is_active: BoolCriterion | None = None # timestamp_end IS NULL - timestamp_start: StringCriterion | None = None # date string - timestamp_end: StringCriterion | None = None # date string + timestamp_start: DateCriterion | None = None # date, compared via __date + timestamp_end: DateCriterion | None = None # date, compared via __date is_manual: BoolCriterion | None = None # duration_manual > 0 created_at: StringCriterion | None = None @@ -519,9 +522,10 @@ class SessionFilter(OperatorFilter): else: q &= Q(timestamp_end__isnull=False) if self.timestamp_start is not None: - q &= self.timestamp_start.to_q("timestamp_start") + # Compare the date portion so a date matches the datetime column. + q &= self.timestamp_start.to_q("timestamp_start__date") if self.timestamp_end is not None: - q &= self.timestamp_end.to_q("timestamp_end") + q &= self.timestamp_end.to_q("timestamp_end__date") if self.is_manual is not None: if self.is_manual.value: q &= ~Q(duration_manual=timedelta(0)) @@ -977,3 +981,36 @@ def parse_platform_filter(json_str: str) -> PlatformFilter | None: def parse_playevent_filter(json_str: str) -> PlayEventFilter | None: return filter_from_json(PlayEventFilter, json_str) + + +# ── URL building (the "reverse() for filters") ───────────────────────────── + + +_FILTER_LIST_URL: dict[type[OperatorFilter], str] = { + 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, so a filter can never be + paired with a mismatched list URL. ``extra_params`` are merged into the + query string (e.g. ``sort``, ``page``). + + Usage: + filter_url(GameFilter.where(purchase_count__gt=1)) + """ + try: + url_name = _FILTER_LIST_URL[type(filter_obj)] + except KeyError: + raise TypeError( + f"No list view registered for {type(filter_obj).__name__}" + ) from None + params = {"filter": filter_to_json(filter_obj), **extra_params} + return f"{reverse(url_name)}?{urlencode(params)}" diff --git a/games/views/general.py b/games/views/general.py index 000fc0c..8fba285 100644 --- a/games/views/general.py +++ b/games/views/general.py @@ -15,6 +15,7 @@ from django.utils.timezone import now as timezone_now from common.layout import render_page from common.time import format_duration +from games.filters import SessionFilter, filter_url from games.models import Game, Platform, Purchase, Session from games.views.stats_content import stats_content from games.views.stats_data import compute_stats @@ -23,21 +24,37 @@ from games.views.stats_data import compute_stats # component, so Page() loads it automatically on the stats pages. -def model_counts(request: HttpRequest) -> dict[str, bool]: +def model_counts(request: HttpRequest) -> dict[str, Any]: now = timezone_now() # Use a contiguous [midnight, next midnight) range in the active timezone # instead of day/month/year extracts: a range filter can use an index on # timestamp_start, whereas the extracts force a per-row datetime function. + today = localtime(now).date() start_of_today = localtime(now).replace(hour=0, minute=0, second=0, microsecond=0) start_of_tomorrow = start_of_today + timedelta(days=1) + # "Last 7 days" is a calendar-day window (today plus the previous six) so the + # displayed total matches the list its navbar link points to. + start_of_window = start_of_today - timedelta(days=6) today_played = Session.objects.filter( timestamp_start__gte=start_of_today, timestamp_start__lt=start_of_tomorrow, ).aggregate(time=Sum(F("duration_total")))["time"] last_7_played = Session.objects.filter( - timestamp_start__gte=(now - timedelta(days=7)) + timestamp_start__gte=start_of_window, + timestamp_start__lt=start_of_tomorrow, ).aggregate(time=Sum(F("duration_total")))["time"] + today_iso = today.isoformat() + today_url = filter_url(SessionFilter.where(timestamp_start=today_iso)) + last_7_url = filter_url( + SessionFilter.where( + timestamp_start__between=( + (today - timedelta(days=6)).isoformat(), + today_iso, + ) + ) + ) + return { "game_available": Game.objects.exists(), "platform_available": Platform.objects.exists(), @@ -45,6 +62,8 @@ def model_counts(request: HttpRequest) -> dict[str, bool]: "session_count": Session.objects.exists(), "today_played": format_duration(today_played, "%H h %m m"), "last_7_played": format_duration(last_7_played, "%H h %m m"), + "today_url": today_url, + "last_7_url": last_7_url, } diff --git a/games/views/session.py b/games/views/session.py index de5bef0..a52c537 100644 --- a/games/views/session.py +++ b/games/views/session.py @@ -308,7 +308,13 @@ def _row_with_navbar(request: HttpRequest, session: Session) -> HttpResponse: counts = model_counts(request) fragment = Fragment( session_row(session, device_list, get_token(request)), - NavbarPlaytime(counts["today_played"], counts["last_7_played"], oob=True), + NavbarPlaytime( + counts["today_played"], + counts["last_7_played"], + today_url=counts["today_url"], + last_7_url=counts["last_7_url"], + oob=True, + ), ) return HttpResponse(str(fragment)) diff --git a/tests/test_filter_url.py b/tests/test_filter_url.py new file mode 100644 index 0000000..0c6a509 --- /dev/null +++ b/tests/test_filter_url.py @@ -0,0 +1,38 @@ +"""Tests for filter_url() — the reverse()-style helper that builds a URL to a +filtered list view from a filter object (issue #56).""" + +from urllib.parse import parse_qs, urlparse + +from django.urls import reverse + +from common.criteria import IntCriterion, Modifier, filter_to_json +from games.filters import ( + GameFilter, + PurchaseFilter, + SessionFilter, + filter_url, + parse_game_filter, +) + + +def test_filter_url_path_inferred_from_filter_type(): + assert urlparse(filter_url(GameFilter())).path == reverse("games:list_games") + assert urlparse(filter_url(SessionFilter())).path == reverse("games:list_sessions") + assert urlparse(filter_url(PurchaseFilter())).path == reverse( + "games:list_purchases" + ) + + +def test_filter_url_encodes_filter_json_that_round_trips(): + game_filter = GameFilter( + year_released=IntCriterion(value=2010, modifier=Modifier.GREATER_THAN) + ) + url = filter_url(game_filter) + query = parse_qs(urlparse(url).query) + assert query["filter"][0] == filter_to_json(game_filter) + assert parse_game_filter(query["filter"][0]).to_q() == game_filter.to_q() + + +def test_filter_url_merges_extra_params(): + query = parse_qs(urlparse(filter_url(GameFilter(), sort="name")).query) + assert query["sort"][0] == "name" diff --git a/tests/test_filter_where.py b/tests/test_filter_where.py new file mode 100644 index 0000000..fa6b7ce --- /dev/null +++ b/tests/test_filter_where.py @@ -0,0 +1,79 @@ +"""Tests for OperatorFilter.where() — Django-.filter()-style ergonomic +construction of filters (issue #56, Component 1b).""" + +import pytest + +from common.criteria import ( + BoolCriterion, + ChoiceCriterion, + IntCriterion, + Modifier, + MultiCriterion, + StringCriterion, +) +from games.filters import GameFilter + + +def test_no_suffix_defaults_to_equals_for_scalar(): + assert GameFilter.where(year_released=2010) == GameFilter( + year_released=IntCriterion(value=2010, modifier=Modifier.EQUALS) + ) + + +def test_no_suffix_defaults_to_includes_for_set_criterion(): + assert GameFilter.where(status=["f", "p"]) == GameFilter( + status=ChoiceCriterion(value=["f", "p"], modifier=Modifier.INCLUDES) + ) + + +def test_gt_suffix_maps_to_greater_than(): + assert GameFilter.where(year_released__gt=2010) == GameFilter( + year_released=IntCriterion(value=2010, modifier=Modifier.GREATER_THAN) + ) + + +def test_contains_suffix_maps_to_includes_for_string(): + assert GameFilter.where(name__contains="Zelda") == GameFilter( + name=StringCriterion(value="Zelda", modifier=Modifier.INCLUDES) + ) + + +def test_between_suffix_consumes_tuple_into_value_and_value2(): + assert GameFilter.where(year_released__between=(2010, 2020)) == GameFilter( + year_released=IntCriterion(value=2010, value2=2020, modifier=Modifier.BETWEEN) + ) + + +def test_isnull_suffix_ignores_value(): + assert GameFilter.where(playtime_hours__isnull=True) == GameFilter( + playtime_hours=IntCriterion(modifier=Modifier.IS_NULL) + ) + + +def test_bool_field_resolves_bool_criterion(): + assert GameFilter.where(mastered=True) == GameFilter( + mastered=BoolCriterion(value=True, modifier=Modifier.EQUALS) + ) + + +def test_multi_field_resolves_multi_criterion(): + assert GameFilter.where(device=[1, 2]) == GameFilter( + device=MultiCriterion(value=[1, 2], modifier=Modifier.INCLUDES) + ) + + +def test_multiple_lookups_are_combined_on_one_filter(): + assert GameFilter.where(year_released__gt=2010, mastered=True) == GameFilter( + year_released=IntCriterion(value=2010, modifier=Modifier.GREATER_THAN), + mastered=BoolCriterion(value=True, modifier=Modifier.EQUALS), + ) + + +def test_unknown_field_raises(): + with pytest.raises((TypeError, ValueError)): + GameFilter.where(does_not_exist=1) + + +def test_unknown_suffix_raises(): + with pytest.raises((TypeError, ValueError)): + GameFilter.where(year_released__nope=1) diff --git a/tests/test_navbar_playtime.py b/tests/test_navbar_playtime.py index 6c26efd..0cafd7e 100644 --- a/tests/test_navbar_playtime.py +++ b/tests/test_navbar_playtime.py @@ -1,4 +1,10 @@ +from urllib.parse import parse_qs, urlparse + +import pytest +from django.test import RequestFactory + from common.layout import NavbarPlaytime +from games.filters import parse_session_filter def test_navbar_playtime_has_stable_id_and_values(): @@ -13,3 +19,32 @@ def test_navbar_playtime_oob_flag(): html = str(NavbarPlaytime("1 h 00 m", "7 h 00 m", oob=True)) assert 'id="navbar-playtime"' in html assert 'hx-swap-oob="true"' in html + + +def test_navbar_playtime_wraps_totals_in_links(): + html = str( + NavbarPlaytime( + "1 h 00 m", + "5 h 00 m", + today_url="/sessions/?filter=today", + last_7_url="/sessions/?filter=week", + ) + ) + assert 'href="/sessions/?filter=today"' in html + assert 'href="/sessions/?filter=week"' in html + assert "1 h 00 m" in html + assert "5 h 00 m" in html + + +@pytest.mark.django_db +def test_model_counts_exposes_session_filter_urls(): + from games.views.general import model_counts + + request = RequestFactory().get("/") + counts = model_counts(request) + + today_filter_json = parse_qs(urlparse(counts["today_url"]).query)["filter"][0] + last_7_filter_json = parse_qs(urlparse(counts["last_7_url"]).query)["filter"][0] + + assert parse_session_filter(today_filter_json).timestamp_start is not None + assert parse_session_filter(last_7_filter_json).timestamp_start is not None diff --git a/tests/test_session_date_filter.py b/tests/test_session_date_filter.py new file mode 100644 index 0000000..34189c0 --- /dev/null +++ b/tests/test_session_date_filter.py @@ -0,0 +1,54 @@ +"""Date-range filtering on session timestamps (issue #56, Component 2). + +The navbar 'today' / 'last 7 days' links need SessionFilter to express date +ranges over the timestamp_start datetime column.""" + +from datetime import timedelta + +import pytest +from django.utils.timezone import localtime +from django.utils.timezone import now as timezone_now + +from games.filters import SessionFilter +from games.models import Game, Platform, Session + + +@pytest.fixture +def sessions_across_days(db): + platform = Platform.objects.create(name="PC") + game = Game.objects.create(name="Zelda", platform=platform) + today = localtime(timezone_now()) + return { + "today": Session.objects.create(game=game, timestamp_start=today), + "three_days_ago": Session.objects.create( + game=game, timestamp_start=today - timedelta(days=3) + ), + "ten_days_ago": Session.objects.create( + game=game, timestamp_start=today - timedelta(days=10) + ), + "today_date": today.date(), + } + + +def test_today_filter_matches_only_todays_sessions(sessions_across_days): + today_iso = sessions_across_days["today_date"].isoformat() + session_filter = SessionFilter.where(timestamp_start=today_iso) + matched = list(Session.objects.filter(session_filter.to_q())) + assert matched == [sessions_across_days["today"]] + + +def test_last_7_days_filter_matches_calendar_window(sessions_across_days): + today_date = sessions_across_days["today_date"] + session_filter = SessionFilter.where( + timestamp_start__between=( + (today_date - timedelta(days=6)).isoformat(), + today_date.isoformat(), + ) + ) + matched = set( + Session.objects.filter(session_filter.to_q()).values_list("id", flat=True) + ) + assert matched == { + sessions_across_days["today"].id, + sessions_across_days["three_days_ago"].id, + }