# Symmetric date-range picker — design **Date:** 2026-05-25 **Task:** t-paliad-248 (Gitea m/paliad#79) **Inventor:** atlas **Branch:** `mai/atlas/inventor-symmetric-date` **Status:** READ-ONLY design. Awaiting head's go/no-go before coder shift. --- ## §0 TL;DR Today paliad has **three independent date-range schemes** scattered across surfaces: 1. **`/agenda`** — future-only chip row [7|14|30|90 Tage], state `rangeDays`. 2. **`/admin/audit-log`** — past-only `` pair. 3. **`/projects/:id/chart`** — symmetric `RangePreset` [1y|2y|all|custom] + manual date pair. …plus a **fourth, unified `TimeHorizon` contract** (`internal/services/filter_spec.go`, mirrored in `frontend/src/client/views/types.ts`) that's used by the filter-bar, Verlauf, Custom Views, and InboxFilterBar — but its "Anpassen" custom-range chip is still stubbed (`filter-bar/axes.ts:105-112`, marked Phase 2, disabled, "coming soon" tooltip). The fix is **not** "build a fourth scheme." The fix is to **finish the TimeHorizon contract** (add `past_14d`, `next_14d`, `past_all`, `next_all`), build **one reusable ``** that emits a `TimeSpec`, then migrate the three legacy affordances to it. **Layout (m's brief, locked):** ``` ┌──────────────────────────────────────────────────────────────┐ │ [Zeitraum: Nächste 30 Tage ▾] │ └──────────────────────────────────────────────────────────────┘ ↓ click to open ┌──────────────────────────────────────────────────────────────┐ │ Vergangenheit (ALLE) Zukunft │ │ [Ganze Vergangenheit] [⌖ ALLE] [Ganze Zukunft] │ │ [90 T] [30 T] [14 T] [7 T] [7 T] [14 T] [30 T] [90 T] │ │ │ │ ── oder benutzerdefiniert ── │ │ Von [____.____.____] Bis [____.____.____] [Anwenden] │ └──────────────────────────────────────────────────────────────┘ ``` **Slice plan:** - **Slice A** — `` component + 4 new horizon constants (`past_14d`, `next_14d`, `past_all`, `next_all`). Wired onto filter-bar `time` axis first (lights up Verlauf + InboxFilterBar + views simultaneously by replacing the stubbed Phase-2 chip). - **Slice B** — `/agenda` migrates (highest-traffic standalone consumer). - **Slice C** — `/admin/audit-log` + `/projects/:id/chart` migrate. Each surface picks the preset subset it cares about. - **Slice D** *(optional, later)* — upckommentar-style two-handle slicer replaces the inline date-pair for the "custom" mode. **Hard rules honoured:** - No new top-level table or migration in Slice A — purely additive enum values + Go switch arms. - No new dependency in Slice A — slicer is deferred (it's a non-trivial port from Svelte to paliad's plain TSX renderer). - Backward-compatible URL shape — each surface keeps its current short-alias parser (e.g. `?range=30` → `horizon=next_30d`) and additionally accepts the canonical `?horizon=…&from=…&to=…`. --- ## §1 Current state — every date-range affordance Cataloguing **every** place a paliad user picks a past/future window, with file:line refs. ### 1.1 `/agenda` — future-only chip row `frontend/src/agenda.tsx:64-67`: ```tsx ``` State machine `frontend/src/client/agenda.ts:80-104`: - `state.rangeDays ∈ {7,14,30,90}` (set `VALID_RANGES`). Default `30`. - URL: `?range=30&types=…&event_type=…`. - Fetch: `GET /api/agenda?from=&to=&types=…`. - **Future-only by construction** — m's complaint applies precisely here. No "past 7 days" affordance, no "all" affordance. ### 1.2 `/admin/audit-log` — past-only ` ``` State machine `frontend/src/client/admin-audit-log.ts:135-174`: - `rangePresetToFrom(preset)` converts `"24h" | "7d" | "30d"` → `Date`. `"custom"` reads `from`/`to` inputs. `"all"` clears both bounds. - URL: `?source=…&range=7d&q=…&from=…&to=…&limit=…&before_ts=…&before_id=…` (cursor-paged). - **Past-only by construction.** No future-projection — this is an audit log, looking forward makes no sense. ### 1.3 `/projects/:id/chart` — symmetric `RangePreset` `frontend/src/client/views/types.ts:77-79`: ```ts range_preset?: "1y" | "2y" | "all" | "custom"; range_from?: string; range_to?: string; ``` UI `frontend/src/projects-chart.tsx:78-82`: ```tsx ``` State machine `frontend/src/client/projects-chart.ts:73-118`: - `rangeFromURL()` → `{preset, from?, to?}` with default `"1y"`. - "1y" = `today-1y..today+1y`, "2y" = `today-2y..today+2y`, "all" derived from loaded events, "custom" = read inputs. - URL: `?range=1y&from=YYYY-MM-DD&to=YYYY-MM-DD`. - **Symmetric around today** by construction — this is a chart, not a filter; the user is panning a viewport, not picking a fan. ### 1.4 `views-editor.tsx` (Custom Views config form) `frontend/src/views-editor.tsx:102-109`: ```tsx ``` - Mixes past + future, but only 5 horizons exposed (no 14d, no past_7d, no all). - Persists into `paliad.user_views.filter_spec` (JSON column) as a `TimeSpec`. - **This is the closest existing affordance to m's symmetric fan**, but rendered as a plain `` × 2 + CSS — or vendor the lib's pure parts. | ### 2.8 What does NOT apply to paliad - **Floor at 2023-01-01.** upckommentar starts at the UPC's first day. paliad has decade-old patents and future-projecting deadlines; the axis must extend in both directions. We use `today ± 5 years` as the default visible range with `past_all` / `next_all` chips to escape it. - **Single granularity locked per session.** upckommentar's UI shows one of year/month/day at a time. paliad's typical use ("next 30 days for the deadline list") doesn't benefit from a zoom; the chips ARE the granularity. Slicer in Slice D only opens when the user picks "Anpassen" — at which point the zoom UI makes sense. --- ## §3 Component design — `` ### 3.1 Public API ```ts type TimeHorizonExt = | "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all" | "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all" | "any" | "custom"; interface DateRangePickerProps { // Current state. The component is fully controlled. value: TimeSpec; onChange: (next: TimeSpec) => void; // Per-surface preset filter — omit a chip by leaving it out of the array. // Default: all symmetric chips + "any" + "custom". presets?: TimeHorizonExt[]; // Closed-state button label override. Defaults to the i18n key for value.horizon // (e.g. "Letzte 30 Tage"). Override for surfaces that want a heading prefix // like "Zeitraum: Letzte 30 Tage". labelPrefix?: string; // i18n strings consumed via the i18n.ts dictionary. No props for individual labels. // Localisation flows through existing data-i18n attributes. // Surface tag — used to derive a stable testid and URL-param namespace if // the host wires URL serialization through helpers we provide (see §4). surface: string; // e.g. "agenda" | "audit-log" | "filter-bar" // Mode — popover (default) or modal (rare). mode?: "popover" | "modal"; // Anchor / placement for popover mode. Defaults to "below". placement?: "below" | "above" | "right"; } ``` `TimeSpec` mirrors the existing shape (`internal/services/filter_spec.go:107-112`), extended with the 4 new horizon values: ```ts interface TimeSpec { horizon: TimeHorizonExt; field?: "auto" | "created_at"; from?: string; // ISO YYYY-MM-DD; set only when horizon === "custom" to?: string; } ``` ### 3.2 States The component is a small state machine: ``` closed ────[click button]────► open ▲ │ └──[click outside / Esc]───────┘ │ open ───[click chip]──── closed (commit immediately) │ open ───[click "Anpassen"]► custom-editor │ custom-editor ─[Anwenden]► closed (commit) custom-editor ─[Esc]─────► open ``` - **closed** — single button with current selection label and a chevron `▾`. No outline/highlight unless the value is not the default for this surface. - **open** — popover anchored below the button (or below-then-flip-up on viewport-bottom). Contains the symmetric chip row + ALL center + "Anpassen" sub-section. - **custom-editor** — replaces the "Anpassen" link with two `` + "Anwenden" / "Abbrechen" buttons. (In Slice D this becomes the slicer.) ### 3.3 Symmetric chip layout The popover body — full ASCII sketch: ``` ┌─────────────────────────────────────────────────────────────┐ │ ╭ Vergangenheit ────────╮ ╭ ALLES ╮ ╭ Zukunft ───────────╮ │ │ │ [Ganze Vergangenheit] │ │ [⌖] │ │ [Ganze Zukunft] │ │ │ │ [Letzte 90 Tage] │ │ │ │ [Nächste 7 Tage] │ │ │ │ [Letzte 30 Tage] │ │ │ │ [Nächste 14 Tage] │ │ │ │ [Letzte 14 Tage] │ │ │ │ [Nächste 30 Tage] │ │ │ │ [Letzte 7 Tage] │ │ │ │ [Nächste 90 Tage] │ │ │ ╰───────────────────────╯ ╰───────╯ ╰────────────────────╯ │ │ │ │ ── Anpassen ────────────────────────────────────────── │ │ Von [____.____.____] Bis [____.____.____] [Anwenden] │ └─────────────────────────────────────────────────────────────┘ ``` Visual cues: - The currently-selected chip gets the **lime accent** (`--color-bg-lime-tint` background, `--color-text` text, `--color-accent` border) — matches existing `.agenda-chip-active` so we don't introduce a new active state. - The "ALLES" center button is **larger** than the fan chips (44px tall vs. 32px), drawn with a target-style glyph `⌖` (or `∞` — see Q3.B). Inventor pick: `⌖` plus the word "ALLES" beneath. Larger so it reads as "the no-filter affordance," not as one chip among many. - The two fans are visually **mirrored** — past on the left, future on the right. Both have a "Ganze …" terminal chip at the outer edge (left-most for past_all, right-most for next_all) and decreasing-magnitude chips fanning toward the center. The ordering matches the human intuition: "left = back in time, right = forward in time." - On viewports < 480px the popover stacks vertically (past fan above, ALL middle, future fan below). On viewports < 360px the popover becomes a modal-feeling slide-up sheet (existing inbox modal CSS pattern reusable). ### 3.4 Sketch of the closed button states ``` default: ┌─Zeitraum: Nächste 30 Tage ▾─┐ custom: ┌─Zeitraum: 15.03.2026 – 30.04.2026 ▾─┐ any: ┌─Zeitraum: Alles ▾─┐ past_all: ┌─Zeitraum: Ganze Vergangenheit ▾─┐ hover/open: same + outline + bg-accent-tint ``` When the value is **not** the surface default, an additional small `●` dot appears between "Zeitraum:" and the value — the existing universal "filter is non-default" indicator used by the filter-bar. ### 3.5 Keyboard - `Tab` lands on the button. `Enter`/`Space` opens the popover. - `Esc` from open state closes it. `Esc` from custom-editor returns to chip view (one level back). - Chips are focusable buttons in the natural left-to-right reading order: past_all → past_90 → past_30 → past_14 → past_7 → any (center) → next_7 → next_14 → next_30 → next_90 → next_all. - The custom date inputs are `` — gets the OS-native picker on macOS / iOS / Android / Windows. No new custom calendar widget. ### 3.6 Accessibility - The button has `aria-haspopup="dialog"` and `aria-expanded` toggled on open/close. - The popover has `role="dialog"` with `aria-label` = `t("date_range.dialog.label")` ("Zeitraum wählen" / "Choose date range"). - Chips are `