From ab4dae55ebc9008a4d054086ef18a21805755d44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 11:47:39 +0200 Subject: [PATCH 1/2] feat(filters): date-range filtering on PlayEventFilter.started/ended (#67) Change PlayEventFilter.started/ended from StringCriterion to DateCriterion so they support GREATER_THAN / LESS_THAN / BETWEEN, enabling "finished in year Y" to be expressed through the filter system. PlayEvent.started/ended are DateField columns, so the criteria apply with bare field names (no __date lookup, unlike SessionFilter.timestamp_start which is a datetime). This mirrors the existing PurchaseFilter DateField precedent. Deserialization auto-switches via the field annotation and the serialized JSON shape is unchanged, so the type change is backward-compatible. Prerequisite for #65 Tier-2 stats-page filtered links. Part of #61. Co-Authored-By: Claude Opus 4.8 --- games/filters.py | 4 +- tests/test_filters.py | 98 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 100 insertions(+), 2 deletions(-) diff --git a/games/filters.py b/games/filters.py index 8bd22e9..9a3cf45 100644 --- a/games/filters.py +++ b/games/filters.py @@ -899,8 +899,8 @@ class PlayEventFilter(OperatorFilter): NOT: PlayEventFilter | None = None game: MultiCriterion | None = None # filters on game_id - started: StringCriterion | None = None # date string - ended: StringCriterion | None = None # date string + started: DateCriterion | None = None # DateField, bare lookup + ended: DateCriterion | None = None # DateField, bare lookup days_to_finish: IntCriterion | None = None note: StringCriterion | None = None created_at: StringCriterion | None = None diff --git a/tests/test_filters.py b/tests/test_filters.py index 9a7e684..a8e8334 100644 --- a/tests/test_filters.py +++ b/tests/test_filters.py @@ -1241,3 +1241,101 @@ class TestPurchaseFilterDates: assert out["date_purchased"]["value2"] == "2024-12-31" assert out["date_purchased"]["modifier"] == Modifier.BETWEEN assert out["date_refunded"]["modifier"] == Modifier.NOT_NULL + + +class TestPlayEventFilterDates: + """End-to-end: a PlayEventFilter built from JSON narrows the queryset + correctly across the started/ended DateCriterion fields. PlayEvent.started + and ended are DateField columns, so the criteria apply with bare field + names (no __date lookup).""" + + def _seed(self): + import datetime + + from games.models import Game, Platform, PlayEvent + + platform, _ = Platform.objects.get_or_create(name="Test", icon="test") + game = Game.objects.create(name="Test Game", platform=platform) + early = PlayEvent.objects.create( + game=game, + started=datetime.date(2024, 1, 10), + ended=datetime.date(2024, 1, 20), + ) + mid = PlayEvent.objects.create( + game=game, + started=datetime.date(2024, 6, 1), + ended=datetime.date(2024, 6, 30), + ) + late = PlayEvent.objects.create( + game=game, + started=datetime.date(2025, 2, 1), + ended=datetime.date(2025, 2, 15), + ) + return {"early": early, "mid": mid, "late": late} + + @pytest.mark.django_db + def test_ended_between_finds_year(self): + """'Finished in 2024' expressed as a BETWEEN range over ended.""" + from games.filters import PlayEventFilter + from games.models import PlayEvent + + seeded = self._seed() + pf = PlayEventFilter.from_json( + { + "ended": { + "value": "2024-01-01", + "value2": "2024-12-31", + "modifier": "BETWEEN", + } + } + ) + results = set(PlayEvent.objects.filter(pf.to_q())) + assert results == {seeded["early"], seeded["mid"]} + + @pytest.mark.django_db + def test_started_greater_than(self): + from games.filters import PlayEventFilter + from games.models import PlayEvent + + seeded = self._seed() + pf = PlayEventFilter.from_json( + {"started": {"value": "2024-06-01", "modifier": "GREATER_THAN"}} + ) + results = set(PlayEvent.objects.filter(pf.to_q())) + assert results == {seeded["late"]} + + @pytest.mark.django_db + def test_ended_less_than(self): + from games.filters import PlayEventFilter + from games.models import PlayEvent + + seeded = self._seed() + pf = PlayEventFilter.from_json( + {"ended": {"value": "2024-06-30", "modifier": "LESS_THAN"}} + ) + results = set(PlayEvent.objects.filter(pf.to_q())) + assert results == {seeded["early"]} + + @pytest.mark.django_db + def test_playevent_filter_json_round_trip(self): + """PlayEventFilter started/ended survive json → object → json, + confirming DateCriterion is dispatched by from_json (not + StringCriterion).""" + from games.filters import PlayEventFilter + + payload = { + "started": {"value": "2024-01-01", "modifier": "GREATER_THAN"}, + "ended": { + "value": "2024-01-01", + "value2": "2024-12-31", + "modifier": "BETWEEN", + }, + } + pf = PlayEventFilter.from_json(payload) + assert isinstance(pf.started, DateCriterion) + assert isinstance(pf.ended, DateCriterion) + out = pf.to_json() + assert out["ended"]["value"] == "2024-01-01" + assert out["ended"]["value2"] == "2024-12-31" + assert out["ended"]["modifier"] == Modifier.BETWEEN + assert out["started"]["modifier"] == Modifier.GREATER_THAN From 6e7f000e1c5392fc13381ad92b5c61eb3f7ccbe7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Luk=C3=A1=C5=A1=20Kucharczyk?= Date: Sun, 21 Jun 2026 12:08:27 +0200 Subject: [PATCH 2/2] feat(filters): surface started/ended date filters on the PlayEvent filter bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add Started and Finished DateRangePicker widgets to the PlayEvent filter bar and wire filter-started / filter-ended into the filter-bar date-range serializer, so the started/ended DateCriterion fields (added for #67) are reachable from the UI — enabling "finished in year Y" range filtering. Builds on #67 (PlayEventFilter.started/ended are DateCriterion); the bare field names round-trip through _parse_range like the Purchase date fields. Co-Authored-By: Claude Opus 4.8 --- common/components/filters.py | 20 ++++++++++++++ tests/test_filter_bars.py | 53 ++++++++++++++++++++++++++++++++++++ ts/elements/filter-bar.ts | 2 ++ 3 files changed, 75 insertions(+) diff --git a/common/components/filters.py b/common/components/filters.py index 7134125..57d2f6f 100644 --- a/common/components/filters.py +++ b/common/components/filters.py @@ -1479,6 +1479,8 @@ class PlayEventFilterBar(_FilterBarBase): def _playevent_fields(existing: dict) -> list: game_choice = _filter_get_choice(existing, "game") days_min, days_max = _parse_range(existing, "days_to_finish") + started_min, started_max = _parse_range(existing, "started") + ended_min, ended_max = _parse_range(existing, "ended") fields = [ Div( @@ -1495,6 +1497,24 @@ def _playevent_fields(existing: dict) -> list: ), ], ), + _filter_field( + "Started", + DateRangePicker( + label="Started", + input_name_prefix="filter-started", + min_value=started_min, + max_value=started_max, + ), + ), + _filter_field( + "Finished", + DateRangePicker( + label="Finished", + input_name_prefix="filter-ended", + min_value=ended_min, + max_value=ended_max, + ), + ), RangeSlider( label="Days to Finish", input_name_prefix="filter-days-to-finish", diff --git a/tests/test_filter_bars.py b/tests/test_filter_bars.py index 3e80944..4fae42e 100644 --- a/tests/test_filter_bars.py +++ b/tests/test_filter_bars.py @@ -223,6 +223,59 @@ class FilterBarRenderingTest(TestCase): ) self._assert_shell(html, "/presets/playevents/list", "/presets/playevents/save") + def test_playevent_filter_bar_renders_date_inputs(self): + """PlayEventFilterBar surfaces started and ended as DateRangePicker + widgets whose -min/-max hidden inputs (the JS serializer contract) + carry the filter-started / filter-ended prefixes, in labelled fields.""" + from common.components import PlayEventFilterBar + + html = str( + PlayEventFilterBar( + filter_json="", preset_list_url="/l", preset_save_url="/s" + ) + ) + for name in ( + "filter-started-min", + "filter-started-max", + "filter-ended-min", + "filter-ended-max", + ): + self.assertIn(f'name="{name}"', html) + self.assertIn(f'id="{name}"', html) + self.assertIn(" { const dateRangeFields = [ { prefix: "filter-date-purchased", key: "date_purchased" }, { prefix: "filter-date-refunded", key: "date_refunded" }, + { prefix: "filter-started", key: "started" }, + { prefix: "filter-ended", key: "ended" }, ]; dateRangeFields.forEach((dateField) => { const valueMin = stringValue(form, dateField.prefix + "-min");