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("