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.
48 KiB
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:
/agenda— future-only chip row [7|14|30|90 Tage], staterangeDays./admin/audit-log— past-only<select>[24h|7d|30d|custom|all] + manual<input type="date">pair./projects/:id/chart— symmetricRangePreset[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-bartimeaxis first (lights up Verlauf + InboxFilterBar + views simultaneously by replacing the stubbed Phase-2 chip). - Slice B —
/agendamigrates (highest-traffic standalone consumer). - Slice C —
/admin/audit-log+/projects/:id/chartmigrate. 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:
<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}(setVALID_RANGES). Default30.- 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"readsfrom/toinputs."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 aTimeSpec. - 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_soontooltip (line 108-112). This is the documented Phase 2 substrate. - Surfaces declaring axis
timethread their own preset list viaRenderAxisOpts.timePresets— e.g. Verlauf overrides to["past_7d","past_30d","past_90d","any"](frontend/src/client/projects-detail.ts:2310).
Consumers:
/projects/:idVerlauf (projects-detail.ts:2296initial state, 2310 preset override)./viewsand/views/:id(Custom Views runtime)./inbox(InboxFilterBarflow — 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.tsx—start_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=truedraws a colored bar between handles.draggy=truelets 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 showingDD.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 yearsas the default visible range withpast_all/next_allchips 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-tintbackground,--color-texttext,--color-accentborder) — matches existing.agenda-chip-activeso 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
Tablands on the button.Enter/Spaceopens the popover.Escfrom open state closes it.Escfrom 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"andaria-expandedtoggled on open/close. - The popover has
role="dialog"witharia-label=t("date_range.dialog.label")("Zeitraum wählen" / "Choose date range"). - Chips are
<button>witharia-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 newTimeHorizonconstants + validate switch arms.internal/services/view_service.go—computeViewSpecBounds()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— replacewireControls()chip wiring with picker subscription.- URL alias adapter — accept
?range=Nfor 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-83— wrap 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-specificrange_preset). Or — if the head/m prefers — fold 1y/2y/all into TimeHorizon assym_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 fromdate-range-slider-pure.ts. - Replaces inline date-pair when
horizon === "custom"andsurface ∈ {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.customkey value ini18n.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_allconstants. 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-logwhere 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
timeaxis first (Slice A). Lights up 4 surfaces simultaneously (Verlauf, InboxFilterBar, views runtime, Custom Views editor) by replacing the existing Phase-2 disabled stub. - B —
/agendafirst (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 newpast_1dconstant. - B Drop
24h— audit log defaults topast_7dlike 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:
/agenda→next_30d(was 30)./admin/audit-log→past_7d(was 7d)./projects/:idVerlauf →past_30d(was past_30d inprojects-detail.ts:2310)./views/:idruntime → 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-chipstyling — adds no new tokens, no new dark-mode contrast risk (cf. memory t-paliad-150 / fritz — fritz lost 90 minutes to avar(--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 toanyin prod.
Recommended coder profile
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 ./...cleango vet ./...cleango test ./...clean (existing tests must continue passing — additive constants change zero behaviour)bun run buildclean (i18n scan: 21 new keys added, alldata-i18nattributes present)- bun:test covers the pure module (horizon math, label resolver, URL parser/serializer)
- Playwright smoke (manual, not gated): on
/inboxthe 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
- New
<DateRangePicker>mounts on filter-bar'stimeaxis, replacing the disabled "Anpassen" chip. - The 4 new horizon values (
past_14d,next_14d,past_all,next_all) are accepted by Go'sTimeSpec.validate()and produce correct(from, to)bounds incomputeViewSpecBounds(). - The 4 new horizons round-trip through saved Custom Views (
paliad.user_views.filter_specJSON). - URL serialization is canonical (
?horizon=…&from=…&to=…) and surface-default values are omitted. - Verlauf (
/projects/:id),/views,/views/:id, and/inboxcontinue to function with their existing presets unchanged — they pick up the new picker but don't switch their preset list yet. - Pure-module unit tests cover: 12 horizons × bound calculation; URL parse / serialize round-trip; default-omission rule; custom-mode date validation.
bun run buildreports the new i18n keys (no missing-key warnings).- 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:
/projects/:id/chartmigration — keep symmetric (1y/2y/all) presets as a sibling component, NOT fold into TimeHorizon. Chart is a viewport, not a filter.- Popover vs modal — popover by default. Modal is a
modeprop available per surface but no surface picks it in Slice A.- 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
/agendaas the task brief defaulted./agendais 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-187—computeViewSpecBounds()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 values7|14|30|90.frontend/src/admin-audit-log.tsx:50-65— select exact values24h|7d|30d|custom|all.frontend/src/projects-chart.tsx:78-82+frontend/src/client/projects-chart.ts:73-118— RangePreset1y|2y|all|custom, symmetric around today.frontend/src/views-editor.tsx:102-109— select exact valuesnext_7d|next_30d|next_90d|past_30d|past_90d|any./home/m/dev/web/upc-kommentar/src/lib/components/DateRangeSlider.svelte— 448 lines, wrapssvelte-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).