Files
paliad/docs/design-date-range-picker-2026-05-25.md
mAi 0f2f3e3ea1 docs(date-range-picker): inventor design — symmetric past/future fan + ALL center
t-paliad-248 / m/paliad#79.

§0 TL;DR + §1 audit of every paliad date-range affordance today
(/agenda chip row, /admin/audit-log select, /projects/:id/chart
symmetric range, /views editor, filter-bar time axis with stubbed
Anpassen chip, projects-detail Verlauf horizonBounds).

§2 upckommentar slicer pattern — read DateRangeSlider.svelte +
date-range-slider-pure.ts end-to-end. Borrow worth: anchor rail with
click-to-snap left/right halves, granularity zoom, epoch-day pure
math. Defer the actual slicer to Slice D.

§3 component design — <DateRangePicker> emits TimeSpec, extends
TimeHorizon with past_14d / next_14d / past_all / next_all
(additive; no migration). Symmetric chip fan layout, lime accent
for active, target glyph ⌖ for ALLES center button.

§4 URL contract — canonical ?horizon=…&from=…&to=…, surface-level
alias adapters for back-compat with existing ?range=N parsers.

§5 slice plan — A: filter-bar time axis (lights up 4 surfaces) /
B: /agenda / C: /admin/audit-log + /projects/:id/chart (sibling
SymmetricRangePicker for chart) / D (optional): slicer port.

§6 visual decisions, §7 edge cases, §8 open questions w/ (R)
defaults. 3 material picks escalated separately via mai instruct
head: chart migration shape, popover-vs-modal, Slice A first call
site.

§9 implementer notes + acceptance criteria for Slice A. §10
escalation-message summary.
2026-05-25 15:34:03 +02:00

48 KiB
Raw Permalink Blame History

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 <select> [24h|7d|30d|custom|all] + manual <input type="date"> 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 <DateRangePicker> 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<DateRangePicker> 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=30horizon=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:

<button className="agenda-chip" data-range="7"  >7 Tage</button>
<button className="agenda-chip" data-range="14" >14 Tage</button>
<button className="agenda-chip" data-range="30" >30 Tage</button>
<button className="agenda-chip" data-range="90" >90 Tage</button>

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=<today>&to=<today+rangeDays-1>&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 <select> + manual date pair

frontend/src/admin-audit-log.tsx:50-65:

<select id="audit-range">
  <option value="24h">Letzte 24h</option>
  <option value="7d" selected>Letzte 7 Tage</option>
  <option value="30d">Letzte 30 Tage</option>
  <option value="custom">Benutzerdefiniert</option>
  <option value="all">Alles</option>
</select>
<!-- custom toggles a date-pair: -->
<input type="date" id="audit-from" />
<input type="date" id="audit-to" />

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:

range_preset?: "1y" | "2y" | "all" | "custom";
range_from?: string;
range_to?: string;

UI frontend/src/projects-chart.tsx:78-82:

<input type="date" id="projects-chart-range-from" />
<input type="date" id="projects-chart-range-to" />

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:

<select id="editor-time-horizon">
  <option value="next_7d">Nächste 7 Tage</option>
  <option value="next_30d">Nächste 30 Tage</option>
  <option value="next_90d">Nächste 90 Tage</option>
  <option value="past_30d">Letzte 30 Tage</option>
  <option value="past_90d">Letzte 90 Tage</option>
  <option value="any">Beliebig</option>
</select>
  • 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 <select> and incomplete.

