diff --git a/common/components/__init__.py b/common/components/__init__.py
index 44aa1f6..a7cd5c9 100644
--- a/common/components/__init__.py
+++ b/common/components/__init__.py
@@ -11,6 +11,11 @@ from common.components.core import (
_render_element,
randomid,
)
+from common.components.date_range_picker import (
+ DateRangeCalendar,
+ DateRangeField,
+ DateRangePicker,
+)
from common.components.domain import (
GameLink,
GameStatus,
@@ -123,6 +128,9 @@ __all__ = [
"PurchasePrice",
"SessionDeviceSelector",
"_resolve_name_with_icon",
+ "DateRangeCalendar",
+ "DateRangeField",
+ "DateRangePicker",
"FilterBar",
"PurchaseFilterBar",
"SessionFilterBar",
diff --git a/common/components/date_range_picker.py b/common/components/date_range_picker.py
new file mode 100644
index 0000000..19be8f5
--- /dev/null
+++ b/common/components/date_range_picker.py
@@ -0,0 +1,355 @@
+"""DateRangePicker: a segmented date-range input with a calendar popup.
+
+``DateRangePicker`` composes two parts:
+
+- ``DateRangeField`` — the visible widget, styled as a single input. Each
+ date is split into per-part segments (``DD``/``MM``/``YYYY``, ordered by
+ ``common.time.dateformat_hyphenated``) that the user fills digit by digit,
+ plus a calendar icon that opens the popup.
+- ``DateRangeCalendar`` — the popup: a preset column (today, yesterday,
+ last 7 days, …), a month grid rendered client-side, and a
+ Cancel / Clear / Select footer.
+
+The committed value lives in two hidden ISO-date inputs named
+``{input_name_prefix}-min`` / ``{input_name_prefix}-max`` — the same contract
+as the older ``DateRangeFilter``, so ``filter_bar.js`` serializes either
+widget into a ``DateCriterion`` unchanged. All behaviour is wired by
+``games/static/js/date_range_picker.js``.
+"""
+
+from django.utils.safestring import SafeText, mark_safe
+
+from common.components.core import Component, HTMLAttribute
+from common.components.primitives import Div, Input, Span
+from common.time import DatePartSpec, date_parts
+
+_FIELD_CONTAINER_CLASS = (
+ "flex items-center gap-0.5 w-full rounded-base border border-default-medium "
+ "bg-neutral-secondary-medium text-sm text-heading p-1.5 cursor-text "
+ "focus-within:ring-1 focus-within:ring-brand focus-within:border-brand"
+)
+
+# The segments must not stand out from the container: transparent background,
+# no border, and only a subtle highlight when active (focused).
+_SEGMENT_INPUT_CLASS = (
+ "bg-transparent border-0 p-0 text-center text-sm text-heading "
+ "placeholder:text-body rounded-xs focus:outline-none focus:ring-0 "
+ "focus:bg-brand/30 caret-transparent"
+)
+
+_SEGMENT_WIDTH_CLASSES = {2: "w-[2.5ch]", 4: "w-[4.5ch]"}
+
+_CALENDAR_ICON_SVG = (
+ '"
+)
+
+_PRESET_OPTIONS: list[tuple[str, str]] = [
+ ("today", "Today"),
+ ("yesterday", "Yesterday"),
+ ("last_7_days", "Last 7 days"),
+ ("last_30_days", "Last 30 days"),
+ ("this_month", "This month"),
+ ("last_month", "Last month"),
+ ("this_year", "This year"),
+]
+
+_PRESET_BUTTON_CLASS = (
+ "px-3 py-1.5 text-sm text-start text-body hover:text-heading "
+ "hover:bg-neutral-tertiary-medium rounded-base cursor-pointer whitespace-nowrap"
+)
+
+_NAV_BUTTON_CLASS = (
+ "p-1.5 text-body hover:text-heading hover:bg-neutral-tertiary-medium "
+ "rounded-base cursor-pointer"
+)
+
+_FOOTER_BUTTON_CLASS = (
+ "px-3 py-1.5 text-sm font-medium rounded-base cursor-pointer "
+ "text-heading bg-neutral-secondary-medium border border-default-medium "
+ "hover:bg-neutral-tertiary-medium"
+)
+
+_FOOTER_SELECT_BUTTON_CLASS = (
+ "px-3 py-1.5 text-sm font-medium rounded-base cursor-pointer "
+ "text-white bg-brand border border-transparent hover:bg-brand-strong"
+)
+
+
+def _iso_part_values(iso_value: str, parts: list[DatePartSpec]) -> dict[str, str]:
+ """Split an ISO ``YYYY-MM-DD`` string into per-part initial values.
+
+ Returns an empty mapping for empty/malformed input so a bad stored filter
+ renders as empty segments instead of crashing."""
+ if not iso_value:
+ return {}
+ pieces = iso_value.split("-")
+ if len(pieces) != 3:
+ return {}
+ year, month, day = pieces
+ values = {"year": year, "month": month, "day": day}
+ if any(not values[part.name].isdigit() for part in parts):
+ return {}
+ return values
+
+
+def _segment_input(
+ *, part: DatePartSpec, side: str, label: str, value: str
+) -> SafeText:
+ side_label = "from" if side == "min" else "to"
+ return Input(
+ attributes=[
+ ("inputmode", "numeric"),
+ ("autocomplete", "off"),
+ ("maxlength", str(part.length)),
+ ("placeholder", part.placeholder),
+ ("value", value),
+ ("data-date-part", part.name),
+ ("data-date-side", side),
+ ("aria-label", f"{label} {side_label} {part.name}"),
+ (
+ "class",
+ f"{_SEGMENT_INPUT_CLASS} "
+ f"{_SEGMENT_WIDTH_CLASSES.get(part.length, 'w-[4.5ch]')}",
+ ),
+ ],
+ )
+
+
+def _segment_group(*, side: str, label: str, iso_value: str) -> SafeText:
+ """One date's worth of segments (``DD - MM - YYYY``) for a range side."""
+ parts = date_parts()
+ initial_values = _iso_part_values(iso_value, parts)
+ children: list[SafeText] = []
+ for index, part in enumerate(parts):
+ if index > 0:
+ children.append(
+ Span(
+ attributes=[("class", "text-body select-none")],
+ children=["-"],
+ )
+ )
+ children.append(
+ _segment_input(
+ part=part,
+ side=side,
+ label=label,
+ value=initial_values.get(part.name, ""),
+ )
+ )
+ return Span(
+ attributes=[
+ ("class", "flex items-center gap-0.5"),
+ ("data-date-range-side", side),
+ ],
+ children=children,
+ )
+
+
+def DateRangeField(
+ *,
+ label: str,
+ input_name_prefix: str,
+ min_value: str = "",
+ max_value: str = "",
+) -> SafeText:
+ """The visible half of the DateRangePicker: a single-input-looking
+ container holding two segmented dates, a calendar toggle, and the two
+ hidden ISO inputs (``{prefix}-min`` / ``{prefix}-max``) that carry the
+ committed value to ``filter_bar.js``."""
+ min_input_id = f"{input_name_prefix}-min"
+ max_input_id = f"{input_name_prefix}-max"
+ return Div(
+ attributes=[
+ ("class", _FIELD_CONTAINER_CLASS),
+ ("data-date-range-field", ""),
+ ],
+ children=[
+ Input(
+ type="hidden",
+ attributes=[
+ ("name", min_input_id),
+ ("id", min_input_id),
+ ("value", min_value),
+ ("data-date-range-hidden", "min"),
+ ],
+ ),
+ Input(
+ type="hidden",
+ attributes=[
+ ("name", max_input_id),
+ ("id", max_input_id),
+ ("value", max_value),
+ ("data-date-range-hidden", "max"),
+ ],
+ ),
+ _segment_group(side="min", label=label, iso_value=min_value),
+ Span(
+ attributes=[("class", "text-body select-none px-0.5")],
+ children=["–"],
+ ),
+ _segment_group(side="max", label=label, iso_value=max_value),
+ Component(
+ tag_name="button",
+ attributes=[
+ ("type", "button"),
+ ("data-date-range-calendar-toggle", ""),
+ ("aria-label", f"Open {label} calendar"),
+ (
+ "class",
+ "ms-auto p-1 text-body hover:text-heading rounded "
+ "cursor-pointer shrink-0",
+ ),
+ ],
+ children=[mark_safe(_CALENDAR_ICON_SVG)],
+ ),
+ ],
+ )
+
+
+def _calendar_nav_button(direction: str, arrow: str, label: str) -> SafeText:
+ return Component(
+ tag_name="button",
+ attributes=[
+ ("type", "button"),
+ (f"data-date-range-{direction}", ""),
+ ("aria-label", label),
+ ("class", _NAV_BUTTON_CLASS),
+ ],
+ children=[arrow],
+ )
+
+
+def _footer_button(action: str, label: str, button_class: str) -> SafeText:
+ return Component(
+ tag_name="button",
+ attributes=[
+ ("type", "button"),
+ (f"data-date-range-{action}", ""),
+ ("class", button_class),
+ ],
+ children=[label],
+ )
+
+
+def DateRangeCalendar(*, input_name_prefix: str) -> SafeText:
+ """The popup half of the DateRangePicker: preset column, month grid
+ (filled client-side into ``[data-date-range-grid]``), and the
+ Cancel / Clear / Select footer. Hidden until the calendar toggle opens it."""
+ preset_buttons = [
+ Component(
+ tag_name="button",
+ attributes=[
+ ("type", "button"),
+ ("data-date-range-preset", preset_value),
+ ("class", _PRESET_BUTTON_CLASS),
+ ],
+ children=[preset_label],
+ )
+ for preset_value, preset_label in _PRESET_OPTIONS
+ ]
+ return Div(
+ attributes=[
+ (
+ "class",
+ "hidden absolute z-20 top-full start-0 mt-1 flex "
+ "rounded-base border border-default-medium "
+ "bg-neutral-secondary-medium shadow-lg",
+ ),
+ ("data-date-range-calendar", ""),
+ ("data-input-name-prefix", input_name_prefix),
+ ],
+ children=[
+ Div(
+ attributes=[
+ (
+ "class",
+ "flex flex-col gap-0.5 p-2 border-e border-default-medium",
+ ),
+ ("data-date-range-presets", ""),
+ ],
+ children=preset_buttons,
+ ),
+ Div(
+ attributes=[("class", "p-2")],
+ children=[
+ Div(
+ attributes=[
+ ("class", "flex items-center justify-between gap-2"),
+ ],
+ children=[
+ _calendar_nav_button("prev", "‹", "Previous month"),
+ Span(
+ attributes=[
+ ("class", "text-sm font-medium text-heading"),
+ ("data-date-range-month-label", ""),
+ ],
+ ),
+ _calendar_nav_button("next", "›", "Next month"),
+ ],
+ ),
+ Div(
+ attributes=[
+ ("class", "grid grid-cols-7 gap-y-0.5 mt-1"),
+ ("data-date-range-grid", ""),
+ ],
+ ),
+ Div(
+ attributes=[
+ (
+ "class",
+ "flex justify-end gap-2 mt-2 pt-2 border-t "
+ "border-default-medium",
+ ),
+ ],
+ children=[
+ _footer_button("cancel", "Cancel", _FOOTER_BUTTON_CLASS),
+ _footer_button("clear", "Clear", _FOOTER_BUTTON_CLASS),
+ _footer_button(
+ "select", "Select", _FOOTER_SELECT_BUTTON_CLASS
+ ),
+ ],
+ ),
+ ],
+ ),
+ ],
+ )
+
+
+def DateRangePicker(
+ *,
+ label: str,
+ input_name_prefix: str,
+ min_value: str = "",
+ max_value: str = "",
+) -> SafeText:
+ """A date-range widget: segmented manual entry plus a calendar popup.
+
+ Drop-in replacement for ``DateRangeFilter`` — exposes the same hidden
+ ``{prefix}-min`` / ``{prefix}-max`` ISO inputs, so the filter-bar
+ serializer needs no changes. ``min_value`` / ``max_value`` are ISO
+ ``YYYY-MM-DD`` strings used to prefill both the segments and the hidden
+ inputs."""
+ attributes: list[HTMLAttribute] = [
+ ("class", "date-range-picker relative"),
+ ("data-date-range-picker", ""),
+ ("data-input-name-prefix", input_name_prefix),
+ ]
+ return Div(
+ attributes=attributes,
+ children=[
+ DateRangeField(
+ label=label,
+ input_name_prefix=input_name_prefix,
+ min_value=min_value,
+ max_value=max_value,
+ ),
+ DateRangeCalendar(input_name_prefix=input_name_prefix),
+ ],
+ )
diff --git a/common/components/filters.py b/common/components/filters.py
index 36c6a64..1c7f59b 100644
--- a/common/components/filters.py
+++ b/common/components/filters.py
@@ -6,6 +6,7 @@ from django.db import models
from django.utils.safestring import SafeText, mark_safe
from common.components.core import Component
+from common.components.date_range_picker import DateRangePicker
from common.components.primitives import Checkbox, Div, Input, Label, Radio, Span
from common.components.search_select import (
DEFAULT_PREFETCH,
@@ -1289,7 +1290,7 @@ def PurchaseFilterBar(
),
_filter_field(
"Purchased",
- DateRangeFilter(
+ DateRangePicker(
label="Purchased",
input_name_prefix="filter-date-purchased",
min_value=date_purchased_min,
diff --git a/common/time.py b/common/time.py
index d6a3982..dfd8279 100644
--- a/common/time.py
+++ b/common/time.py
@@ -1,17 +1,43 @@
import re
from datetime import date, datetime, timedelta
+from typing import NamedTuple
from django.utils import timezone
from common.utils import generate_split_ranges
dateformat: str = "%d/%m/%Y"
+dateformat_hyphenated: str = "%d-%m-%Y"
datetimeformat: str = "%d/%m/%Y %H:%M"
timeformat: str = "%H:%M"
durationformat: str = "%2.1H hours"
durationformat_manual: str = "%H hours"
+class DatePartSpec(NamedTuple):
+ """One date part (day/month/year) of a hyphenated date format."""
+
+ name: str
+ placeholder: str
+ length: int
+
+
+_DATE_PART_SPECS: dict[str, DatePartSpec] = {
+ "%d": DatePartSpec("day", "DD", 2),
+ "%m": DatePartSpec("month", "MM", 2),
+ "%Y": DatePartSpec("year", "YYYY", 4),
+}
+
+
+def date_parts(format_string: str = dateformat_hyphenated) -> list[DatePartSpec]:
+ """Split a hyphenated strftime date format into its ordered parts.
+
+ ``"%d-%m-%Y"`` becomes ``[day, month, year]`` specs, each carrying the
+ placeholder text (``DD``/``MM``/``YYYY``) and digit length shown by the
+ DateRangeField segments."""
+ return [_DATE_PART_SPECS[directive] for directive in format_string.split("-")]
+
+
def _safe_timedelta(duration: timedelta | int | None):
if duration is None:
return timedelta(0)
diff --git a/e2e/test_date_filter_e2e.py b/e2e/test_date_filter_e2e.py
index ec98a73..352691f 100644
--- a/e2e/test_date_filter_e2e.py
+++ b/e2e/test_date_filter_e2e.py
@@ -5,6 +5,10 @@ cannot reach: ``filter_bar.js`` reading the two ````
elements, building a ``DateCriterion`` JSON object, and navigating the
browser to ``?filter=``.
+The native ```` path is exercised through the Refunded
+field — the Purchased field now uses the DateRangePicker component, covered
+by ``test_date_range_picker_e2e.py``.
+
Renders the bar at its own custom URL so the test doesn't need to auth
against the real app — the bar's JS doesn't care what route serves it.
"""
@@ -42,7 +46,7 @@ def empty_bar_view(request):
def prefilled_bar_view(request):
filter_json = json.dumps(
{
- "date_purchased": {
+ "date_refunded": {
"value": "2024-03-15",
"value2": "2024-09-20",
"modifier": "BETWEEN",
@@ -70,8 +74,8 @@ def _filter_from_url(url: str) -> dict:
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
def test_both_dates_serializes_as_between(live_server, page):
page.goto(live_server.url + "/test-date-filter/")
- page.locator('input[name="filter-date-purchased-min"]').fill("2024-01-01")
- page.locator('input[name="filter-date-purchased-max"]').fill("2024-12-31")
+ page.locator('input[name="filter-date-refunded-min"]').fill("2024-01-01")
+ page.locator('input[name="filter-date-refunded-max"]').fill("2024-12-31")
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
@@ -79,7 +83,7 @@ def test_both_dates_serializes_as_between(live_server, page):
)
parsed = _filter_from_url(page.url)
assert parsed == {
- "date_purchased": {
+ "date_refunded": {
"value": "2024-01-01",
"value2": "2024-12-31",
"modifier": "BETWEEN",
@@ -91,7 +95,7 @@ def test_both_dates_serializes_as_between(live_server, page):
@override_settings(ROOT_URLCONF="e2e.test_date_filter_e2e")
def test_min_only_serializes_as_greater_than(live_server, page):
page.goto(live_server.url + "/test-date-filter/")
- page.locator('input[name="filter-date-purchased-min"]').fill("2024-06-15")
+ page.locator('input[name="filter-date-refunded-min"]').fill("2024-06-15")
with page.expect_navigation():
page.evaluate(
"document.getElementById('filter-bar-form')"
@@ -99,10 +103,10 @@ def test_min_only_serializes_as_greater_than(live_server, page):
)
parsed = _filter_from_url(page.url)
assert parsed == {
- "date_purchased": {"value": "2024-06-15", "modifier": "GREATER_THAN"}
+ "date_refunded": {"value": "2024-06-15", "modifier": "GREATER_THAN"}
}
# value2 must not be present when there's no upper bound.
- assert "value2" not in parsed["date_purchased"]
+ assert "value2" not in parsed["date_refunded"]
@pytest.mark.django_db
@@ -144,11 +148,11 @@ def test_prefilled_bar_reflects_existing_filter_in_inputs(live_server, page):
re-submits the same bounds unchanged."""
page.goto(live_server.url + "/test-date-filter-prefilled/")
assert (
- page.locator('input[name="filter-date-purchased-min"]').input_value()
+ page.locator('input[name="filter-date-refunded-min"]').input_value()
== "2024-03-15"
)
assert (
- page.locator('input[name="filter-date-purchased-max"]').input_value()
+ page.locator('input[name="filter-date-refunded-max"]').input_value()
== "2024-09-20"
)
with page.expect_navigation():
@@ -157,7 +161,7 @@ def test_prefilled_bar_reflects_existing_filter_in_inputs(live_server, page):
".dispatchEvent(new Event('submit', {cancelable: true}))"
)
parsed = _filter_from_url(page.url)
- assert parsed["date_purchased"] == {
+ assert parsed["date_refunded"] == {
"value": "2024-03-15",
"value2": "2024-09-20",
"modifier": "BETWEEN",
diff --git a/e2e/test_date_range_picker_e2e.py b/e2e/test_date_range_picker_e2e.py
new file mode 100644
index 0000000..7b48968
--- /dev/null
+++ b/e2e/test_date_range_picker_e2e.py
@@ -0,0 +1,325 @@
+"""End-to-end Playwright tests for the DateRangePicker component.
+
+Exercises the behaviour layers the rendering tests cannot reach
+(``date_range_picker.js``): segmented digit entry with right-to-left
+placeholder fill and auto-advance, Backspace reverting a part, the calendar
+popup's anchor-style range picking, presets, the Cancel / Clear / Select
+footer, and the ``filter_bar.js`` serialization of the hidden ISO inputs
+into a ``DateCriterion``.
+
+Like the other filter-bar e2e modules, the bar is served from its own
+minimal URLconf (no auth, no CSS) — the JS only cares about the DOM.
+"""
+
+import datetime
+import json
+import urllib.parse
+
+import pytest
+from django.http import HttpResponse
+from django.test import override_settings
+
+from common.components import PurchaseFilterBar
+from django.urls import path
+
+
+def _bar_page(filter_json: str = "") -> str:
+ return f"""
+
+
+ Date range picker E2E
+
+
+
+
+
+
+ {PurchaseFilterBar(filter_json=filter_json, preset_list_url="/p/l", preset_save_url="/p/s")}
+
+"""
+
+
+def empty_bar_view(request):
+ return HttpResponse(_bar_page())
+
+
+def prefilled_bar_view(request):
+ filter_json = json.dumps(
+ {
+ "date_purchased": {
+ "value": "2024-03-15",
+ "value2": "2024-09-20",
+ "modifier": "BETWEEN",
+ }
+ }
+ )
+ return HttpResponse(_bar_page(filter_json))
+
+
+urlpatterns = [
+ path("test-date-range-picker/", empty_bar_view),
+ path("test-date-range-picker-prefilled/", prefilled_bar_view),
+]
+
+
+PICKER = '[data-date-range-picker][data-input-name-prefix="filter-date-purchased"]'
+POPUP = PICKER + " [data-date-range-calendar]"
+HIDDEN_MIN = 'input[name="filter-date-purchased-min"]'
+HIDDEN_MAX = 'input[name="filter-date-purchased-max"]'
+
+
+def _segment(page, side: str, part: str):
+ return page.locator(
+ f'{PICKER} input[data-date-side="{side}"][data-date-part="{part}"]'
+ )
+
+
+def _day_cell(page, iso_date: str):
+ return page.locator(
+ f'{PICKER} [data-date-range-grid] button[data-date="{iso_date}"]'
+ )
+
+
+def _popup_is_open(page) -> bool:
+ return "hidden" not in (page.locator(POPUP).get_attribute("class") or "")
+
+
+def _submit_filter_bar(page):
+ with page.expect_navigation():
+ page.evaluate(
+ "document.getElementById('filter-bar-form')"
+ ".dispatchEvent(new Event('submit', {cancelable: true}))"
+ )
+
+
+def _filter_from_url(url: str) -> dict:
+ query = urllib.parse.urlparse(url).query
+ params = urllib.parse.parse_qs(query)
+ raw = params.get("filter", [""])[0]
+ return json.loads(raw) if raw else {}
+
+
+# ── Segmented manual entry ──────────────────────────────────────────────────
+
+
+@pytest.mark.django_db
+@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
+def test_typing_fills_parts_and_serializes_between(live_server, page):
+ """Digits flow through the parts (DD → MM → YYYY → DD …) with
+ auto-advance, ending in a BETWEEN criterion on submit."""
+ page.goto(live_server.url + "/test-date-range-picker/")
+ _segment(page, "min", "day").click()
+ page.keyboard.type("1503202420092024")
+ assert page.locator(HIDDEN_MIN).input_value() == "2024-03-15"
+ assert page.locator(HIDDEN_MAX).input_value() == "2024-09-20"
+ _submit_filter_bar(page)
+ parsed = _filter_from_url(page.url)
+ assert parsed == {
+ "date_purchased": {
+ "value": "2024-03-15",
+ "value2": "2024-09-20",
+ "modifier": "BETWEEN",
+ }
+ }
+
+
+@pytest.mark.django_db
+@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
+def test_placeholder_fills_from_the_right(live_server, page):
+ """Typing 19 into the YYYY part shows YYY1 then YY19."""
+ page.goto(live_server.url + "/test-date-range-picker/")
+ year_segment = _segment(page, "min", "year")
+ year_segment.click()
+ page.keyboard.press("1")
+ assert year_segment.input_value() == "YYY1"
+ page.keyboard.press("9")
+ assert year_segment.input_value() == "YY19"
+
+
+@pytest.mark.django_db
+@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
+def test_min_side_only_serializes_greater_than(live_server, page):
+ page.goto(live_server.url + "/test-date-range-picker/")
+ _segment(page, "min", "day").click()
+ page.keyboard.type("15062024")
+ _submit_filter_bar(page)
+ parsed = _filter_from_url(page.url)
+ assert parsed == {
+ "date_purchased": {"value": "2024-06-15", "modifier": "GREATER_THAN"}
+ }
+
+
+@pytest.mark.django_db
+@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
+def test_backspace_reverts_part_to_placeholder(live_server, page):
+ page.goto(live_server.url + "/test-date-range-picker/")
+ _segment(page, "min", "day").click()
+ page.keyboard.type("15032024")
+ assert page.locator(HIDDEN_MIN).input_value() == "2024-03-15"
+ month_segment = _segment(page, "min", "month")
+ month_segment.click()
+ page.keyboard.press("Backspace")
+ assert month_segment.input_value() == ""
+ # An incomplete date no longer commits to the hidden input.
+ assert page.locator(HIDDEN_MIN).input_value() == ""
+
+
+@pytest.mark.django_db
+@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
+def test_only_numbers_can_be_typed(live_server, page):
+ page.goto(live_server.url + "/test-date-range-picker/")
+ day_segment = _segment(page, "min", "day")
+ day_segment.click()
+ page.keyboard.type("ab-/")
+ assert day_segment.input_value() == ""
+
+
+@pytest.mark.django_db
+@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
+def test_invalid_calendar_date_does_not_commit(live_server, page):
+ """31-02-2024 fills all parts but is not a real date — no hidden value."""
+ page.goto(live_server.url + "/test-date-range-picker/")
+ _segment(page, "min", "day").click()
+ page.keyboard.type("31022024")
+ assert page.locator(HIDDEN_MIN).input_value() == ""
+
+
+@pytest.mark.django_db
+@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
+def test_clicking_container_activates_first_part(live_server, page):
+ page.goto(live_server.url + "/test-date-range-picker/")
+ page.locator(PICKER + " [data-date-range-field]").click(position={"x": 5, "y": 5})
+ focused = page.evaluate(
+ "document.activeElement.getAttribute('data-date-part') + ':' +"
+ "document.activeElement.getAttribute('data-date-side')"
+ )
+ assert focused == "day:min"
+
+
+# ── Calendar popup ──────────────────────────────────────────────────────────
+
+
+def _open_calendar(page):
+ page.locator(PICKER + " [data-date-range-calendar-toggle]").click()
+
+
+def _current_month_iso(day_of_month: int) -> str:
+ today = datetime.date.today()
+ return today.replace(day=day_of_month).isoformat()
+
+
+@pytest.mark.django_db
+@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
+def test_calendar_pick_range_then_select(live_server, page):
+ page.goto(live_server.url + "/test-date-range-picker/")
+ _open_calendar(page)
+ assert _popup_is_open(page)
+ first_pick = _current_month_iso(10)
+ second_pick = _current_month_iso(20)
+ _day_cell(page, first_pick).click()
+ assert page.locator(HIDDEN_MIN).input_value() == first_pick
+ assert page.locator(HIDDEN_MAX).input_value() == ""
+ _day_cell(page, second_pick).click()
+ assert page.locator(HIDDEN_MAX).input_value() == second_pick
+ page.locator(PICKER + " [data-date-range-select]").click()
+ assert not _popup_is_open(page)
+ _submit_filter_bar(page)
+ parsed = _filter_from_url(page.url)
+ assert parsed == {
+ "date_purchased": {
+ "value": first_pick,
+ "value2": second_pick,
+ "modifier": "BETWEEN",
+ }
+ }
+
+
+@pytest.mark.django_db
+@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
+def test_picking_before_start_restarts_the_range(live_server, page):
+ """With the StartDate anchored, picking an earlier date clears the range
+ and the clicked date becomes the new StartDate."""
+ page.goto(live_server.url + "/test-date-range-picker/")
+ _open_calendar(page)
+ _day_cell(page, _current_month_iso(20)).click()
+ _day_cell(page, _current_month_iso(10)).click()
+ assert page.locator(HIDDEN_MIN).input_value() == _current_month_iso(10)
+ assert page.locator(HIDDEN_MAX).input_value() == ""
+
+
+@pytest.mark.django_db
+@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
+def test_completed_range_anchor_moves_to_end(live_server, page):
+ """After both dates are picked the EndDate becomes the anchor, so a
+ further pick inside the range moves the StartDate."""
+ page.goto(live_server.url + "/test-date-range-picker/")
+ _open_calendar(page)
+ _day_cell(page, _current_month_iso(10)).click()
+ _day_cell(page, _current_month_iso(20)).click()
+ _day_cell(page, _current_month_iso(15)).click()
+ assert page.locator(HIDDEN_MIN).input_value() == _current_month_iso(15)
+ assert page.locator(HIDDEN_MAX).input_value() == _current_month_iso(20)
+
+
+@pytest.mark.django_db
+@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
+def test_preset_fills_both_dates(live_server, page):
+ page.goto(live_server.url + "/test-date-range-picker/")
+ _open_calendar(page)
+ page.locator(PICKER + ' [data-date-range-preset="last_7_days"]').click()
+ today = datetime.date.today()
+ assert (
+ page.locator(HIDDEN_MIN).input_value()
+ == (today - datetime.timedelta(days=6)).isoformat()
+ )
+ assert page.locator(HIDDEN_MAX).input_value() == today.isoformat()
+ # Presets keep the popup open; Select commits and closes.
+ assert _popup_is_open(page)
+
+
+@pytest.mark.django_db
+@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
+def test_clear_clears_dates_but_keeps_popup_open(live_server, page):
+ page.goto(live_server.url + "/test-date-range-picker/")
+ _open_calendar(page)
+ _day_cell(page, _current_month_iso(10)).click()
+ _day_cell(page, _current_month_iso(20)).click()
+ page.locator(PICKER + " [data-date-range-clear]").click()
+ assert page.locator(HIDDEN_MIN).input_value() == ""
+ assert page.locator(HIDDEN_MAX).input_value() == ""
+ assert _popup_is_open(page)
+
+
+@pytest.mark.django_db
+@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
+def test_cancel_clears_dates_and_closes_popup(live_server, page):
+ page.goto(live_server.url + "/test-date-range-picker/")
+ _open_calendar(page)
+ _day_cell(page, _current_month_iso(10)).click()
+ _day_cell(page, _current_month_iso(20)).click()
+ page.locator(PICKER + " [data-date-range-cancel]").click()
+ assert page.locator(HIDDEN_MIN).input_value() == ""
+ assert page.locator(HIDDEN_MAX).input_value() == ""
+ assert not _popup_is_open(page)
+
+
+# ── Prefill round-trip ──────────────────────────────────────────────────────
+
+
+@pytest.mark.django_db
+@override_settings(ROOT_URLCONF="e2e.test_date_range_picker_e2e")
+def test_prefilled_picker_round_trips_unchanged(live_server, page):
+ page.goto(live_server.url + "/test-date-range-picker-prefilled/")
+ assert _segment(page, "min", "day").input_value() == "15"
+ assert _segment(page, "min", "month").input_value() == "03"
+ assert _segment(page, "min", "year").input_value() == "2024"
+ assert _segment(page, "max", "day").input_value() == "20"
+ assert page.locator(HIDDEN_MIN).input_value() == "2024-03-15"
+ assert page.locator(HIDDEN_MAX).input_value() == "2024-09-20"
+ _submit_filter_bar(page)
+ parsed = _filter_from_url(page.url)
+ assert parsed["date_purchased"] == {
+ "value": "2024-03-15",
+ "value2": "2024-09-20",
+ "modifier": "BETWEEN",
+ }
diff --git a/games/static/base.css b/games/static/base.css
index 556712d..fe7c0fb 100644
--- a/games/static/base.css
+++ b/games/static/base.css
@@ -918,6 +918,9 @@
.ms-2\.5 {
margin-inline-start: calc(var(--spacing) * 2.5);
}
+ .ms-auto {
+ margin-inline-start: auto;
+ }
.me-2 {
margin-inline-end: calc(var(--spacing) * 2);
}
@@ -1582,6 +1585,9 @@
.w-5\/6 {
width: calc(5 / 6 * 100%);
}
+ .w-8 {
+ width: calc(var(--spacing) * 8);
+ }
.w-10 {
width: calc(var(--spacing) * 10);
}
@@ -1597,6 +1603,12 @@
.w-72 {
width: calc(var(--spacing) * 72);
}
+ .w-\[2\.5ch\] {
+ width: 2.5ch;
+ }
+ .w-\[4\.5ch\] {
+ width: 4.5ch;
+ }
.w-\[300px\] {
width: 300px;
}
@@ -1736,6 +1748,9 @@
.cursor-pointer {
cursor: pointer;
}
+ .cursor-text {
+ cursor: text;
+ }
.resize {
resize: both;
}
@@ -1787,6 +1802,9 @@
.justify-start {
justify-content: flex-start;
}
+ .gap-0\.5 {
+ gap: calc(var(--spacing) * 0.5);
+ }
.gap-1 {
gap: calc(var(--spacing) * 1);
}
@@ -1836,6 +1854,9 @@
margin-inline-end: calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-x-reverse)));
}
}
+ .gap-y-0\.5 {
+ row-gap: calc(var(--spacing) * 0.5);
+ }
.gap-y-4 {
row-gap: calc(var(--spacing) * 4);
}
@@ -1904,6 +1925,9 @@
.rounded-xl {
border-radius: var(--radius-xl);
}
+ .rounded-xs {
+ border-radius: var(--radius-xs);
+ }
.rounded-s-base {
border-start-start-radius: var(--radius-base);
border-end-start-radius: var(--radius-base);
@@ -1958,6 +1982,10 @@
border-style: var(--tw-border-style);
border-width: 2px;
}
+ .border-y {
+ border-block-style: var(--tw-border-style);
+ border-block-width: 1px;
+ }
.border-e {
border-inline-end-style: var(--tw-border-style);
border-inline-end-width: 1px;
@@ -2030,6 +2058,12 @@
.border-brand {
border-color: var(--color-brand);
}
+ .border-brand\/70 {
+ border-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 70%, transparent);
+ @supports (color: color-mix(in lab, red, red)) {
+ border-color: color-mix(in oklab, var(--color-brand) 70%, transparent);
+ }
+ }
.border-default {
border-color: var(--color-default);
}
@@ -2121,12 +2155,24 @@
.bg-brand {
background-color: var(--color-brand);
}
+ .bg-brand\/10 {
+ background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 10%, transparent);
+ @supports (color: color-mix(in lab, red, red)) {
+ background-color: color-mix(in oklab, var(--color-brand) 10%, transparent);
+ }
+ }
.bg-brand\/15 {
background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 15%, transparent);
@supports (color: color-mix(in lab, red, red)) {
background-color: color-mix(in oklab, var(--color-brand) 15%, transparent);
}
}
+ .bg-brand\/30 {
+ background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 30%, transparent);
+ @supports (color: color-mix(in lab, red, red)) {
+ background-color: color-mix(in oklab, var(--color-brand) 30%, transparent);
+ }
+ }
.bg-dark-backdrop\/70 {
background-color: color-mix(in srgb, oklch(13% 0.028 261.692) 70%, transparent);
@supports (color: color-mix(in lab, red, red)) {
@@ -2296,6 +2342,9 @@
padding: 0 !important;
}
}
+ .p-0 {
+ padding: calc(var(--spacing) * 0);
+ }
.p-1 {
padding: calc(var(--spacing) * 1);
}
@@ -2320,6 +2369,9 @@
.p-6 {
padding: calc(var(--spacing) * 6);
}
+ .px-0\.5 {
+ padding-inline: calc(var(--spacing) * 0.5);
+ }
.px-2 {
padding-inline: calc(var(--spacing) * 2);
}
@@ -2419,6 +2471,9 @@
.text-right {
text-align: right;
}
+ .text-start {
+ text-align: start;
+ }
.align-middle {
vertical-align: middle;
}
@@ -2714,9 +2769,15 @@
.decoration-dotted {
text-decoration-style: dotted;
}
+ .caret-transparent {
+ caret-color: transparent;
+ }
.opacity-0 {
opacity: 0%;
}
+ .opacity-40 {
+ opacity: 40%;
+ }
.opacity-50 {
opacity: 50%;
}
@@ -2752,6 +2813,13 @@
--tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05));
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
}
+ .ring-2 {
+ --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
+ }
+ .ring-brand-strong {
+ --tw-ring-color: var(--color-brand-strong);
+ }
.outline {
outline-style: var(--tw-outline-style);
outline-width: 1px;
@@ -2817,6 +2885,9 @@
.\[program\:qcluster\] {
program: qcluster;
}
+ .ring-inset {
+ --tw-ring-inset: inset;
+ }
.group-hover\:absolute {
&:is(:where(.group):hover *) {
@media (hover: hover) {
@@ -2958,6 +3029,22 @@
padding-top: calc(var(--spacing) * 0);
}
}
+ .focus-within\:border-brand {
+ &:focus-within {
+ border-color: var(--color-brand);
+ }
+ }
+ .focus-within\:ring-1 {
+ &:focus-within {
+ --tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
+ box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
+ }
+ }
+ .focus-within\:ring-brand {
+ &:focus-within {
+ --tw-ring-color: var(--color-brand);
+ }
+ }
.hover\:scale-110 {
&:hover {
@media (hover: hover) {
@@ -3223,6 +3310,14 @@
border-color: var(--color-brand);
}
}
+ .focus\:bg-brand\/30 {
+ &:focus {
+ background-color: color-mix(in srgb, oklch(48.8% 0.243 264.376) 30%, transparent);
+ @supports (color: color-mix(in lab, red, red)) {
+ background-color: color-mix(in oklab, var(--color-brand) 30%, transparent);
+ }
+ }
+ }
.focus\:text-blue-700 {
&:focus {
color: var(--color-blue-700);
diff --git a/games/static/js/date_range_picker.js b/games/static/js/date_range_picker.js
new file mode 100644
index 0000000..ad3da6b
--- /dev/null
+++ b/games/static/js/date_range_picker.js
@@ -0,0 +1,530 @@
+/**
+ * DateRangePicker — vanilla JavaScript implementation.
+ *
+ * Drives the DateRangePicker component (common/components/date_range_picker.py):
+ *
+ * - DateRangeField: segmented manual entry. Each date part (DD/MM/YYYY) is its
+ * own input; digits fill the placeholder from the right (YYYY → YYY1 → YY19
+ * → Y198 → 1987), full parts auto-advance to the next one, and
+ * Backspace/Delete reverts the active part to its placeholder.
+ * - DateRangeCalendar: popup month grid with a preset column and a
+ * Cancel / Clear / Select footer. Picking works anchor-style: the first
+ * pick becomes the StartDate anchor, the second pick sets the EndDate and
+ * moves the anchor there so further picks adjust the StartDate. Picking on
+ * the wrong side of the anchor clears the range and restarts from the
+ * clicked date.
+ *
+ * The committed value lives in the two hidden ISO inputs ({prefix}-min /
+ * {prefix}-max) that filter_bar.js serializes into a DateCriterion.
+ *
+ * NB: class strings below are emitted verbatim so the Tailwind scanner picks
+ * them up — keep them as plain literals.
+ */
+(function () {
+ "use strict";
+
+ var WEEKDAY_LABELS = ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"];
+
+ var WEEKDAY_CLASS =
+ "w-8 h-6 flex items-center justify-center text-xs text-body select-none";
+ var DAY_BASE_CLASS =
+ "date-range-day w-8 h-8 flex items-center justify-center text-sm " +
+ "text-heading cursor-pointer hover:bg-neutral-tertiary-medium";
+ var DAY_ROUNDED_CLASS = "rounded-base";
+ var DAY_OUTSIDE_MONTH_CLASS = "opacity-40";
+ var DAY_SELECTED_CLASS = "bg-brand text-white hover:bg-brand-strong";
+ var DAY_ANCHOR_CLASS =
+ "bg-brand text-white ring-2 ring-inset ring-brand-strong hover:bg-brand-strong";
+ // The three visual states of the date range track (the days between the
+ // two endpoints): outlined while picking the second date, filled once both
+ // are picked, muted when showing an already-committed range read-only.
+ var TRACK_OUTLINED_CLASS = "border-y border-brand/70 bg-brand/10";
+ var TRACK_FILLED_CLASS = "bg-brand/30";
+ var TRACK_MUTED_CLASS = "bg-brand/15";
+
+ // ── Date helpers (all local-time; values are ISO YYYY-MM-DD strings) ──
+
+ function padNumber(value, width) {
+ var text = String(value);
+ while (text.length < width) text = "0" + text;
+ return text;
+ }
+
+ function isoFromDate(dateObject) {
+ return (
+ padNumber(dateObject.getFullYear(), 4) +
+ "-" +
+ padNumber(dateObject.getMonth() + 1, 2) +
+ "-" +
+ padNumber(dateObject.getDate(), 2)
+ );
+ }
+
+ function dateFromIso(isoString) {
+ var pieces = isoString.split("-");
+ return new Date(
+ parseInt(pieces[0], 10),
+ parseInt(pieces[1], 10) - 1,
+ parseInt(pieces[2], 10)
+ );
+ }
+
+ function addDays(dateObject, dayCount) {
+ var copy = new Date(dateObject.getTime());
+ copy.setDate(copy.getDate() + dayCount);
+ return copy;
+ }
+
+ /** Validate a (year, month, day) triple as a real calendar date. */
+ function isoFromParts(year, month, day) {
+ var candidate = new Date(year, month - 1, day);
+ if (
+ candidate.getFullYear() !== year ||
+ candidate.getMonth() !== month - 1 ||
+ candidate.getDate() !== day
+ ) {
+ return "";
+ }
+ return isoFromDate(candidate);
+ }
+
+ function presetRange(presetName) {
+ var today = new Date();
+ today.setHours(0, 0, 0, 0);
+ var yesterday = addDays(today, -1);
+ var year = today.getFullYear();
+ var month = today.getMonth();
+ switch (presetName) {
+ case "today":
+ return [today, today];
+ case "yesterday":
+ return [yesterday, yesterday];
+ case "last_7_days":
+ return [addDays(today, -6), today];
+ case "last_30_days":
+ return [addDays(today, -29), today];
+ case "this_month":
+ return [new Date(year, month, 1), new Date(year, month + 1, 0)];
+ case "last_month":
+ return [new Date(year, month - 1, 1), new Date(year, month, 0)];
+ case "this_year":
+ return [new Date(year, 0, 1), new Date(year, 11, 31)];
+ default:
+ return null;
+ }
+ }
+
+ // ── DateRangeField: segmented manual entry ──────────────────────────────
+
+ function segmentBuffer(segment) {
+ return segment.dataset.typedDigits || "";
+ }
+
+ function setSegmentBuffer(segment, buffer) {
+ segment.dataset.typedDigits = buffer;
+ if (buffer === "") {
+ segment.value = "";
+ return;
+ }
+ var placeholder = segment.getAttribute("placeholder");
+ // Fill the placeholder from the right: typing 19 into YYYY shows YY19.
+ segment.value = placeholder.slice(0, placeholder.length - buffer.length) + buffer;
+ }
+
+ function segmentsForSide(picker, side) {
+ return Array.prototype.slice.call(
+ picker.querySelectorAll('input[data-date-side="' + side + '"]')
+ );
+ }
+
+ /** Recompute one hidden ISO input from its side's segment buffers. */
+ function syncHiddenFromSegments(picker, side) {
+ var hidden = picker.querySelector(
+ 'input[data-date-range-hidden="' + side + '"]'
+ );
+ var partValues = {};
+ var complete = true;
+ segmentsForSide(picker, side).forEach(function (segment) {
+ var buffer = segmentBuffer(segment);
+ if (buffer.length !== parseInt(segment.getAttribute("maxlength"), 10)) {
+ complete = false;
+ }
+ partValues[segment.dataset.datePart] = buffer;
+ });
+ var previousValue = hidden.value;
+ if (complete) {
+ hidden.value = isoFromParts(
+ parseInt(partValues.year, 10),
+ parseInt(partValues.month, 10),
+ parseInt(partValues.day, 10)
+ );
+ } else {
+ hidden.value = "";
+ }
+ return hidden.value !== previousValue;
+ }
+
+ /** Push an ISO value (or "") into a side's segments and hidden input. */
+ function setSideValue(picker, side, isoString) {
+ var hidden = picker.querySelector(
+ 'input[data-date-range-hidden="' + side + '"]'
+ );
+ hidden.value = isoString;
+ var partValues = { year: "", month: "", day: "" };
+ if (isoString) {
+ var pieces = isoString.split("-");
+ partValues = { year: pieces[0], month: pieces[1], day: pieces[2] };
+ }
+ segmentsForSide(picker, side).forEach(function (segment) {
+ setSegmentBuffer(segment, partValues[segment.dataset.datePart]);
+ });
+ }
+
+ function initField(picker, calendarState) {
+ var field = picker.querySelector("[data-date-range-field]");
+ var segments = Array.prototype.slice.call(
+ picker.querySelectorAll("input[data-date-part]")
+ );
+
+ // Adopt server-rendered values (prefilled filter) as typed buffers.
+ segments.forEach(function (segment) {
+ if (segment.value) setSegmentBuffer(segment, segment.value);
+ });
+
+ // Clicking anywhere in the container that is not a date part activates
+ // the first date part.
+ field.addEventListener("mousedown", function (event) {
+ if (event.target.closest("input[data-date-part]")) return;
+ if (event.target.closest("[data-date-range-calendar-toggle]")) return;
+ event.preventDefault();
+ segments[0].focus();
+ });
+
+ segments.forEach(function (segment, segmentIndex) {
+ segment.addEventListener("keydown", function (event) {
+ if (event.key === "Tab") return; // native Tab / Shift+Tab navigation
+ if (event.key === "Enter") return; // let the filter form submit
+ if (event.key === "Backspace" || event.key === "Delete") {
+ event.preventDefault();
+ setSegmentBuffer(segment, "");
+ syncHiddenFromSegments(picker, segment.dataset.dateSide);
+ return;
+ }
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
+ event.preventDefault();
+ if (!/^[0-9]$/.test(event.key)) return; // only numbers can be typed
+ var maximumLength = parseInt(segment.getAttribute("maxlength"), 10);
+ var buffer = segmentBuffer(segment);
+ // Typing into an already-full part starts it over.
+ buffer = buffer.length >= maximumLength ? event.key : buffer + event.key;
+ setSegmentBuffer(segment, buffer);
+ syncHiddenFromSegments(picker, segment.dataset.dateSide);
+ if (buffer.length === maximumLength && segmentIndex + 1 < segments.length) {
+ segments[segmentIndex + 1].focus();
+ }
+ });
+ // Swallow any input that bypassed keydown (e.g. IME/paste).
+ segment.addEventListener("input", function () {
+ setSegmentBuffer(segment, segmentBuffer(segment));
+ });
+ segment.addEventListener("focus", function () {
+ if (calendarState) calendarState.refreshFromField();
+ });
+ });
+ }
+
+ // ── DateRangeCalendar: popup month grid ────────────────────────────────
+
+ function createCalendarState(picker) {
+ var popup = picker.querySelector("[data-date-range-calendar]");
+ var grid = popup.querySelector("[data-date-range-grid]");
+ var monthLabel = popup.querySelector("[data-date-range-month-label]");
+
+ var today = new Date();
+ var state = {
+ open: false,
+ viewYear: today.getFullYear(),
+ viewMonth: today.getMonth(),
+ startIso: "",
+ endIso: "",
+ // The anchor is the fixed endpoint: "start" while picking the EndDate,
+ // "end" once the range is complete (further picks move the StartDate).
+ anchor: "",
+ hoverIso: "",
+ // True while showing a committed range the user has not edited yet —
+ // the track renders muted until the first pick.
+ readOnly: false,
+ };
+
+ function hiddenValue(side) {
+ return picker.querySelector(
+ 'input[data-date-range-hidden="' + side + '"]'
+ ).value;
+ }
+
+ state.refreshFromField = function () {
+ if (state.open) return;
+ state.startIso = hiddenValue("min");
+ state.endIso = hiddenValue("max");
+ };
+
+ function syncSelectionToField() {
+ setSideValue(picker, "min", state.startIso);
+ setSideValue(picker, "max", state.endIso);
+ }
+
+ function openPopup() {
+ state.startIso = hiddenValue("min");
+ state.endIso = hiddenValue("max");
+ state.anchor = state.startIso && state.endIso ? "end" : state.startIso ? "start" : "";
+ state.readOnly = Boolean(state.startIso && state.endIso);
+ state.hoverIso = "";
+ var focusDate = state.startIso ? dateFromIso(state.startIso) : new Date();
+ state.viewYear = focusDate.getFullYear();
+ state.viewMonth = focusDate.getMonth();
+ state.open = true;
+ popup.classList.remove("hidden");
+ render();
+ }
+
+ function closePopup() {
+ state.open = false;
+ state.hoverIso = "";
+ popup.classList.add("hidden");
+ }
+
+ function clearSelection() {
+ state.startIso = "";
+ state.endIso = "";
+ state.anchor = "";
+ state.hoverIso = "";
+ state.readOnly = false;
+ syncSelectionToField();
+ }
+
+ /**
+ * Anchor-style picking:
+ * - no selection: the pick becomes the StartDate anchor
+ * - anchor=start (picking EndDate): a pick on/after the StartDate
+ * completes the range and moves the anchor to the EndDate; a pick
+ * before it clears the range and restarts
+ * - anchor=end (adjusting StartDate): a pick on/before the EndDate
+ * moves the StartDate (extend/shorten); a pick after it clears the
+ * range and restarts from the clicked date
+ */
+ function pickDate(isoString) {
+ state.readOnly = false;
+ if (!state.startIso) {
+ state.startIso = isoString;
+ state.anchor = "start";
+ } else if (state.anchor === "start" && !state.endIso) {
+ if (isoString >= state.startIso) {
+ state.endIso = isoString;
+ state.anchor = "end";
+ } else {
+ state.startIso = isoString;
+ state.endIso = "";
+ state.anchor = "start";
+ }
+ } else {
+ if (isoString <= state.endIso) {
+ state.startIso = isoString;
+ } else {
+ state.startIso = isoString;
+ state.endIso = "";
+ state.anchor = "start";
+ }
+ }
+ syncSelectionToField();
+ render();
+ }
+
+ function applyPreset(presetName) {
+ var range = presetRange(presetName);
+ if (!range) return;
+ state.startIso = isoFromDate(range[0]);
+ state.endIso = isoFromDate(range[1]);
+ state.anchor = "end";
+ state.readOnly = false;
+ state.viewYear = range[0].getFullYear();
+ state.viewMonth = range[0].getMonth();
+ syncSelectionToField();
+ render();
+ }
+
+ /** The (inclusive-exclusive of endpoints) track between the two range
+ * ends; while picking the second date the hovered day acts as the
+ * provisional other end. */
+ function trackBounds() {
+ if (state.startIso && state.endIso) {
+ return [state.startIso, state.endIso, state.readOnly ? TRACK_MUTED_CLASS : TRACK_FILLED_CLASS];
+ }
+ if (state.startIso && state.hoverIso && state.hoverIso !== state.startIso) {
+ var lower = state.hoverIso < state.startIso ? state.hoverIso : state.startIso;
+ var upper = state.hoverIso < state.startIso ? state.startIso : state.hoverIso;
+ return [lower, upper, TRACK_OUTLINED_CLASS];
+ }
+ return null;
+ }
+
+ function dayCellClass(isoString, inViewMonth) {
+ var classes = [DAY_BASE_CLASS];
+ var isStart = isoString === state.startIso;
+ var isEnd = isoString === state.endIso;
+ var isAnchor =
+ (state.anchor === "start" && isStart) || (state.anchor === "end" && isEnd);
+ var track = trackBounds();
+ var inTrack = track && isoString > track[0] && isoString < track[1];
+ if (inTrack) {
+ classes.push(track[2]);
+ } else {
+ classes.push(DAY_ROUNDED_CLASS);
+ }
+ if (isAnchor && !state.readOnly) {
+ classes.push(DAY_ANCHOR_CLASS);
+ } else if (isStart || isEnd) {
+ classes.push(DAY_SELECTED_CLASS);
+ } else if (!inViewMonth) {
+ classes.push(DAY_OUTSIDE_MONTH_CLASS);
+ }
+ return classes.join(" ");
+ }
+
+ function render() {
+ monthLabel.textContent = new Date(
+ state.viewYear,
+ state.viewMonth,
+ 1
+ ).toLocaleDateString(undefined, { month: "long", year: "numeric" });
+
+ grid.textContent = "";
+ WEEKDAY_LABELS.forEach(function (weekdayLabel) {
+ var headerCell = document.createElement("span");
+ headerCell.className = WEEKDAY_CLASS;
+ headerCell.textContent = weekdayLabel;
+ grid.appendChild(headerCell);
+ });
+
+ var firstOfMonth = new Date(state.viewYear, state.viewMonth, 1);
+ // Monday-first offset of the leading overflow days.
+ var leadingDays = (firstOfMonth.getDay() + 6) % 7;
+ var cellDate = addDays(firstOfMonth, -leadingDays);
+ for (var cellIndex = 0; cellIndex < 42; cellIndex++) {
+ var isoString = isoFromDate(cellDate);
+ var dayButton = document.createElement("button");
+ dayButton.type = "button";
+ dayButton.setAttribute("data-date", isoString);
+ dayButton.className = dayCellClass(
+ isoString,
+ cellDate.getMonth() === state.viewMonth
+ );
+ dayButton.textContent = String(cellDate.getDate());
+ grid.appendChild(dayButton);
+ cellDate = addDays(cellDate, 1);
+ }
+ }
+
+ // ── Wiring ──
+ picker
+ .querySelector("[data-date-range-calendar-toggle]")
+ .addEventListener("click", function () {
+ if (state.open) closePopup();
+ else openPopup();
+ });
+
+ grid.addEventListener("click", function (event) {
+ var dayButton = event.target.closest("button[data-date]");
+ if (dayButton) pickDate(dayButton.getAttribute("data-date"));
+ });
+
+ grid.addEventListener("mouseover", function (event) {
+ if (!state.startIso || state.endIso) return;
+ var dayButton = event.target.closest("button[data-date]");
+ if (!dayButton) return;
+ var hoveredIso = dayButton.getAttribute("data-date");
+ if (hoveredIso === state.hoverIso) return;
+ state.hoverIso = hoveredIso;
+ render();
+ });
+
+ popup
+ .querySelector("[data-date-range-prev]")
+ .addEventListener("click", function () {
+ state.viewMonth -= 1;
+ if (state.viewMonth < 0) {
+ state.viewMonth = 11;
+ state.viewYear -= 1;
+ }
+ render();
+ });
+
+ popup
+ .querySelector("[data-date-range-next]")
+ .addEventListener("click", function () {
+ state.viewMonth += 1;
+ if (state.viewMonth > 11) {
+ state.viewMonth = 0;
+ state.viewYear += 1;
+ }
+ render();
+ });
+
+ popup.querySelectorAll("[data-date-range-preset]").forEach(function (button) {
+ button.addEventListener("click", function () {
+ applyPreset(button.getAttribute("data-date-range-preset"));
+ });
+ });
+
+ // Cancel: close the popup and clear the selected dates.
+ popup
+ .querySelector("[data-date-range-cancel]")
+ .addEventListener("click", function () {
+ clearSelection();
+ closePopup();
+ });
+
+ // Clear: clear the selected dates but keep the popup open.
+ popup
+ .querySelector("[data-date-range-clear]")
+ .addEventListener("click", function () {
+ clearSelection();
+ render();
+ });
+
+ // Select: close the popup, keeping the selected dates.
+ popup
+ .querySelector("[data-date-range-select]")
+ .addEventListener("click", function () {
+ closePopup();
+ });
+
+ document.addEventListener("keydown", function (event) {
+ if (event.key === "Escape" && state.open) closePopup();
+ });
+
+ document.addEventListener("mousedown", function (event) {
+ if (state.open && !picker.contains(event.target)) closePopup();
+ });
+
+ return state;
+ }
+
+ function initPicker(picker) {
+ if (picker.dataset.dateRangePickerInitialized) return;
+ picker.dataset.dateRangePickerInitialized = "true";
+ var calendarState = createCalendarState(picker);
+ initField(picker, calendarState);
+ }
+
+ function initAllPickers() {
+ document.querySelectorAll("[data-date-range-picker]").forEach(initPicker);
+ }
+
+ window.initDateRangePickers = initAllPickers;
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", initAllPickers);
+ } else {
+ initAllPickers();
+ }
+})();
diff --git a/games/views/purchase.py b/games/views/purchase.py
index 2707e7f..1de80f4 100644
--- a/games/views/purchase.py
+++ b/games/views/purchase.py
@@ -143,6 +143,7 @@ def list_purchases(request: HttpRequest) -> HttpResponse:
title="Manage purchases",
scripts=ModuleScript("range_slider.js")
+ ModuleScript("search_select.js")
+ + ModuleScript("date_range_picker.js")
+ ModuleScript("filter_bar.js"),
)
diff --git a/tests/test_date_range_picker.py b/tests/test_date_range_picker.py
new file mode 100644
index 0000000..62b9694
--- /dev/null
+++ b/tests/test_date_range_picker.py
@@ -0,0 +1,196 @@
+"""Unit tests for the DateRangePicker component family.
+
+Pins the structural contract of DateRangeField / DateRangeCalendar /
+DateRangePicker — segment inputs ordered by ``dateformat_hyphenated``, the
+hidden ISO ``{prefix}-min`` / ``{prefix}-max`` inputs that ``filter_bar.js``
+serializes, the calendar's preset/footer hooks — and the PurchaseFilterBar
+integration that replaced the native-date DateRangeFilter for the Purchased
+field.
+"""
+
+import json
+import re
+
+from django.test import SimpleTestCase, TestCase
+
+from common.components import (
+ DateRangeCalendar,
+ DateRangeField,
+ DateRangePicker,
+ PurchaseFilterBar,
+)
+from common.time import date_parts, dateformat_hyphenated
+
+_ESCAPED_TAG_MARKERS = ["<div", "<span", "<button", "<input"]
+
+
+class DatePartsTest(SimpleTestCase):
+ def test_default_format_yields_day_month_year(self):
+ parts = date_parts()
+ self.assertEqual([part.name for part in parts], ["day", "month", "year"])
+ self.assertEqual([part.placeholder for part in parts], ["DD", "MM", "YYYY"])
+ self.assertEqual([part.length for part in parts], [2, 2, 4])
+
+ def test_parts_follow_format_order(self):
+ parts = date_parts("%Y-%d-%m")
+ self.assertEqual([part.name for part in parts], ["year", "day", "month"])
+
+ def test_dateformat_hyphenated_is_parseable(self):
+ self.assertEqual(len(date_parts(dateformat_hyphenated)), 3)
+
+
+class DateRangeFieldTest(SimpleTestCase):
+ def render(self, **kwargs):
+ defaults = {"label": "Purchased", "input_name_prefix": "filter-date-purchased"}
+ defaults.update(kwargs)
+ return str(DateRangeField(**defaults))
+
+ def test_renders_hidden_iso_inputs(self):
+ html = self.render(min_value="2024-03-15", max_value="2024-09-20")
+ self.assertIn('name="filter-date-purchased-min"', html)
+ self.assertIn('name="filter-date-purchased-max"', html)
+ self.assertIn('data-date-range-hidden="min"', html)
+ self.assertIn('data-date-range-hidden="max"', html)
+ self.assertIn('value="2024-03-15"', html)
+ self.assertIn('value="2024-09-20"', html)
+
+ def test_renders_segments_in_dateformat_order_for_both_sides(self):
+ html = self.render()
+ for side in ("min", "max"):
+ side_segments = re.findall(
+ rf'data-date-part="(\w+)" data-date-side="{side}"', html
+ )
+ self.assertEqual(side_segments, ["day", "month", "year"])
+
+ def test_segment_placeholders_and_lengths(self):
+ html = self.render()
+ self.assertEqual(html.count('placeholder="DD"'), 2)
+ self.assertEqual(html.count('placeholder="MM"'), 2)
+ self.assertEqual(html.count('placeholder="YYYY"'), 2)
+ self.assertEqual(html.count('maxlength="2"'), 4)
+ self.assertEqual(html.count('maxlength="4"'), 2)
+ self.assertEqual(html.count('inputmode="numeric"'), 6)
+
+ def test_prefills_segments_from_iso_values(self):
+ html = self.render(min_value="2024-03-15")
+ self.assertIn('value="15" data-date-part="day" data-date-side="min"', html)
+ self.assertIn('value="03" data-date-part="month" data-date-side="min"', html)
+ self.assertIn('value="2024" data-date-part="year" data-date-side="min"', html)
+ # The max side stays empty.
+ self.assertIn('value="" data-date-part="day" data-date-side="max"', html)
+
+ def test_malformed_iso_value_renders_empty_segments(self):
+ html = self.render(min_value="not-a-date")
+ self.assertIn('value="" data-date-part="day" data-date-side="min"', html)
+
+ def test_renders_calendar_toggle(self):
+ html = self.render()
+ self.assertIn("data-date-range-calendar-toggle", html)
+ self.assertIn('aria-label="Open Purchased calendar"', html)
+
+ def test_no_native_date_inputs(self):
+ self.assertNotIn('type="date"', self.render())
+
+
+class DateRangeCalendarTest(SimpleTestCase):
+ def render(self):
+ return str(DateRangeCalendar(input_name_prefix="filter-date-purchased"))
+
+ def test_renders_all_presets(self):
+ html = self.render()
+ for preset in (
+ "today",
+ "yesterday",
+ "last_7_days",
+ "last_30_days",
+ "this_month",
+ "last_month",
+ "this_year",
+ ):
+ self.assertIn(f'data-date-range-preset="{preset}"', html)
+
+ def test_renders_footer_buttons(self):
+ html = self.render()
+ self.assertIn("data-date-range-cancel", html)
+ self.assertIn("data-date-range-clear", html)
+ self.assertIn("data-date-range-select", html)
+ self.assertIn(">Cancel<", html)
+ self.assertIn(">Clear<", html)
+ self.assertIn(">Select<", html)
+
+ def test_renders_grid_and_navigation_hooks(self):
+ html = self.render()
+ self.assertIn("data-date-range-grid", html)
+ self.assertIn("data-date-range-month-label", html)
+ self.assertIn("data-date-range-prev", html)
+ self.assertIn("data-date-range-next", html)
+
+ def test_starts_hidden(self):
+ self.assertIn('class="hidden absolute', self.render())
+
+ def test_all_buttons_are_type_button(self):
+ """No button inside the calendar may submit the surrounding filter form."""
+ html = self.render()
+ button_count = html.count("