diff --git a/docs/design-date-range-picker-2026-05-25.md b/docs/design-date-range-picker-2026-05-25.md new file mode 100644 index 0000000..ef63d70 --- /dev/null +++ b/docs/design-date-range-picker-2026-05-25.md @@ -0,0 +1,856 @@ +# 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 `