1.5 Filter-bar time axis (riemann's t-paliad-163 Phase 1)

frontend/src/client/filter-bar/axes.ts:65-115:

  • Renders a chip cluster: [next_7d, next_30d, next_90d, past_30d, any] (default presets, line 77-79).
  • "Anpassen" chip is disabled with coming_soon tooltip (line 108-112). This is the documented Phase 2 substrate.
  • Surfaces declaring axis time thread their own preset list via RenderAxisOpts.timePresets — e.g. Verlauf overrides to ["past_7d","past_30d","past_90d","any"] (frontend/src/client/projects-detail.ts:2310).

Consumers:

  • /projects/:id Verlauf (projects-detail.ts:2296 initial state, 2310 preset override).
  • /views and /views/:id (Custom Views runtime).
  • /inbox (InboxFilterBar flow — t-paliad-138/139 derived inbox).

1.6 horizonBounds() — the materializer

frontend/src/client/projects-detail.ts:393-406 mirrors the Go-side computeViewSpecBounds() (internal/services/view_service.go:156-187):

case "past_7d":  return { from: offset(-7),  to: offset(1) };
case "past_30d": return { from: offset(-30), to: offset(1) };
case "past_90d": return { from: offset(-90), to: offset(1) };
case "next_7d":  return { from: day,         to: offset(7) };
case "next_30d": return { from: day,         to: offset(30) };
case "next_90d": return { from: day,         to: offset(90) };
default:         return {};

(Backend equivalent: internal/services/view_service.go:160-186.)

1.7 Single-date inputs (NOT date-range — listed for completeness)

These are out of scope but mentioned so the audit is exhaustive:

  • verfahrensablauf.tsx:174#trigger-date (calculator anchor).
  • fristenrechner.tsx:496,504,616#trigger-date, #priority-date, #event-date (calculator).
  • admin-rules-edit.tsx:265#preview-trigger-date.
  • deadlines-detail.tsx:82#deadline-due-edit (inline-edit).
  • deadlines-new.tsx:116#deadline-due (form).
  • appointments-new.tsx, appointments-detail.tsxstart_at/end_at.
  • projects-detail.tsx:181#smart-timeline-milestone-date (add-milestone modal).
  • components/ProjectFormFields.tsx:134,138#project-filing-date, #project-grant-date.

1.8 Summary matrix

Surface Direction Presets Custom URL contract Default
/agenda Future 7|14|30|90 ?range=N 30d
/admin/audit-log Past 24h|7d|30d|all + custom date pair ?range=…&from=…&to=… 7d
/projects/:id/chart Symmetric ±N 1y|2y|all + custom date pair ?range=…&from=…&to=… 1y
/views/:id editor Past+Future mix next_7d|next_30d|next_90d|past_30d|past_90d|any persisted JSON next_30d
Filter-bar time axis Past+Future mix next_7d|next_30d|next_90d|past_30d|any stubbed persisted + ?…__time_from= per surface
Verlauf Past + any past_7d|past_30d|past_90d|any stubbed URL past_30d
InboxFilterBar Mix filter-bar default stubbed URL per surface

Three of seven surfaces have incomplete custom-range affordances. None of the seven exposes the full symmetric fan m wants.


§2 upckommentar slicer pattern

Verified by reading source at /home/m/dev/web/upc-kommentar/src/lib/:

  • DateRangeSlider.svelte (component, 448 lines).
  • date-range-slider-pure.ts (pure-math helpers, 487 lines, fully unit-tested).
  • InboxFilterBar.svelte (host).

2.1 What it is

A two-handle range slider that wraps svelte-range-slider-pips (npm: svelte-range-slider-pips@4). The slider's rail is the upckommentar floor (2023-01-01) to today, and the two handles define dateFrom and dateTo. Step is 1 day regardless of zoom.

Public contract (DateRangeSlider.svelte:57-82):

interface Props {
  minISO: string;                 // axis lower bound, default 2023-01-01
  maxISO: string;                 // axis upper bound, today
  fromISO: string | null;         // current From (null = parked at min)
  toISO: string | null;           // current To   (null = parked at max)
  onChange: (from, to) => void;   // emits on every slider change
  testid?: string;
  axisWidthPx?: number;           // test override for jsdom
}

2.2 Anchor rail + granularity

Below the slider rail is a custom-rendered anchor rail (the lib's own pips are hidden via pips={false} because they're evenly-spaced approximations — issue #42 in upckommentar). Anchor day-numbers come from pipAnchorsFor(granularity, minDay, maxDay):

  • year: every Jan 1 in range.
  • month: every 1st-of-month.
  • day: every Monday.

Edges (minDay, maxDay) are always anchors so the user can park at the slider's extremes.

Granularity has +/- zoom buttons in the top-right of the slider (year → month → day), with each level showing more anchors.

2.3 Click-to-snap (left half / right half)

DateRangeSlider.svelte:219-240 + pure helper endOfPeriodDay():

  • Left half of an anchor label → snap closest handle to start of period (the anchor day itself, e.g. Jan 1).
  • Right half of the same label → snap to end of period (Dec 31 for year, last-of-month for month, Sunday for day).
  • Keyboard activation falls back to left-half (start-of-period) deterministically.

2.4 Label thinning + two-row alternation

pipLabelStrideFor() + pipLabelRow() (pure helpers):

  • Measures rail width via ResizeObserver.
  • Computes a stride — only every Nth label is rendered.
  • Adjacent rendered labels alternate row 0 / row 1 (~1.1em offset down) so they can sit closer horizontally without colliding.

2.5 Handle behaviour

  • range=true draws a colored bar between handles.
  • draggy=true lets the user drag the bar itself to shift the window without changing its width.
  • pushy=true — handles push each other when crossed.
  • float=true — tooltip floats above the dragged handle showing DD.MM.YYYY.

2.6 URL contract on host

InboxFilterBar.svelte debounces onChange at 250ms, then writes:

?date_from=2024-03-15&date_to=2024-09-30

When a handle is parked at min/max, that bound is omitted from the URL (valuesToFromTo() in the pure module). So ?date_from=2024-03-15 alone means "from March 15 onwards, no upper bound."

2.7 What's worth borrowing for paliad

Element Borrow? Why
Two-handle drag Yes — but defer to Slice D Excellent fine-tune UX. Non-trivial to port without svelte-range-slider-pips (or a Svelte ↔ TSX adapter).
Anchor rail with click-to-snap Yes (in Slice D) Year/month/Monday anchors are the right granularities.
Label thinning + two-row alternation Yes (in Slice D) Makes the rail readable at any width.
Granularity + zoom +/- Yes (in Slice D) Single most useful interaction; users don't drag pixel-precise.
Epoch-day pure math Yes — verbatim The date-range-slider-pure.ts module is well-tested and dependency-free. Port to TS in paliad's pure-helper layer.
null = parked at edge Yes — already aligned TimeHorizon's past_all / next_all map cleanly to "one bound parked at infinity."
The library svelte-range-slider-pips itself No Adds a Svelte dependency to a non-Svelte project. Slice D would build a tiny equivalent on top of <input type="range"> × 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 — <DateRangePicker>

3.1 Public API

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:

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 <input type="date"> + "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 <input type="date" lang="de"> — 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 <button> with aria-pressed="true" on the active one.
  • The two fan groups have role="group" + aria-label="Vergangenheit" / aria-label="Zukunft".

3.7 Module layout

frontend/src/
├── components/
│   └── DateRangePicker.tsx          ← TSX shell (markup only)
├── client/
│   ├── date-range-picker.ts         ← mount() + state machine + DOM event wiring
│   └── date-range-picker-pure.ts    ← horizon-bounds math, label resolver, parse/serialize
└── styles/
    └── global.css                    ← .date-range-* classes

-pure.ts is the headless module — fully testable under bun test. The boot client in -picker.ts consumes it, mirroring the pattern used by shape-timeline-chart.ts + shape-timeline-chart.test.ts (see memory: t-paliad-173 / gauss).

Pure module exports (preliminary):

export function horizonBounds(h: TimeHorizonExt, now: Date): { from?: Date; to?: Date }
export function labelForHorizon(h: TimeHorizonExt, lang: "de"|"en"): string
export function labelForCustom(from: string, to: string, lang: "de"|"en"): string
export function parseURL(params: URLSearchParams): TimeSpec
export function serializeURL(spec: TimeSpec, defaults: Partial<TimeSpec>): URLSearchParams
export function isDefault(spec: TimeSpec, default_: TimeSpec): boolean

3.8 Go-side additions

internal/services/filter_spec.go:

// Add four new constants alongside the existing TimeHorizon block.
HorizonNext14d  TimeHorizon = "next_14d"
HorizonPast14d  TimeHorizon = "past_14d"
HorizonNextAll  TimeHorizon = "next_all"
HorizonPastAll  TimeHorizon = "past_all"

internal/services/view_service.go:computeViewSpecBounds():

case HorizonNext14d:
    bounds.from = &startOfDay; t := startOfDay.AddDate(0, 0, 14);  bounds.to = &t
case HorizonPast14d:
    f := startOfDay.AddDate(0, 0, -14); bounds.from = &f;          bounds.to = &startOfTomorrow
case HorizonNextAll:
    bounds.from = &startOfDay
    // bounds.to left nil  → "no upper bound"
case HorizonPastAll:
    bounds.to = &startOfTomorrow
    // bounds.from left nil

HorizonNextAll and HorizonPastAll are one-sided unbounded — distinct from existing HorizonAll (bidirectional unbounded) and HorizonAny (no filter at all, same effect as HorizonAll for view-spec runtime but different in intent).

filter_spec.go:validate() (line 280-292) gains the two new past/next constants in the switch.

3.9 i18n keys

Two-language matrix (DE primary, EN secondary):

date_range.button.label                 "Zeitraum"               / "Time range"
date_range.button.label.custom          "Von … bis …"            / "From … to …"
date_range.horizon.next_7d              "Nächste 7 Tage"         / "Next 7 days"
date_range.horizon.next_14d             "Nächste 14 Tage"        / "Next 14 days"
date_range.horizon.next_30d             "Nächste 30 Tage"        / "Next 30 days"
date_range.horizon.next_90d             "Nächste 90 Tage"        / "Next 90 days"
date_range.horizon.next_all             "Ganze Zukunft"          / "All future"
date_range.horizon.past_7d              "Letzte 7 Tage"          / "Last 7 days"
date_range.horizon.past_14d             "Letzte 14 Tage"         / "Last 14 days"
date_range.horizon.past_30d             "Letzte 30 Tage"         / "Last 30 days"
date_range.horizon.past_90d             "Letzte 90 Tage"         / "Last 90 days"
date_range.horizon.past_all             "Ganze Vergangenheit"    / "All past"
date_range.horizon.any                  "Alles"                  / "All"
date_range.horizon.custom               "Benutzerdefiniert"      / "Custom"
date_range.dialog.label                 "Zeitraum wählen"        / "Choose date range"
date_range.fan.past.label               "Vergangenheit"          / "Past"
date_range.fan.future.label             "Zukunft"                / "Future"
date_range.center.label                 "Alles"                  / "All"
date_range.custom.from                  "Von"                    / "From"
date_range.custom.to                    "Bis"                    / "To"
date_range.custom.apply                 "Anwenden"               / "Apply"
date_range.custom.cancel                "Abbrechen"              / "Cancel"
date_range.custom.invalid               "Bis-Datum muss nach Von-Datum liegen." / "End date must be after start date."

Total: 21 keys × 2 langs = 42 new entries in i18n.ts. Existing per-surface keys (agenda.range.7, admin.audit.range.24h, views.bar.time.next_30d etc.) stay until each surface migrates, then get retired.


§4 URL / form serialization contract

4.1 Canonical URL shape

The picker writes (and reads) canonical params on the host's URL:

?horizon=next_30d
?horizon=past_all
?horizon=any           ← omitted if it matches the surface default
?horizon=custom&from=2026-03-15&to=2026-04-30

The host page's URL-init code (bootDateRangePicker(surface, opts)) calls parseURL(searchParams) to derive the initial TimeSpec, then calls serializeURL(spec, defaults) on every change. Params equal to the surface default are omitted so the canonical URL stays short and dedupable — matches the existing writeParamToURL pattern in projects-chart.ts:144-154.

4.2 Backwards-compat aliases

Each migrating surface keeps its existing alias parser for the transition window:

Surface Legacy URL Canonical URL Adapter
/agenda ?range=30 ?horizon=next_30d range=N → horizon=next_${N}d if N ∈ {7,14,30,90}, else next_all for N>90. Read both, write canonical.
/admin/audit-log ?range=7d ?horizon=past_7d range=24h → horizon=past_1d (new, see Q5) or kept as past_7d fallback. range=all → horizon=any.
/projects/:id/chart ?range=1y ?range=1y (kept) NOT migrated to TimeHorizon — projects-chart is symmetric-around-today. It uses DateRangePicker only for its custom-mode UI (the date-pair → slicer in Slice D). The 1y/2y/all presets stay surface-specific.

The Go side is unaffected by aliasing — handlers receive whatever shape they always have, and the URL alias adapter lives entirely client-side per surface. No backend route signature changes in Slice A.

4.3 Custom Views (persisted JSON)

paliad.user_views.filter_spec is a JSON column. The TimeSpec extension is additive (new enum values, no shape change). Existing rows continue to validate. Migration not needed.

4.4 Form fields (Custom Views editor)

views-editor.tsx:102-109 migrates from <select> to the picker. The form submits the same FormData shape (just one extra key for custom from/to — already plumbed via TimeSpec.from / TimeSpec.to). The Go-side parseViewForm() (TBD by coder) gains 4 new acceptable horizon values; existing test cases continue to pass.


§5 Migration plan

Slice A — substrate + filter-bar time axis

Backend (single migration not needed — additive constants only):

  • internal/services/filter_spec.go — 4 new TimeHorizon constants + validate switch arms.
  • internal/services/view_service.gocomputeViewSpecBounds() 4 new switch cases.
  • Pure unit tests for each new horizon (zero DB).

Frontend:

  • New frontend/src/components/DateRangePicker.tsx + boot client + pure module.
  • New i18n keys (42 entries).
  • frontend/src/client/filter-bar/axes.ts:renderTimeAxis() — replace the disabled "Anpassen" stub with the picker. The chip cluster either becomes the picker's open-state (preferred) OR the chips stay flat and the picker only opens on "Anpassen" click (fallback if popover-in-bar is visually noisy). Inventor pick (R): chips stay flat in the bar; "Anpassen" chip becomes the picker trigger. Picker emits TimeSpec back into the bar's state, same patch path.

Surfaces lit up automatically: Verlauf (/projects/:id), Custom Views (/views, /views/:id), InboxFilterBar (/inbox).

LoC estimate: ~600 LoC (pure: 180 / boot: 180 / TSX: 100 / CSS: 80 / Go: 30 / tests: 240). Tests-first per docs/design-paliad-test-strategy-2026-05-19.md.

Slice B — /agenda

  • agenda.tsx:51-69 — replace chip rows with <DateRangePicker surface="agenda" presets={["next_7d","next_14d","next_30d","next_90d","next_all","custom"]} />.
  • client/agenda.ts:85-104 — replace wireControls() chip wiring with picker subscription.
  • URL alias adapter — accept ?range=N for back-compat, emit ?horizon=….

LoC: ~80 LoC delta, mostly deletion.

Slice C — /admin/audit-log + /projects/:id/chart

  • admin-audit-log.tsx:50-65 — replace <select> + date-pair with <DateRangePicker surface="audit-log" presets={["past_7d","past_14d","past_30d","past_90d","past_all","custom"]} />.
  • projects-chart.tsx:75-83wrap the existing 1y/2y/all presets in a custom-prop variant (a sibling component <SymmetricRangePicker> that shares the picker's popover scaffolding but emits the surface-specific range_preset). Or — if the head/m prefers — fold 1y/2y/all into TimeHorizon as sym_1y / sym_2y / sym_all. Inventor pick (R): sibling component, because symmetric-around-today is conceptually different from past/future fan. See §8 Q1.

LoC: ~120 LoC for audit-log, ~80 LoC for projects-chart wrap.

Slice D (optional, separate task) — slicer

  • Add <DateRangeSlicer> for the custom-editor sub-pane. Built on <input type="range"> × 2 with a custom anchor rail above, ported from date-range-slider-pure.ts.
  • Replaces inline date-pair when horizon === "custom" and surface ∈ {agenda, audit-log, filter-bar}. Projects-chart keeps inline date-pair OR also uses slicer — its choice.
  • No new dependency.
  • ~400 LoC including pure helpers + DOM scaffolding + tests.

Per-slice rollout

Slice Risk Surfaces affected Coder profile
A Low — additive only 4 (filter-bar + 3 consumers) Pattern-fluent Sonnet
B Low 1 Same coder
C Medium (projects-chart sibling) 2 Same coder
D Medium (new slicer) 0 (additive on top of A) Separate task

§6 Visual decisions

6.1 Chip labels

Final labels — bilingual (DE first):

Chip DE EN
past_all Ganze Vergangenheit All past
past_90d Letzte 90 Tage Last 90 days
past_30d Letzte 30 Tage Last 30 days
past_14d Letzte 14 Tage Last 14 days
past_7d Letzte 7 Tage Last 7 days
any (center) Alles All
next_7d Nächste 7 Tage Next 7 days
next_14d Nächste 14 Tage Next 14 days
next_30d Nächste 30 Tage Next 30 days
next_90d Nächste 90 Tage Next 90 days
next_all Ganze Zukunft All future
custom Anpassen Customize

Rationale on "Anpassen" vs "Benutzerdefiniert":

  • "Anpassen" matches existing views.bar.time.custom key value in i18n.ts.
  • "Benutzerdefiniert" is used in admin-audit-log's dropdown — verbose, but more accurate.
  • (R): Anpassen (consistent with filter-bar; six chars vs. eighteen).

6.2 Accent / active state

Reuse the existing lime accent chip-active state (--color-bg-lime-tint background, --color-accent border, --color-text text). This is the established affordance for the agenda-chip-active class — same visual reused, no new accent token.

6.3 The "ALLES" center button

A larger, target-glyph button — visually distinct from the fan chips so the user reads it as the "no time filter" exit, not as one chip among many:

   ╭──────╮
   │  ⌖   │
   │ ALLES│
   ╰──────╯

(R) glyph: (Unicode U+2316 POSITION INDICATOR). Alternatives considered: (too math-y), (too connect-y), (too checkbox-y), no glyph (chip then looks like every other chip). See §8 Q3.B.

6.4 Custom-range entry

In Slice A: inline date-pair below the chip rows, with an "Anwenden" button that commits + closes the picker. Plain <input type="date" lang="de"> — gets the OS-native picker.

In Slice D (later): same slot becomes the slicer. The chip rows remain; the slicer collapses under them so the user can switch back to a chip with one click.

6.5 Hover / focus

  • Chip hover: existing .agenda-chip:hover (lighter background tint).
  • Chip focus-visible: 2px outline using --color-accent.
  • Button focus-visible: same.
  • Popover entry: 120ms fade-in via transform: translateY(-4px) → 0 + opacity. Reduced-motion users (prefers-reduced-motion: reduce) get instant show.

6.6 Indication that the filter is non-default

The closed button shows a small dot to the left of the label when the value is not the surface default. This matches the existing filter-bar non-default-indicator pattern (frontend/src/client/filter-bar/index.ts has a similar dot but on the whole bar; we adopt it per-control).


§7 Edge cases

7.1 Timezones

All horizon math runs against UTC startOfDay of new Date() — same convention as horizonBounds() in projects-detail.ts:393-406. The user's browser may be in CEST in summer or CET in winter; the picker still treats "today" as a UTC date for filter purposes. The date-input localizes display (German locale → DD.MM.YYYY) but the underlying ISO is YYYY-MM-DD parsed as UTC midnight.

Practical impact: a user in CEST clicking "Letzte 7 Tage" at 01:30 local on 2026-06-15 sees from=2026-06-07T00:00Z, to=2026-06-15T00:00Z even though their local clock shows the 15th. This matches every other date-filter in paliad and avoids "the same row vanishes at 01:00 vs. 23:00" surprises. Document the convention in the pure module's header comment.

7.2 Far past truncation

past_all materialises to from: nil. The Go side (view_service.go) treats nil as "no lower bound" — the SQL WHERE due_date >= ? clause is omitted. No truncation needed.

For projects-chart's symmetric "all" mode, "all" still means bounds derived from loaded events (status quo) — the picker for projects-chart's surface uses the sibling <SymmetricRangePicker> which doesn't have past_all/next_all chips, only 1y/2y/all.

7.3 Overlapping selections — past_7 + next_7 simultaneously?

The picker is single-select — one chip active at a time, OR custom mode. m's brief doesn't mention multi-select and the existing TimeSpec is single-valued. Multi-select would require a fundamental contract change. Don't.

If a user genuinely wants "last 7 days OR next 7 days," they use the custom-range with from=today-7d, to=today+7d — which is what ±1w would mean. The fact that this is two chip-clicks vs. one isn't a real ergonomic loss.

7.4 Custom dates with from > to

Validate client-side: when both inputs are filled and from > to, the "Anwenden" button is disabled and a hint appears: "Bis-Datum muss nach Von-Datum liegen" (i18n key date_range.custom.invalid). The picker does not auto-swap.

7.5 Empty inputs in custom mode

If the user clicks "Anpassen" then clicks elsewhere before filling inputs, the picker reverts to whatever horizon was active before (state cached on entry to custom-editor). No "half-custom" state persists.

7.6 Surface-specific preset overrides

Each surface declares its own presets via the presets prop. The picker hides chips not in the array. The default surface preset (read from defaults prop, or hardcoded if absent) is what serializeURL() omits from the URL.

Important invariant: defaults must be a member of presets, OR be a special value like any that's always rendered. The component asserts this at boot and falls back to any if violated.

7.7 Bilingual labels mid-session

labelForHorizon() consults the live i18n.ts dictionary on every render, so a language toggle updates the picker immediately — including the closed-button label.

7.8 Embedded picker inside a filter bar

When the picker is mounted inside filter-bar, it should NOT use a full popover overlay — the filter bar already wraps controls. Instead the open-state's chip rows render inline below the time chip cluster, expanding the bar's height. This is mode="inline" (a third mode beyond popover/modal). Slice A picks this for filter-bar consumers; standalone surfaces (/agenda, /admin/audit-log) use popover mode.

7.9 What happens if a saved Custom View references past_14d before Slice A ships?

The JSON validator rejects it (filter_spec.go:validate() enum check). Saved views are migration-safe in one direction only — adding new enum values is fine; removing is not. Slice A adds, doesn't remove. No issue.

7.10 Race: URL change while picker is open

If the user has the picker open and a URL change happens via another control (e.g. they Cmd-Click a sidebar link), the picker is unmounted naturally with the page navigation. No state to preserve across navigations.


§8 Open questions for m

Per task brief: no AskUserQuestion. Material picks escalated via mai instruct head; everything else defaults to (R) below. The head decides whether to forward to m or rule on the spot.

Q1 [MATERIAL — escalate]: How to handle /projects/:id/chart?

The chart's range presets are symmetric around today (1y / 2y / all = ±1y / ±2y / all-data-bounds), conceptually different from past/future fans. Options:

  • (R) A — sibling component. Keep a separate <SymmetricRangePicker> for the chart surface. Same popover scaffolding, different chip set. Chart's URL stays ?range=1y. Doesn't add to TimeHorizon.
  • B — fold into TimeHorizon. Add sym_1y, sym_2y, sym_all constants. Picker prop selects which fan vs. symmetric. Saved views could then express "±1y" too.
  • C — leave the chart as-is. Don't migrate. Accept the visual inconsistency.

(R) A. Symmetric vs fan is a real semantic difference; one component trying to be both is muddier than two components sharing scaffolding. The chart isn't a "filter" — it's a viewport, and viewports legitimately want symmetric panning.

Q2 [MATERIAL — escalate]: Modal vs popover for the standalone case?

m's brief says "mini modal." Options:

  • (R) A — popover always. Anchored to the trigger button, click-outside dismiss. In-context, lightweight.
  • B — modal for explicit "open date filter" intent. Use a centered modal with scrim when the picker is the page's primary filter (e.g. /admin/audit-log where date is the most prominent control). Popover for embedded uses.
  • C — modal everywhere. Strong visual hierarchy, but interrupts the user.

(R) A. Modal feels heavy for what is conceptually a chip cluster. The "mini" qualifier in m's wording suggests popover, not full modal. If a surface specifically needs the modal weight, the mode="modal" prop is available — but no default surface picks it.

Q3 [MATERIAL — escalate]: Slice priority — what migrates first?

  • (R) A — filter-bar time axis first (Slice A). Lights up 4 surfaces simultaneously (Verlauf, InboxFilterBar, views runtime, Custom Views editor) by replacing the existing Phase-2 disabled stub.
  • B — /agenda first (per task brief default). Highest-traffic standalone surface, simplest migration.
  • C — both A and B in parallel (head splits between two coders).

(R) A. Filter-bar is the substrate everything else either uses or should use. Lighting it up first turns three downstream surfaces from "almost working" (the stubbed custom-range chip with "coming_soon" tooltip) to "fully working." Agenda then migrates as Slice B, on top of a proven component.

Q3.B [DEFAULT — no escalation needed]: ALL center button glyph?

  • (R) (POSITION INDICATOR, U+2316). Implies "center / pin to here."
  • B (infinity). Mathy.
  • C (circled plus). Looks like a button.
  • D No glyph, just "ALLES" in bold.

(R) . If the head/m doesn't like the unicode lookup, D is the safe fallback.

Q4 [DEFAULT — no escalation]: Custom-range entry in Slice A?

  • (R) Inline <input type="date"> pair, OS-native picker. Slice D adds the slicer.

Q5 [DEFAULT — no escalation]: Past 24h in audit-log?

audit-log currently has a 24h preset; the picker would express this as past_1d. Options:

  • (R) Map legacy ?range=24h?horizon=past_1d. Add a new past_1d constant.
  • B Drop 24h — audit log defaults to past_7d like other surfaces. Users wanting "last 24h" use custom mode.

(R) Add past_1d. It's a one-line addition and audit-log users genuinely use "last 24h" for incident triage.

(Note: this means the picker actually has 5 past chips + 5 future chips + center + custom = 12 chips total, which fits comfortably in the popover.)

Q6 [DEFAULT — no escalation]: Slice D (slicer) — separate task or fold in?

  • (R) Separate task. Slice A-C are independently shippable. Slice D is meaningful design + ~400 LoC and shouldn't gate the main migration.

Q7 [DEFAULT — no escalation]: Per-surface defaults?

Each migrating surface keeps its current default exactly:

  • /agendanext_30d (was 30).
  • /admin/audit-logpast_7d (was 7d).
  • /projects/:id Verlauf → past_30d (was past_30d in projects-detail.ts:2310).
  • /views/:id runtime → whatever the saved view has (no change).
  • /inbox (InboxFilterBar) → whatever filter-bar's surface defines.

Q8 [DEFAULT — no escalation]: Should past_14d and next_14d retroactively appear in views-editor.tsx's <select>?

(R) Yes — once Slice A ships, the <select> in views-editor.tsx is replaced by the picker (part of Slice A, as filter-bar consumers all flip in one commit). All 12 preset values become available for new Custom Views.


§9 Implementer notes (for the coder shift, if approved)

Lessons embedded

  • TimeSpec extension is additive only — Go enum + TS union + i18n keys + horizonBounds switch. No DB migration, no contract break.
  • Pure module is testable under bun test — no DOM needed for horizon math, label resolution, URL serialization. Aim for 95%+ coverage of the pure module before touching the boot client.
  • Reuse .agenda-chip styling — adds no new tokens, no new dark-mode contrast risk (cf. memory t-paliad-150 / fritz — fritz lost 90 minutes to a var(--token, #hex) fallback bug because the token wasn't defined in dark mode).
  • mode="inline" for filter-bar consumers — the bar already wraps its own popover-like layout; nesting popovers gets visually noisy.
  • Surface defaults must be members of presets — assert at boot, fail loud in dev, fall back to any in prod.

Pattern-fluent Sonnet. Substrate is well-trodden (TimeSpec/TimeHorizon already lives, chip-cluster CSS exists, URL-codec pattern documented in projects-chart.ts). The novel piece is the popover scaffolding — paliad doesn't have a generic Popover primitive today; the picker builds its own DOM-anchored overlay. ~80 LoC of plain JS, no dependency.

Build hygiene checklist

  • go build ./... clean
  • go vet ./... clean
  • go test ./... clean (existing tests must continue passing — additive constants change zero behaviour)
  • bun run build clean (i18n scan: 21 new keys added, all data-i18n attributes present)
  • bun:test covers the pure module (horizon math, label resolver, URL parser/serializer)
  • Playwright smoke (manual, not gated): on /inbox the time axis "Anpassen" chip is now functional; custom-from/to date pair commits a usable filter.

Out of scope for the coder

  • Slicer (Slice D) — separate task.
  • Per-language adjustments beyond DE/EN (per task brief, out of scope).
  • Time-of-day picking — separate concern.
  • Recurring-event windows — events feed handles separately.
  • A generic Popover primitive — extract only if a second consumer appears in the same slice.

Acceptance criteria for Slice A

  1. New <DateRangePicker> mounts on filter-bar's time axis, replacing the disabled "Anpassen" chip.
  2. The 4 new horizon values (past_14d, next_14d, past_all, next_all) are accepted by Go's TimeSpec.validate() and produce correct (from, to) bounds in computeViewSpecBounds().
  3. The 4 new horizons round-trip through saved Custom Views (paliad.user_views.filter_spec JSON).
  4. URL serialization is canonical (?horizon=…&from=…&to=…) and surface-default values are omitted.
  5. Verlauf (/projects/:id), /views, /views/:id, and /inbox continue to function with their existing presets unchanged — they pick up the new picker but don't switch their preset list yet.
  6. Pure-module unit tests cover: 12 horizons × bound calculation; URL parse / serialize round-trip; default-omission rule; custom-mode date validation.
  7. bun run build reports the new i18n keys (no missing-key warnings).
  8. No regression in go test ./internal/services/... (existing TimeSpec tests stay green).

§10 Material picks summary — escalation message

To be sent via mai instruct head after this doc is pushed:

Three material picks for m on date-range-picker design:

  1. /projects/:id/chart migration — keep symmetric (1y/2y/all) presets as a sibling component, NOT fold into TimeHorizon. Chart is a viewport, not a filter.
  2. Popover vs modal — popover by default. Modal is a mode prop available per surface but no surface picks it in Slice A.
  3. Slice A first migrates filter-bar time axis (lights up Verlauf + InboxFilterBar + Views + Custom-Views-editor simultaneously by un-stubbing the existing "Anpassen" chip), not /agenda as the task brief defaulted. /agenda is Slice B.

Everything else (chip labels, accent, glyph, custom-mode entry, surface defaults, past_1d for audit, slicer-as-Slice-D, 42 i18n keys) defaults per (R) in §8. Doc at docs/design-date-range-picker-2026-05-25.md.


Verified premises (live, before designing):

  • internal/services/filter_spec.go:107-126 — TimeHorizon enum at 9 values today.
  • internal/services/view_service.go:156-187computeViewSpecBounds() switches on the same enum.
  • frontend/src/client/views/types.ts:21-33 — TimeHorizon TS mirror; same 9 values.
  • frontend/src/client/filter-bar/axes.ts:65-115 — chip cluster renderer; "Anpassen" stub at line 105-112 marked Phase 2, disabled, "coming_soon" tooltip.
  • frontend/src/agenda.tsx:64-67 — chip row exact values 7|14|30|90.
  • frontend/src/admin-audit-log.tsx:50-65 — select exact values 24h|7d|30d|custom|all.
  • frontend/src/projects-chart.tsx:78-82 + frontend/src/client/projects-chart.ts:73-118 — RangePreset 1y|2y|all|custom, symmetric around today.
  • frontend/src/views-editor.tsx:102-109 — select exact values next_7d|next_30d|next_90d|past_30d|past_90d|any.
  • /home/m/dev/web/upc-kommentar/src/lib/components/DateRangeSlider.svelte — 448 lines, wraps svelte-range-slider-pips@4, custom anchor rail above the lib's hidden pips, click-to-snap left/right halves, granularity year/month/day zoom.
  • /home/m/dev/web/upc-kommentar/src/lib/modules/date-range-slider/date-range-slider-pure.ts — 487 lines, fully testable pure helpers, dependency-free, portable to paliad's TS.

Not verified live: upckommentar.de in a browser (requires author auth; the source code IS the source of truth and was read end-to-end).