From 31d78526cf63136cad50cbd22935aff0099192dc Mon Sep 17 00:00:00 2001 From: mAi Date: Mon, 25 May 2026 15:47:51 +0200 Subject: [PATCH] =?UTF-8?q?feat(date-range-picker):=20t-paliad-248=20?= =?UTF-8?q?=E2=80=94=20symmetric=20picker=20+=20filter-bar=20wiring?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice A complete. Builds on the additive backend constants (commit 34e3d71) by shipping the user-facing surface. # Pure helpers (no DOM) frontend/src/client/date-range-picker-pure.ts (190 LoC) — TimeSpec shape, ALL_HORIZONS / PAST_HORIZONS / NEXT_HORIZONS registries, horizonBounds (mirrors view_service.go), isValidHorizon, isValidISODate (strict — rejects 2026-02-30 etc.), validateCustomRange, parseURL / serializeURL (canonical ?horizon=...&horizon_from=...&horizon_to=... with default-omission), isDefault. frontend/src/client/date-range-picker-pure.test.ts (38 bun tests, 118 expect calls): registries, horizon bounds for all 14 values, ISO-date validity rejects calendar-impossible dates, validateCustomRange on every error branch, parseURL fallback to default, serializeURL default-omission + key-override + custom-bounds, full round-trip. # DOM mount frontend/src/client/date-range-picker.ts (290 LoC) — mountDateRangePicker returns {element, getValue, setValue, close, destroy}. Trigger button in a .multi-anchor wrapper, popover panel reusing .multi-panel positioning. Symmetric chip row: past fan (right-aligned) | ALLES centre (target glyph U+2316) | next fan. 'Anpassen' chip toggles an inline date-pair editor with Apply / Cancel + a live validation message that surfaces only the meaningful 'inverted range' error during typing (empty/format errors are visible via the disabled Apply button). Outside-click + Esc close the popover, focus returns to the trigger. setValue lets the host sync from URL changes. # Filter-bar wiring frontend/src/client/filter-bar/axes.ts:renderTimeAxis — the disabled 'Anpassen' stub (t-paliad-163 Phase 2 placeholder) is gone; the axis mounts the picker instead. New default presets surface 6 chips + ALLES centre + Anpassen, plus the per-surface timePresets override filters down to whatever subset the surface declares. 'any' still maps to BarState.time = undefined to keep the canonical URL short and preserve the existing 'no overlay' semantics. frontend/src/client/filter-bar/types.ts — TimeOverlay.horizon union extended with next_1d / next_14d / next_all / past_1d / past_14d / past_all. frontend/src/client/filter-bar/url-codec.ts — parseHorizon accepts the six new values; existing 9 values continue to round-trip. frontend/src/client/filter-bar/url-codec.test.ts — round-trip iteration extended to all 14 horizons. frontend/src/client/views/types.ts — TimeHorizon TS mirror extended. frontend/src/client/projects-detail.ts — horizonBounds covers the six new values (open-ended for next_all/past_all so the upstream filter treats nil bounds as 'no narrowing in that direction'). # i18n + retired legacy keys frontend/src/client/i18n.ts — 30 new keys per language (date_range.* namespace for the picker + 6 missing views.horizon.* labels for existing dynamic-key composition in views.ts:317). Legacy views.bar.time.* keys (10 per language) retired with a one-line breadcrumb comment pointing at the date_range.* namespace. frontend/src/i18n-keys.ts — regenerated by build.ts. # CSS frontend/src/styles/global.css — date-range-* class block (256 LoC). Trigger button, popover panel, past/centre/next groups, custom-range editor, mobile stack at <540px. Reuses --color-accent / --color-accent-light / --color-bg-lime-tint / --color-border / --color-text + .agenda-chip / .agenda-chip-active for chip styling so every active state lights up with the same lime accent as every other paliad filter chip — no new tokens, no fresh dark-mode contrast risk (t-paliad-150 / fritz lesson held). # Surfaces lit up by this single change - /projects/:id Verlauf (filter-bar consumer) - /views runtime - /views/:id Custom-Views editor - /inbox InboxFilterBar All four pick up the picker on their next page load. Per-surface presets (timePresets MountOpt) preserved exactly; Verlauf still shows the past-only subset, /inbox the forward-leaning subset etc. The custom chip that's been disabled-with-coming_soon since t-paliad-163 now works. # Tests + build hygiene - go build ./... clean - go test ./internal/services/ clean (filter_spec + new bounds test) - bun test passes (150 tests, 8 files, 377 expect calls) - bun run build clean (2848 i18n keys, data-i18n scan clean) # What's NOT in this slice - /agenda chip-row migration (Slice B). - /admin/audit-log + /projects/:id/chart migration (Slice C). - upckommentar-style range slicer for custom mode (Slice D, separate task). --- .../src/client/date-range-picker-pure.test.ts | 289 +++++++++++ frontend/src/client/date-range-picker-pure.ts | 292 +++++++++++ frontend/src/client/date-range-picker.ts | 470 ++++++++++++++++++ frontend/src/client/filter-bar/axes.ts | 95 ++-- frontend/src/client/filter-bar/types.ts | 8 +- .../src/client/filter-bar/url-codec.test.ts | 7 +- frontend/src/client/filter-bar/url-codec.ts | 6 + frontend/src/client/i18n.ts | 102 +++- frontend/src/client/projects-detail.ts | 10 +- frontend/src/client/views/types.ts | 4 +- frontend/src/i18n-keys.ts | 44 +- frontend/src/styles/global.css | 255 ++++++++++ 12 files changed, 1504 insertions(+), 78 deletions(-) create mode 100644 frontend/src/client/date-range-picker-pure.test.ts create mode 100644 frontend/src/client/date-range-picker-pure.ts create mode 100644 frontend/src/client/date-range-picker.ts diff --git a/frontend/src/client/date-range-picker-pure.test.ts b/frontend/src/client/date-range-picker-pure.test.ts new file mode 100644 index 0000000..6d05270 --- /dev/null +++ b/frontend/src/client/date-range-picker-pure.test.ts @@ -0,0 +1,289 @@ +// Unit tests for the date-range picker's pure helpers (t-paliad-248). +// Run with `bun test`. + +import { test, expect, describe } from "bun:test"; +import { + horizonBounds, + isValidHorizon, + isValidISODate, + validateCustomRange, + parseURL, + serializeURL, + isDefault, + ALL_HORIZONS, + PAST_HORIZONS, + NEXT_HORIZONS, + type TimeHorizon, + type TimeSpec, +} from "./date-range-picker-pure"; + +// Anchor the clock so day-arithmetic assertions don't drift with the +// wall clock. 2026-05-25 00:00 UTC matches the Go-side bounds test. +const NOW = new Date(Date.UTC(2026, 4, 25)); +const DAY = (offsetDays: number): Date => + new Date(NOW.getTime() + offsetDays * 86_400_000); + +describe("ALL_HORIZONS / PAST / NEXT registries", () => { + test("registries sum to a known total without overlap", () => { + // 6 past + 6 next + any + custom = 14 fan chips (custom is the + // trailing entry in ALL_HORIZONS; `all` is intentionally absent — + // surfaces don't render the legacy bidirectional-unbounded chip). + expect(ALL_HORIZONS.length).toBe(14); + expect(PAST_HORIZONS.length).toBe(6); + expect(NEXT_HORIZONS.length).toBe(6); + expect(new Set(ALL_HORIZONS).size).toBe(ALL_HORIZONS.length); + }); + + test("PAST_HORIZONS are all past_*", () => { + for (const h of PAST_HORIZONS) { + expect(h.startsWith("past_")).toBe(true); + } + }); + + test("NEXT_HORIZONS are all next_*", () => { + for (const h of NEXT_HORIZONS) { + expect(h.startsWith("next_")).toBe(true); + } + }); + + test("ALL_HORIZONS ends with custom and contains any in the middle", () => { + expect(ALL_HORIZONS.at(-1)).toBe("custom"); + expect(ALL_HORIZONS).toContain("any"); + }); +}); + +describe("horizonBounds", () => { + test("future fan: bounds anchor at today, extend forward", () => { + expect(horizonBounds("next_1d", NOW)).toEqual({ from: DAY(0), to: DAY(1) }); + expect(horizonBounds("next_7d", NOW)).toEqual({ from: DAY(0), to: DAY(7) }); + expect(horizonBounds("next_14d", NOW)).toEqual({ from: DAY(0), to: DAY(14) }); + expect(horizonBounds("next_30d", NOW)).toEqual({ from: DAY(0), to: DAY(30) }); + expect(horizonBounds("next_90d", NOW)).toEqual({ from: DAY(0), to: DAY(90) }); + }); + + test("past fan: bounds extend back, upper bound is tomorrow (exclusive end-of-today)", () => { + expect(horizonBounds("past_1d", NOW)).toEqual({ from: DAY(-1), to: DAY(1) }); + expect(horizonBounds("past_7d", NOW)).toEqual({ from: DAY(-7), to: DAY(1) }); + expect(horizonBounds("past_14d", NOW)).toEqual({ from: DAY(-14), to: DAY(1) }); + expect(horizonBounds("past_30d", NOW)).toEqual({ from: DAY(-30), to: DAY(1) }); + expect(horizonBounds("past_90d", NOW)).toEqual({ from: DAY(-90), to: DAY(1) }); + }); + + test("next_all is one-sided: from=today, to undefined", () => { + const b = horizonBounds("next_all", NOW); + expect(b.from).toEqual(DAY(0)); + expect(b.to).toBeUndefined(); + }); + + test("past_all is one-sided: from undefined, to=tomorrow", () => { + const b = horizonBounds("past_all", NOW); + expect(b.from).toBeUndefined(); + expect(b.to).toEqual(DAY(1)); + }); + + test("any / all / custom: both bounds undefined", () => { + expect(horizonBounds("any", NOW)).toEqual({}); + expect(horizonBounds("all", NOW)).toEqual({}); + expect(horizonBounds("custom", NOW)).toEqual({}); + }); + + test("bounds anchor on UTC start-of-day regardless of input clock time", () => { + const nowAfternoon = new Date(Date.UTC(2026, 4, 25, 14, 37, 0)); + const nowMidnight = new Date(Date.UTC(2026, 4, 25, 0, 0, 0)); + expect(horizonBounds("past_7d", nowAfternoon)).toEqual(horizonBounds("past_7d", nowMidnight)); + }); +}); + +describe("isValidHorizon", () => { + test("accepts every entry in ALL_HORIZONS plus 'all' (legacy)", () => { + for (const h of ALL_HORIZONS) { + expect(isValidHorizon(h)).toBe(true); + } + expect(isValidHorizon("all")).toBe(true); + }); + + test("rejects unknown strings, numbers, undefined, null", () => { + expect(isValidHorizon("next_5d")).toBe(false); + expect(isValidHorizon("past_100d")).toBe(false); + expect(isValidHorizon("")).toBe(false); + expect(isValidHorizon(7)).toBe(false); + expect(isValidHorizon(undefined)).toBe(false); + expect(isValidHorizon(null)).toBe(false); + }); +}); + +describe("isValidISODate", () => { + test("accepts valid YYYY-MM-DD", () => { + expect(isValidISODate("2026-05-25")).toBe(true); + expect(isValidISODate("2026-12-31")).toBe(true); + expect(isValidISODate("2024-02-29")).toBe(true); + }); + + test("rejects shape mismatches", () => { + expect(isValidISODate("2026/05/25")).toBe(false); + expect(isValidISODate("25.05.2026")).toBe(false); + expect(isValidISODate("2026-5-25")).toBe(false); + expect(isValidISODate("")).toBe(false); + expect(isValidISODate(undefined)).toBe(false); + }); + + test("rejects calendar-impossible dates (Date.parse silently rolls over)", () => { + expect(isValidISODate("2026-02-30")).toBe(false); + expect(isValidISODate("2026-13-01")).toBe(false); + expect(isValidISODate("2026-04-31")).toBe(false); + }); + + test("rejects 2025-02-29 (non-leap February)", () => { + expect(isValidISODate("2025-02-29")).toBe(false); + }); +}); + +describe("validateCustomRange", () => { + test("requires both bounds present and valid", () => { + expect(validateCustomRange(undefined, undefined)).toBe("date_range.custom.invalid_missing"); + expect(validateCustomRange("2026-05-25", undefined)).toBe("date_range.custom.invalid_missing"); + expect(validateCustomRange(undefined, "2026-05-25")).toBe("date_range.custom.invalid_missing"); + }); + + test("rejects malformed dates with format error", () => { + expect(validateCustomRange("bogus", "2026-05-25")).toBe("date_range.custom.invalid_format"); + expect(validateCustomRange("2026-13-01", "2026-12-31")).toBe("date_range.custom.invalid_format"); + }); + + test("rejects to <= from with invalid error", () => { + expect(validateCustomRange("2026-05-25", "2026-05-25")).toBe("date_range.custom.invalid"); + expect(validateCustomRange("2026-05-25", "2026-05-24")).toBe("date_range.custom.invalid"); + }); + + test("accepts strictly-ordered valid pair", () => { + expect(validateCustomRange("2026-05-25", "2026-05-26")).toBeNull(); + expect(validateCustomRange("2026-01-01", "2026-12-31")).toBeNull(); + }); +}); + +describe("parseURL", () => { + test("missing horizon yields contract default", () => { + expect(parseURL(new URLSearchParams(""))).toEqual({ horizon: "any" }); + expect(parseURL(new URLSearchParams(""), { default: "next_30d" })).toEqual({ horizon: "next_30d" }); + }); + + test("unknown horizon falls back to default, doesn't throw", () => { + expect(parseURL(new URLSearchParams("horizon=mystery"), { default: "next_7d" })) + .toEqual({ horizon: "next_7d" }); + }); + + test("every fan horizon round-trips on a fresh URLSearchParams", () => { + for (const h of ALL_HORIZONS) { + if (h === "custom") continue; + const params = new URLSearchParams(`horizon=${h}`); + expect(parseURL(params)).toEqual({ horizon: h }); + } + }); + + test("custom horizon reads from+to", () => { + const params = new URLSearchParams("horizon=custom&horizon_from=2026-03-15&horizon_to=2026-04-30"); + expect(parseURL(params)).toEqual({ + horizon: "custom", + from: "2026-03-15", + to: "2026-04-30", + }); + }); + + test("custom with malformed dates falls back to default rather than half-state", () => { + const params = new URLSearchParams("horizon=custom&horizon_from=2026-99-99&horizon_to=2026-04-30"); + expect(parseURL(params, { default: "next_30d" })).toEqual({ horizon: "next_30d" }); + }); + + test("custom with from>=to falls back", () => { + const params = new URLSearchParams("horizon=custom&horizon_from=2026-05-25&horizon_to=2026-05-25"); + expect(parseURL(params)).toEqual({ horizon: "any" }); + }); + + test("custom URL key override", () => { + const params = new URLSearchParams("range=past_30d"); + expect(parseURL(params, { key: "range" })).toEqual({ horizon: "past_30d" }); + expect(parseURL(params)).toEqual({ horizon: "any" }); // default `horizon` key absent + }); +}); + +describe("serializeURL", () => { + test("default horizon is omitted (canonical URL stays short)", () => { + const params = new URLSearchParams(); + serializeURL({ horizon: "any" }, params); + expect(params.toString()).toBe(""); + }); + + test("explicit default param removed when value matches default", () => { + const params = new URLSearchParams("horizon=past_30d&other=keep"); + serializeURL({ horizon: "past_30d" }, params, { default: "past_30d" }); + expect(params.toString()).toBe("other=keep"); + }); + + test("non-default horizon is written", () => { + const params = new URLSearchParams("other=keep"); + serializeURL({ horizon: "next_7d" }, params); + expect(params.toString()).toBe("other=keep&horizon=next_7d"); + }); + + test("custom writes horizon+from+to", () => { + const params = new URLSearchParams(); + serializeURL({ horizon: "custom", from: "2026-03-15", to: "2026-04-30" }, params); + expect(params.toString()).toBe("horizon=custom&horizon_from=2026-03-15&horizon_to=2026-04-30"); + }); + + test("custom partial bounds: from/to are written individually", () => { + const params = new URLSearchParams(); + serializeURL({ horizon: "custom", from: "2026-03-15" }, params); + expect(params.toString()).toBe("horizon=custom&horizon_from=2026-03-15"); + }); + + test("stale params cleared on re-serialize", () => { + const params = new URLSearchParams("horizon=custom&horizon_from=2026-03-15&horizon_to=2026-04-30&other=keep"); + serializeURL({ horizon: "past_30d" }, params); + expect(params.toString()).toBe("other=keep&horizon=past_30d"); + // Stale from/to must be gone. + expect(params.has("horizon_from")).toBe(false); + expect(params.has("horizon_to")).toBe(false); + }); + + test("key override propagates to from/to", () => { + const params = new URLSearchParams(); + serializeURL({ horizon: "custom", from: "2026-03-15", to: "2026-04-30" }, params, { key: "range" }); + expect(params.toString()).toBe("range=custom&range_from=2026-03-15&range_to=2026-04-30"); + }); + + test("URL round-trips through parse → serialize → parse", () => { + const specs: TimeSpec[] = [ + { horizon: "any" }, + { horizon: "next_7d" }, + { horizon: "past_all" }, + { horizon: "next_all" }, + { horizon: "custom", from: "2026-03-15", to: "2026-04-30" }, + ]; + for (const spec of specs) { + const params = new URLSearchParams(); + serializeURL(spec, params); + expect(parseURL(params)).toEqual(spec); + } + }); +}); + +describe("isDefault", () => { + test("true when horizon matches default exactly", () => { + expect(isDefault({ horizon: "any" }, "any")).toBe(true); + expect(isDefault({ horizon: "next_30d" }, "next_30d")).toBe(true); + }); + + test("false when horizon differs", () => { + expect(isDefault({ horizon: "past_7d" }, "any")).toBe(false); + expect(isDefault({ horizon: "next_30d" }, "next_7d")).toBe(false); + }); + + test("custom is never default — even when bounds match", () => { + // No surface treats "custom" as the natural default, so any custom + // selection IS user-driven and the closed button must surface + // the non-default indicator. + expect(isDefault({ horizon: "custom", from: "2026-01-01", to: "2026-12-31" }, "custom" as TimeHorizon)) + .toBe(false); + }); +}); diff --git a/frontend/src/client/date-range-picker-pure.ts b/frontend/src/client/date-range-picker-pure.ts new file mode 100644 index 0000000..ab019d6 --- /dev/null +++ b/frontend/src/client/date-range-picker-pure.ts @@ -0,0 +1,292 @@ +// date-range-picker-pure.ts — pure helpers for the symmetric date-range +// picker (t-paliad-248). No DOM access; runnable under `bun test`. The +// picker's boot client (date-range-picker.ts) drives the popover, but +// every interesting decision — what does "Letzte 7 Tage" mean today, +// what URL params should land, when is a custom range valid — lives +// here so it can be tested without a browser. +// +// The Go side (internal/services/view_service.go:computeViewSpecBounds) +// is the canonical materializer; horizonBounds() below MUST stay in +// step with it. The bounds test in pure-tests pins the shape so a +// divergent change to one side breaks the assertions on the other. + +import type { I18nKey } from "../i18n-keys"; + +/** + * TimeHorizon — the full 14-value union the symmetric picker can emit. + * Mirrors `internal/services/filter_spec.go` TimeHorizon. + * + * The fan chips: 6 past + 6 next + the ALLES centre (`any`) + custom. + * `all` is the legacy bidirectional-unbounded value, gated to + * scope=explicit by the validator (Q26); the picker doesn't surface it + * but parseURL accepts it for back-compat with saved Custom Views. + */ +export type TimeHorizon = + | "next_1d" | "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all" + | "past_1d" | "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all" + | "any" | "all" | "custom"; + +/** + * TimeSpec — the wire shape mirrored from the Go FilterSpec.TimeSpec. + * `from`/`to` are ISO YYYY-MM-DD strings — UTC dates, not timestamps. + * Times-of-day intentionally absent from the picker's contract. + */ +export interface TimeSpec { + horizon: TimeHorizon; + from?: string; + to?: string; +} + +/** + * The full list of horizon values the picker is willing to render + * as chips. Order is the picker's reading order — past edge → past + * → ALLES → next → next edge, with `custom` last because it lives + * below the chip rows in the popover, not in the row itself. + */ +export const ALL_HORIZONS: readonly TimeHorizon[] = [ + "past_all", + "past_90d", + "past_30d", + "past_14d", + "past_7d", + "past_1d", + "any", + "next_1d", + "next_7d", + "next_14d", + "next_30d", + "next_90d", + "next_all", + "custom", +]; + +// Strict-validity set. Includes the legacy bidirectional-unbounded `all` +// horizon so a saved Custom View JSON ({"horizon":"all", …}) deserializes +// without falling back to the surface default. The picker UI itself +// doesn't surface a chip for `all` — it's read in, kept as state, but +// the chip the user sees light up is `any` (the centre ALLES button). +const ALL_HORIZONS_SET: ReadonlySet = new Set([...ALL_HORIZONS, "all"]); + +/** + * Past chips, in reading order (outermost → innermost). The picker + * renders this left-to-right in the popover's past fan. + */ +export const PAST_HORIZONS: readonly TimeHorizon[] = [ + "past_all", + "past_90d", + "past_30d", + "past_14d", + "past_7d", + "past_1d", +]; + +/** + * Future chips, in reading order (innermost → outermost). The picker + * renders this left-to-right in the popover's future fan. + */ +export const NEXT_HORIZONS: readonly TimeHorizon[] = [ + "next_1d", + "next_7d", + "next_14d", + "next_30d", + "next_90d", + "next_all", +]; + +/** + * The i18n key for the closed-button label and chip text of every + * horizon. Lives here (not in the TSX) so a single dictionary lookup + * sites can hand back a translated string at any point. + */ +export const HORIZON_LABEL_KEY: Record = { + past_all: "date_range.horizon.past_all", + past_90d: "date_range.horizon.past_90d", + past_30d: "date_range.horizon.past_30d", + past_14d: "date_range.horizon.past_14d", + past_7d: "date_range.horizon.past_7d", + past_1d: "date_range.horizon.past_1d", + any: "date_range.horizon.any", + next_1d: "date_range.horizon.next_1d", + next_7d: "date_range.horizon.next_7d", + next_14d: "date_range.horizon.next_14d", + next_30d: "date_range.horizon.next_30d", + next_90d: "date_range.horizon.next_90d", + next_all: "date_range.horizon.next_all", + all: "date_range.horizon.any", // legacy alias — surfaces "Alles" in the closed label + custom: "date_range.horizon.custom", +}; + +/** + * Bounds for a given horizon, anchored at `now`. Pure function: the + * caller passes the clock so tests can pin a specific day without + * mocking Date. Bounds are UTC dates; the `to` bound is exclusive + * (start-of-day-after) so "past 7d" includes today. + * + * Returns `{}` for `any` / `all` / `custom` — the picker's surface + * lifts the from/to out of TimeSpec directly when horizon === custom, + * and treats unbounded values as "no narrowing in that direction". + */ +export function horizonBounds( + horizon: TimeHorizon, + now: Date, +): { from?: Date; to?: Date } { + const day = new Date(Date.UTC( + now.getUTCFullYear(), + now.getUTCMonth(), + now.getUTCDate(), + )); + const offset = (days: number): Date => + new Date(day.getTime() + days * 86_400_000); + + switch (horizon) { + case "past_1d": return { from: offset(-1), to: offset(1) }; + case "past_7d": return { from: offset(-7), to: offset(1) }; + case "past_14d": return { from: offset(-14), to: offset(1) }; + case "past_30d": return { from: offset(-30), to: offset(1) }; + case "past_90d": return { from: offset(-90), to: offset(1) }; + case "past_all": return { to: offset(1) }; + case "next_1d": return { from: day, to: offset(1) }; + case "next_7d": return { from: day, to: offset(7) }; + case "next_14d": return { from: day, to: offset(14) }; + case "next_30d": return { from: day, to: offset(30) }; + case "next_90d": return { from: day, to: offset(90) }; + case "next_all": return { from: day }; + case "any": + case "all": + case "custom": + return {}; + } +} + +/** + * isValidHorizon — narrows an unknown string to a TimeHorizon, used + * by parseURL and by surface-side URL alias adapters. + */ +export function isValidHorizon(s: unknown): s is TimeHorizon { + return typeof s === "string" && ALL_HORIZONS_SET.has(s); +} + +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +/** + * isValidISODate — `YYYY-MM-DD` shape check plus a real-date validity + * check (rejects 2026-02-30). Doesn't enforce timezone or floor at any + * particular date. + */ +export function isValidISODate(s: unknown): s is string { + if (typeof s !== "string" || !ISO_DATE_RE.test(s)) return false; + const ms = Date.parse(`${s}T00:00:00Z`); + if (Number.isNaN(ms)) return false; + // Reject 2026-02-30 etc. — Date.parse accepts those by rolling over. + return new Date(ms).toISOString().slice(0, 10) === s; +} + +/** + * Validate a custom range. Returns null on success, an i18n key + * pointing at the error message on failure. + * + * Rules: + * - Both `from` and `to` must be valid ISO YYYY-MM-DD. + * - `to` must be strictly after `from` (single-day ranges use + * `from=2026-05-25&to=2026-05-26`, NOT `from=to=2026-05-25`). + */ +export function validateCustomRange( + from: string | undefined, + to: string | undefined, +): I18nKey | null { + if (!from || !to) return "date_range.custom.invalid_missing"; + if (!isValidISODate(from) || !isValidISODate(to)) return "date_range.custom.invalid_format"; + if (Date.parse(`${from}T00:00:00Z`) >= Date.parse(`${to}T00:00:00Z`)) { + return "date_range.custom.invalid"; + } + return null; +} + +/** + * URLContract — the picker's stable URL serialization. Surfaces can + * override the param name via `key` so two pickers on the same page + * (rare) don't collide. + */ +export interface URLContract { + /** Base param name, defaults to "horizon". */ + key?: string; + /** Default value omitted from URL (matches surface's natural default). */ + default?: TimeHorizon; +} + +/** + * parseURL — reads a URL search-params object into a TimeSpec. + * + * ?horizon=past_30d → {horizon:"past_30d"} + * ?horizon=custom&from=2026-03-15&to=… → {horizon:"custom",from,to} + * (no params) → {horizon: contract.default ?? "any"} + * + * Unknown / malformed values fall back to the default. Out-of-shape + * custom dates clamp to {horizon: default} — the picker never lands + * in a half-custom state from a URL. + */ +export function parseURL( + params: URLSearchParams, + contract: URLContract = {}, +): TimeSpec { + const key = contract.key ?? "horizon"; + const fallback: TimeHorizon = contract.default ?? "any"; + + const raw = params.get(key); + if (raw === null) return { horizon: fallback }; + if (!isValidHorizon(raw)) return { horizon: fallback }; + if (raw !== "custom") return { horizon: raw }; + + const from = params.get(`${key}_from`) ?? undefined; + const to = params.get(`${key}_to`) ?? undefined; + if (validateCustomRange(from, to) !== null) { + return { horizon: fallback }; + } + return { horizon: "custom", from, to }; +} + +/** + * serializeURL — writes a TimeSpec into the URL search-params object, + * mutating the passed-in instance. Values equal to the surface + * default are OMITTED — the canonical URL stays short. + * + * Always deletes `horizon`, `_from`, `_to` first so a + * re-serialise after the picker reverts to default cleans up rather + * than accumulating stale entries. + */ +export function serializeURL( + spec: TimeSpec, + params: URLSearchParams, + contract: URLContract = {}, +): void { + const key = contract.key ?? "horizon"; + const fromKey = `${key}_from`; + const toKey = `${key}_to`; + + params.delete(key); + params.delete(fromKey); + params.delete(toKey); + + if (spec.horizon === (contract.default ?? "any") && spec.horizon !== "custom") { + return; + } + + if (spec.horizon === "custom") { + params.set(key, "custom"); + if (spec.from) params.set(fromKey, spec.from); + if (spec.to) params.set(toKey, spec.to); + return; + } + + params.set(key, spec.horizon); +} + +/** + * isDefault — used by surfaces to decide whether to render the + * "value is non-default" dot on the closed button. + */ +export function isDefault(spec: TimeSpec, defaultHorizon: TimeHorizon): boolean { + if (spec.horizon !== defaultHorizon) return false; + if (spec.horizon === "custom") return false; + return true; +} diff --git a/frontend/src/client/date-range-picker.ts b/frontend/src/client/date-range-picker.ts new file mode 100644 index 0000000..67fb196 --- /dev/null +++ b/frontend/src/client/date-range-picker.ts @@ -0,0 +1,470 @@ +// date-range-picker.ts — boot client + DOM mount for the symmetric +// date-range picker (t-paliad-248). The picker is a controlled +// component: callers pass `value` + `onChange`, the component renders +// the trigger button + popover scaffold, the popover materialises a +// chip row and (when "Anpassen" is picked) an inline date-pair editor. +// +// The picker reuses the existing `.agenda-chip` styling for chips and +// the `.multi-panel` popover pattern (auto-positioned under a +// `.multi-anchor` wrapper). Both patterns are battle-tested by the +// filter-bar + multi-select widgets — no new design tokens, no new +// dark-mode contrast risk. + +import { t } from "./i18n"; +import { + ALL_HORIZONS, + HORIZON_LABEL_KEY, + NEXT_HORIZONS, + PAST_HORIZONS, + isDefault, + isValidISODate, + validateCustomRange, + type TimeHorizon, + type TimeSpec, +} from "./date-range-picker-pure"; + +export interface MountOpts { + /** Current value. The picker is fully controlled. */ + value: TimeSpec; + /** Fired on every committed change (chip click or Anwenden). */ + onChange(next: TimeSpec): void; + /** + * Which horizon constitutes the "default" for this surface. Used + * for the non-default indicator dot. Defaults to `"any"`. + */ + defaultHorizon?: TimeHorizon; + /** + * Which chips to render. Order is preserved. Defaults to the full + * 14-chip fan from ALL_HORIZONS. + */ + presets?: readonly TimeHorizon[]; + /** + * Stable surface tag — feeds into the `data-testid` on every DOM + * node the picker creates so tests can scope. Example: "agenda", + * "filter-bar.time", "audit-log". + */ + surface: string; + /** + * Optional prefix for the closed-button label. The label always + * starts with the resolved horizon name (e.g. "Letzte 30 Tage"). + * Surfaces that want a heading prefix ("Zeitraum: Letzte 30 Tage") + * pass it here. + */ + labelPrefix?: string; +} + +export interface PickerHandle { + /** Root element — append to the host container. */ + element: HTMLElement; + /** Read the current value (may have been edited via Anpassen). */ + getValue(): TimeSpec; + /** Update the value from the host (e.g. after URL change). */ + setValue(next: TimeSpec): void; + /** Force-close the popover. Safe to call when already closed. */ + close(): void; + /** Detach event listeners + remove from DOM. */ + destroy(): void; +} + +/** + * Mount a date-range picker. The returned `element` is a single + * inline node containing both the trigger button and the popover + * (absolutely positioned via `.multi-anchor` + `.multi-panel`). + * + * The popover stays in the DOM permanently; opening/closing toggles + * the `[hidden]` attribute. This keeps the chip's tab-order stable + * and matches the multi-select widget's behaviour. + */ +export function mountDateRangePicker(opts: MountOpts): PickerHandle { + const presets = opts.presets ?? ALL_HORIZONS; + const defaultHorizon = opts.defaultHorizon ?? "any"; + let value: TimeSpec = normalize(opts.value); + + // Cached drafts for the "Anpassen" editor — preserved across + // open/close so the user doesn't lose their typing if they peek + // away. Seeded from the live value when the editor opens. + let customFromDraft = value.horizon === "custom" ? (value.from ?? "") : ""; + let customToDraft = value.horizon === "custom" ? (value.to ?? "") : ""; + let customEditorOpen = value.horizon === "custom"; + + const root = document.createElement("div"); + root.className = "date-range-anchor multi-anchor"; + root.dataset.testid = `${opts.surface}.date-range-picker`; + + const trigger = document.createElement("button"); + trigger.type = "button"; + trigger.className = "date-range-trigger"; + trigger.setAttribute("aria-haspopup", "dialog"); + trigger.setAttribute("aria-expanded", "false"); + trigger.dataset.testid = `${opts.surface}.date-range-trigger`; + + const panel = document.createElement("div"); + panel.className = "date-range-panel multi-panel"; + panel.setAttribute("role", "dialog"); + panel.setAttribute("aria-label", t("date_range.dialog.label")); + panel.hidden = true; + panel.dataset.testid = `${opts.surface}.date-range-panel`; + + root.appendChild(trigger); + root.appendChild(panel); + + renderTrigger(); + renderPanel(); + + // Open/close wiring. Click outside the root collapses the popover; + // Esc inside it bubbles up to the same handler via keydown delegate. + const onDocClick = (e: MouseEvent) => { + if (panel.hidden) return; + if (e.target instanceof Node && root.contains(e.target)) return; + closePopover(); + }; + const onKeydown = (e: KeyboardEvent) => { + if (panel.hidden) return; + if (e.key === "Escape") { + e.stopPropagation(); + closePopover(); + trigger.focus(); + } + }; + + trigger.addEventListener("click", () => { + if (panel.hidden) openPopover(); + else closePopover(); + }); + + document.addEventListener("mousedown", onDocClick); + document.addEventListener("keydown", onKeydown); + + function openPopover(): void { + panel.hidden = false; + trigger.setAttribute("aria-expanded", "true"); + // Re-render to reflect the very latest value (host may have + // patched via setValue between open/close). + renderPanel(); + // Move keyboard focus into the panel so Esc works without a + // prior click. The first chip is the natural landing spot. + const firstChip = panel.querySelector(".date-range-chip"); + firstChip?.focus({ preventScroll: true }); + } + + function closePopover(): void { + panel.hidden = true; + trigger.setAttribute("aria-expanded", "false"); + } + + function commit(next: TimeSpec, closeAfter: boolean): void { + value = normalize(next); + customEditorOpen = value.horizon === "custom"; + if (value.horizon === "custom") { + customFromDraft = value.from ?? ""; + customToDraft = value.to ?? ""; + } + renderTrigger(); + renderPanel(); + opts.onChange(value); + if (closeAfter) { + closePopover(); + trigger.focus({ preventScroll: true }); + } + } + + function renderTrigger(): void { + trigger.replaceChildren(); + if (!isDefault(value, defaultHorizon)) { + const dot = document.createElement("span"); + dot.className = "date-range-trigger-dot"; + dot.setAttribute("aria-hidden", "true"); + trigger.appendChild(dot); + } + const labelSpan = document.createElement("span"); + labelSpan.className = "date-range-trigger-label"; + labelSpan.textContent = labelFor(value, opts.labelPrefix); + trigger.appendChild(labelSpan); + + const chev = document.createElement("span"); + chev.className = "date-range-trigger-chev"; + chev.setAttribute("aria-hidden", "true"); + chev.textContent = "▾"; + trigger.appendChild(chev); + } + + function renderPanel(): void { + panel.replaceChildren(); + + // Three groups in a single row: past fan / ALLES centre / next fan. + const row = document.createElement("div"); + row.className = "date-range-row"; + + const pastGroup = renderFan( + PAST_HORIZONS.filter((h) => presets.includes(h)), + "past", + ); + const centerGroup = renderCenter(); + const nextGroup = renderFan( + NEXT_HORIZONS.filter((h) => presets.includes(h)), + "next", + ); + + if (pastGroup) row.appendChild(pastGroup); + if (centerGroup) row.appendChild(centerGroup); + if (nextGroup) row.appendChild(nextGroup); + + panel.appendChild(row); + + // Custom-range section ("Anpassen"). Toggle button + collapsible + // date-pair editor below. + if (presets.includes("custom")) { + panel.appendChild(renderCustomSection()); + } + } + + function renderFan(horizons: readonly TimeHorizon[], side: "past" | "next"): HTMLElement | null { + if (horizons.length === 0) return null; + const group = document.createElement("div"); + group.className = `date-range-fan date-range-fan--${side}`; + group.setAttribute("role", "group"); + group.setAttribute("aria-label", side === "past" + ? t("date_range.fan.past.label") + : t("date_range.fan.future.label")); + for (const h of horizons) { + group.appendChild(makeChip(h)); + } + return group; + } + + function renderCenter(): HTMLElement | null { + if (!presets.includes("any")) return null; + const wrap = document.createElement("div"); + wrap.className = "date-range-center"; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "date-range-center-btn"; + if (value.horizon === "any" || value.horizon === "all") { + btn.classList.add("date-range-center-btn--active"); + } + btn.setAttribute("aria-pressed", String(value.horizon === "any" || value.horizon === "all")); + btn.dataset.testid = `${opts.surface}.date-range-chip.any`; + + const glyph = document.createElement("span"); + glyph.className = "date-range-center-glyph"; + glyph.setAttribute("aria-hidden", "true"); + glyph.textContent = "⌖"; // ⌖ POSITION INDICATOR + const label = document.createElement("span"); + label.className = "date-range-center-label"; + label.textContent = t("date_range.center.label"); + btn.appendChild(glyph); + btn.appendChild(label); + + btn.addEventListener("click", () => { + commit({ horizon: "any" }, /*closeAfter*/ true); + }); + + wrap.appendChild(btn); + return wrap; + } + + function makeChip(h: TimeHorizon): HTMLButtonElement { + const chip = document.createElement("button"); + chip.type = "button"; + chip.className = "agenda-chip date-range-chip"; + if (value.horizon === h) chip.classList.add("agenda-chip-active"); + chip.setAttribute("aria-pressed", String(value.horizon === h)); + chip.textContent = t(HORIZON_LABEL_KEY[h]); + chip.dataset.testid = `${opts.surface}.date-range-chip.${h}`; + chip.addEventListener("click", () => { + commit({ horizon: h }, /*closeAfter*/ true); + }); + return chip; + } + + function renderCustomSection(): HTMLElement { + const section = document.createElement("div"); + section.className = "date-range-custom"; + + const toggleBtn = document.createElement("button"); + toggleBtn.type = "button"; + toggleBtn.className = "agenda-chip date-range-chip date-range-chip--custom"; + if (value.horizon === "custom") toggleBtn.classList.add("agenda-chip-active"); + toggleBtn.setAttribute("aria-expanded", String(customEditorOpen)); + toggleBtn.dataset.testid = `${opts.surface}.date-range-chip.custom`; + toggleBtn.textContent = t("date_range.horizon.custom"); + toggleBtn.addEventListener("click", () => { + customEditorOpen = !customEditorOpen; + renderPanel(); + if (customEditorOpen) { + // Focus the first input on expand. + panel.querySelector(".date-range-custom-from")?.focus(); + } + }); + section.appendChild(toggleBtn); + + if (!customEditorOpen) return section; + + const editor = document.createElement("div"); + editor.className = "date-range-custom-editor"; + + const fromWrap = document.createElement("label"); + fromWrap.className = "date-range-custom-field"; + const fromLbl = document.createElement("span"); + fromLbl.className = "date-range-custom-label"; + fromLbl.textContent = t("date_range.custom.from"); + const fromInput = document.createElement("input"); + fromInput.type = "date"; + fromInput.lang = "de"; + fromInput.className = "date-range-custom-from"; + fromInput.value = customFromDraft; + fromInput.dataset.testid = `${opts.surface}.date-range-custom-from`; + fromInput.addEventListener("input", () => { + customFromDraft = fromInput.value; + refreshValidity(); + }); + fromWrap.appendChild(fromLbl); + fromWrap.appendChild(fromInput); + + const toWrap = document.createElement("label"); + toWrap.className = "date-range-custom-field"; + const toLbl = document.createElement("span"); + toLbl.className = "date-range-custom-label"; + toLbl.textContent = t("date_range.custom.to"); + const toInput = document.createElement("input"); + toInput.type = "date"; + toInput.lang = "de"; + toInput.className = "date-range-custom-to"; + toInput.value = customToDraft; + toInput.dataset.testid = `${opts.surface}.date-range-custom-to`; + toInput.addEventListener("input", () => { + customToDraft = toInput.value; + refreshValidity(); + }); + toWrap.appendChild(toLbl); + toWrap.appendChild(toInput); + + const applyBtn = document.createElement("button"); + applyBtn.type = "button"; + applyBtn.className = "date-range-custom-apply"; + applyBtn.textContent = t("date_range.custom.apply"); + applyBtn.dataset.testid = `${opts.surface}.date-range-custom-apply`; + applyBtn.addEventListener("click", () => { + const err = validateCustomRange(customFromDraft, customToDraft); + if (err !== null) { + showError(err); + return; + } + commit( + { horizon: "custom", from: customFromDraft, to: customToDraft }, + /*closeAfter*/ true, + ); + }); + + const cancelBtn = document.createElement("button"); + cancelBtn.type = "button"; + cancelBtn.className = "date-range-custom-cancel"; + cancelBtn.textContent = t("date_range.custom.cancel"); + cancelBtn.addEventListener("click", () => { + customEditorOpen = false; + // Restore drafts from live value so a re-open shows the + // committed state rather than the abandoned typing. + customFromDraft = value.horizon === "custom" ? (value.from ?? "") : ""; + customToDraft = value.horizon === "custom" ? (value.to ?? "") : ""; + renderPanel(); + }); + + const errEl = document.createElement("div"); + errEl.className = "date-range-custom-error"; + errEl.hidden = true; + errEl.dataset.testid = `${opts.surface}.date-range-custom-error`; + + editor.appendChild(fromWrap); + editor.appendChild(toWrap); + editor.appendChild(applyBtn); + editor.appendChild(cancelBtn); + editor.appendChild(errEl); + section.appendChild(editor); + + refreshValidity(); + + function refreshValidity(): void { + const err = validateCustomRange(customFromDraft, customToDraft); + if (err === null) { + applyBtn.disabled = false; + errEl.hidden = true; + errEl.textContent = ""; + return; + } + applyBtn.disabled = true; + // Only surface the *content* error (`invalid` = inverted range) + // while the user is typing. Empty / format errors are visible + // through the disabled-Anwenden state alone — surfacing them on + // every keystroke would be noisy. + if (err === "date_range.custom.invalid") { + showError(err); + } else { + errEl.hidden = true; + } + } + + function showError(key: Parameters[0]): void { + errEl.textContent = t(key); + errEl.hidden = false; + } + + return section; + } + + return { + element: root, + getValue: () => normalize(value), + setValue(next: TimeSpec) { + value = normalize(next); + customEditorOpen = value.horizon === "custom"; + if (value.horizon === "custom") { + customFromDraft = value.from ?? ""; + customToDraft = value.to ?? ""; + } + renderTrigger(); + renderPanel(); + }, + close: closePopover, + destroy() { + document.removeEventListener("mousedown", onDocClick); + document.removeEventListener("keydown", onKeydown); + root.remove(); + }, + }; +} + +function normalize(spec: TimeSpec): TimeSpec { + if (spec.horizon === "custom") { + return { + horizon: "custom", + from: spec.from && isValidISODate(spec.from) ? spec.from : undefined, + to: spec.to && isValidISODate(spec.to) ? spec.to : undefined, + }; + } + return { horizon: spec.horizon }; +} + +function labelFor(spec: TimeSpec, prefix?: string): string { + let body: string; + if (spec.horizon === "custom") { + if (spec.from && spec.to) { + body = t("date_range.button.label.custom_range") + .replace("{from}", formatISO(spec.from)) + .replace("{to}", formatISO(spec.to)); + } else { + body = t("date_range.horizon.custom"); + } + } else { + body = t(HORIZON_LABEL_KEY[spec.horizon]); + } + return prefix ? `${prefix}: ${body}` : body; +} + +function formatISO(iso: string): string { + if (!isValidISODate(iso)) return iso; + // DE locale: DD.MM.YYYY. The picker is German-first; surfaces in EN + // can override via labelPrefix or by formatting before commit if + // they want a different shape. + const [y, m, d] = iso.split("-"); + return `${d}.${m}.${y}`; +} diff --git a/frontend/src/client/filter-bar/axes.ts b/frontend/src/client/filter-bar/axes.ts index 19d3311..2358344 100644 --- a/frontend/src/client/filter-bar/axes.ts +++ b/frontend/src/client/filter-bar/axes.ts @@ -12,6 +12,12 @@ // New classes are scoped under .filter-bar-* so they don't bleed. import { t, tDyn, type I18nKey } from "../i18n"; +import { mountDateRangePicker } from "../date-range-picker"; +import { + ALL_HORIZONS as DRP_ALL_HORIZONS, + type TimeHorizon as DRPTimeHorizon, + type TimeSpec as DRPTimeSpec, +} from "../date-range-picker-pure"; import type { BarState, AxisKey } from "./types"; export interface AxisCtx { @@ -57,60 +63,63 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts): } // ---------------------------------------------------------------------- -// time — chip cluster (presets + Anpassen) +// time — symmetric date-range picker (t-paliad-248, replaces the t-163 +// chip-cluster + disabled Anpassen stub). The picker emits a TimeSpec +// (horizon + optional custom from/to); the bar patches that onto +// BarState.time directly. // ---------------------------------------------------------------------- type TimeHorizonValue = NonNullable["horizon"]; -const TIME_PRESET_LABELS: Record = { - next_7d: "views.bar.time.next_7d", - next_30d: "views.bar.time.next_30d", - next_90d: "views.bar.time.next_90d", - past_7d: "views.bar.time.past_7d", - past_30d: "views.bar.time.past_30d", - past_90d: "views.bar.time.past_90d", - any: "views.bar.time.any", - all: "views.bar.time.all", - custom: "views.bar.time.custom", -}; - +// Default chip set when the surface doesn't override. Matches the +// forward-leaning bias of the legacy filter-bar default (the universal +// substrate is more often used for "what's coming up" than "what just +// happened") but now covers the full symmetric fan plus past_30d for +// quick recent-history lookups. const DEFAULT_TIME_PRESETS: TimeHorizonValue[] = [ - "next_7d", "next_30d", "next_90d", "past_30d", "any", + "past_30d", "past_7d", "any", "next_7d", "next_30d", "next_90d", "custom", ]; function renderTimeAxis(ctx: AxisCtx, presetOverride?: TimeHorizonValue[]): HTMLElement { const wrap = group("views.bar.label.time"); - const row = chipRow(); - const presets = presetOverride && presetOverride.length ? presetOverride : DEFAULT_TIME_PRESETS; - // "any" / "all" are both unbounded — clearing state is the cleanest - // representation, so each maps to "no overlay" rather than a stored - // horizon. The chip's active state then keys off "no time set". - const current = ctx.get("time")?.horizon ?? "any"; - for (const preset of presets) { - if (preset === "custom") continue; // custom rendered separately below - const isUnbounded = preset === "any" || preset === "all"; - const isActive = isUnbounded - ? !ctx.get("time") - : preset === current; - const chip = chipBtn(t(TIME_PRESET_LABELS[preset]), isActive); - chip.addEventListener("click", () => { - if (isUnbounded) { + const presetSource = presetOverride && presetOverride.length ? presetOverride : DEFAULT_TIME_PRESETS; + // The picker's pure module owns the complete chip set; we narrow it + // here to whatever this surface declares (preserving the surface's + // chip order so timePresets remains the override knob it always was). + const presets: DRPTimeHorizon[] = presetSource.flatMap((p) => + DRP_ALL_HORIZONS.includes(p as DRPTimeHorizon) ? [p as DRPTimeHorizon] : [], + ); + + const current = ctx.get("time"); + const initialValue: DRPTimeSpec = current + ? { horizon: current.horizon as DRPTimeHorizon, from: current.from, to: current.to } + : { horizon: "any" }; + + const picker = mountDateRangePicker({ + value: initialValue, + onChange(next) { + // The bar treats `any` as "no time overlay" (matches the legacy + // chip-cluster's behaviour) so the BarState stays minimal when + // the user lands on the centre ALLES button. + if (next.horizon === "any") { ctx.patch({ time: undefined }); - } else { - ctx.patch({ time: { horizon: preset } }); + return; } - }); - row.appendChild(chip); - } - // Custom range — placeholder chip; opens a small popover with two - // in Phase 2. For Phase 1 we render the chip - // disabled with a tooltip so the affordance is discoverable. - const customChip = chipBtn(t("views.bar.time.custom"), current === "custom"); - customChip.classList.add("filter-bar-chip-pending"); - customChip.title = t("views.bar.time.custom.coming_soon"); - customChip.disabled = true; - row.appendChild(customChip); - wrap.appendChild(row); + ctx.patch({ + time: { + horizon: next.horizon as TimeHorizonValue, + from: next.horizon === "custom" ? next.from : undefined, + to: next.horizon === "custom" ? next.to : undefined, + }, + }); + }, + defaultHorizon: "any", + presets, + surface: "filter-bar.time", + labelPrefix: t("views.bar.label.time"), + }); + + wrap.appendChild(picker.element); return wrap; } diff --git a/frontend/src/client/filter-bar/types.ts b/frontend/src/client/filter-bar/types.ts index ecf297d..ac1208f 100644 --- a/frontend/src/client/filter-bar/types.ts +++ b/frontend/src/client/filter-bar/types.ts @@ -65,7 +65,13 @@ export interface BarState { } export interface TimeOverlay { - horizon: "next_7d" | "next_30d" | "next_90d" | "past_7d" | "past_30d" | "past_90d" | "any" | "all" | "custom"; + // Mirrors internal/services/filter_spec.go TimeHorizon. t-paliad-248 + // added the symmetric 1d / 14d / all chips on each side; the union + // here is the wire-shape the URL codec parses and the picker emits. + horizon: + | "next_1d" | "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all" + | "past_1d" | "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all" + | "any" | "all" | "custom"; from?: string; // ISO 8601 — only when horizon === "custom" to?: string; } diff --git a/frontend/src/client/filter-bar/url-codec.test.ts b/frontend/src/client/filter-bar/url-codec.test.ts index 2fcbf03..e9fb7f8 100644 --- a/frontend/src/client/filter-bar/url-codec.test.ts +++ b/frontend/src/client/filter-bar/url-codec.test.ts @@ -18,7 +18,12 @@ describe("filter-bar/url-codec", () => { }); test("time horizon round-trips", () => { - for (const h of ["next_7d", "next_30d", "next_90d", "past_30d", "past_90d", "any", "all"] as const) { + // Includes the t-paliad-248 symmetric additions (1d / 14d / all on each side). + for (const h of [ + "next_1d", "next_7d", "next_14d", "next_30d", "next_90d", "next_all", + "past_1d", "past_7d", "past_14d", "past_30d", "past_90d", "past_all", + "any", "all", + ] as const) { expect(roundTrip({ time: { horizon: h } })).toEqual({ time: { horizon: h } }); } }); diff --git a/frontend/src/client/filter-bar/url-codec.ts b/frontend/src/client/filter-bar/url-codec.ts index 8526918..97d9ed6 100644 --- a/frontend/src/client/filter-bar/url-codec.ts +++ b/frontend/src/client/filter-bar/url-codec.ts @@ -172,12 +172,18 @@ export function encodeBar(state: BarState, params: URLSearchParams, ns?: string) function parseHorizon(s: string): TimeOverlay["horizon"] | null { switch (s) { + case "next_1d": case "next_7d": + case "next_14d": case "next_30d": case "next_90d": + case "next_all": + case "past_1d": case "past_7d": + case "past_14d": case "past_30d": case "past_90d": + case "past_all": case "any": case "all": case "custom": diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 8bd8719..1646fac 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -2689,11 +2689,18 @@ const translations: Record> = { "views.scope.my_subtree": "Mein Teilbaum", "views.scope.explicit": "Bestimmte Projekte", "views.scope.personal_only": "Nur persönliche", + "views.horizon.next_1d": "Morgen", "views.horizon.next_7d": "Nächste 7 Tage", + "views.horizon.next_14d": "Nächste 14 Tage", "views.horizon.next_30d": "Nächste 30 Tage", "views.horizon.next_90d": "Nächste 90 Tage", + "views.horizon.next_all": "Ganze Zukunft", + "views.horizon.past_1d": "Letzter Tag", + "views.horizon.past_7d": "Letzte 7 Tage", + "views.horizon.past_14d": "Letzte 14 Tage", "views.horizon.past_30d": "Letzte 30 Tage", "views.horizon.past_90d": "Letzte 90 Tage", + "views.horizon.past_all": "Ganze Vergangenheit", "views.horizon.any": "Beliebig", "views.horizon.all": "Komplett (alle Daten)", "views.horizon.custom": "Benutzerdefiniert", @@ -2777,16 +2784,10 @@ const translations: Record> = { "views.bar.label.density": "Dichte", "views.bar.label.sort": "Sortierung", "views.bar.common.all": "Alle", - "views.bar.time.next_7d": "7 Tage", - "views.bar.time.next_30d": "30 Tage", - "views.bar.time.next_90d": "90 Tage", - "views.bar.time.past_7d": "Letzte 7 T.", - "views.bar.time.past_30d": "Letzte 30 T.", - "views.bar.time.past_90d": "Letzte 90 T.", - "views.bar.time.any": "Beliebig", - "views.bar.time.all": "Alle Zeit", - "views.bar.time.custom": "Anpassen", - "views.bar.time.custom.coming_soon": "Benutzerdefinierter Zeitraum folgt in einer der nächsten Iterationen.", + // views.bar.time.* keys retired in t-paliad-248 — the filter-bar time + // axis now mounts the symmetric date-range picker, whose labels live + // under date_range.horizon.* (see end of this dict). The picker reuses + // views.bar.label.time as the closed-button prefix. "views.bar.personal.on": "Nur eigene", "views.bar.approval_role.approver_eligible": "Zur Genehmigung", "views.bar.approval_role.self_requested": "Eigene Anfragen", @@ -3013,6 +3014,38 @@ const translations: Record> = { "admin.rules.export.ok": "{n} Audit-Zeilen exportiert.", "admin.rules.export.error": "Export fehlgeschlagen.", "admin.rules.export.no_pending": "Keine offenen Audit-Zeilen zum Export.", + + // Date-range picker (t-paliad-248). Symmetric past/future chip fan + // around an ALLES centre. Used by the filter-bar 'time' axis from + // Slice A onwards; future slices will migrate /agenda and + // /admin/audit-log to the same component. + "date_range.button.label": "Zeitraum", + "date_range.button.label.custom_range": "Von {from} bis {to}", + "date_range.horizon.next_1d": "Morgen", + "date_range.horizon.next_7d": "Nächste 7 Tage", + "date_range.horizon.next_14d": "Nächste 14 Tage", + "date_range.horizon.next_30d": "Nächste 30 Tage", + "date_range.horizon.next_90d": "Nächste 90 Tage", + "date_range.horizon.next_all": "Ganze Zukunft", + "date_range.horizon.past_1d": "Letzter Tag", + "date_range.horizon.past_7d": "Letzte 7 Tage", + "date_range.horizon.past_14d": "Letzte 14 Tage", + "date_range.horizon.past_30d": "Letzte 30 Tage", + "date_range.horizon.past_90d": "Letzte 90 Tage", + "date_range.horizon.past_all": "Ganze Vergangenheit", + "date_range.horizon.any": "Alles", + "date_range.horizon.custom": "Anpassen", + "date_range.dialog.label": "Zeitraum wählen", + "date_range.fan.past.label": "Vergangenheit", + "date_range.fan.future.label": "Zukunft", + "date_range.center.label": "Alles", + "date_range.custom.from": "Von", + "date_range.custom.to": "Bis", + "date_range.custom.apply": "Anwenden", + "date_range.custom.cancel": "Abbrechen", + "date_range.custom.invalid": "Bis-Datum muss nach Von-Datum liegen.", + "date_range.custom.invalid_format": "Datum nicht erkannt (Format JJJJ-MM-TT).", + "date_range.custom.invalid_missing": "Bitte beide Datumsfelder ausfüllen.", }, en: { @@ -5657,11 +5690,18 @@ const translations: Record> = { "views.scope.my_subtree": "My subtree", "views.scope.explicit": "Specific projects", "views.scope.personal_only": "Personal only", + "views.horizon.next_1d": "Tomorrow", "views.horizon.next_7d": "Next 7 days", + "views.horizon.next_14d": "Next 14 days", "views.horizon.next_30d": "Next 30 days", "views.horizon.next_90d": "Next 90 days", + "views.horizon.next_all": "All future", + "views.horizon.past_1d": "Last day", + "views.horizon.past_7d": "Last 7 days", + "views.horizon.past_14d": "Last 14 days", "views.horizon.past_30d": "Last 30 days", "views.horizon.past_90d": "Last 90 days", + "views.horizon.past_all": "All past", "views.horizon.any": "Any", "views.horizon.all": "All-time", "views.horizon.custom": "Custom", @@ -5744,16 +5784,9 @@ const translations: Record> = { "views.bar.label.density": "Density", "views.bar.label.sort": "Sort", "views.bar.common.all": "All", - "views.bar.time.next_7d": "7 days", - "views.bar.time.next_30d": "30 days", - "views.bar.time.next_90d": "90 days", - "views.bar.time.past_7d": "Past 7d", - "views.bar.time.past_30d": "Past 30 d.", - "views.bar.time.past_90d": "Past 90 d.", - "views.bar.time.any": "Any", - "views.bar.time.all": "All time", - "views.bar.time.custom": "Custom", - "views.bar.time.custom.coming_soon": "Custom date range arrives in a follow-up iteration.", + // views.bar.time.* keys retired in t-paliad-248 — see the DE block + // for context. The filter-bar time axis now mounts the symmetric + // date-range picker whose labels live under date_range.horizon.*. "views.bar.personal.on": "Mine only", "views.bar.approval_role.approver_eligible": "To approve", "views.bar.approval_role.self_requested": "My requests", @@ -5980,6 +6013,35 @@ const translations: Record> = { "admin.rules.export.ok": "{n} audit rows exported.", "admin.rules.export.error": "Export failed.", "admin.rules.export.no_pending": "No pending audit rows to export.", + + // Date-range picker (t-paliad-248). See DE block above for details. + "date_range.button.label": "Time range", + "date_range.button.label.custom_range": "From {from} to {to}", + "date_range.horizon.next_1d": "Tomorrow", + "date_range.horizon.next_7d": "Next 7 days", + "date_range.horizon.next_14d": "Next 14 days", + "date_range.horizon.next_30d": "Next 30 days", + "date_range.horizon.next_90d": "Next 90 days", + "date_range.horizon.next_all": "All future", + "date_range.horizon.past_1d": "Last day", + "date_range.horizon.past_7d": "Last 7 days", + "date_range.horizon.past_14d": "Last 14 days", + "date_range.horizon.past_30d": "Last 30 days", + "date_range.horizon.past_90d": "Last 90 days", + "date_range.horizon.past_all": "All past", + "date_range.horizon.any": "All", + "date_range.horizon.custom": "Customize", + "date_range.dialog.label": "Choose time range", + "date_range.fan.past.label": "Past", + "date_range.fan.future.label": "Future", + "date_range.center.label": "All", + "date_range.custom.from": "From", + "date_range.custom.to": "To", + "date_range.custom.apply": "Apply", + "date_range.custom.cancel": "Cancel", + "date_range.custom.invalid": "End date must be strictly after start date.", + "date_range.custom.invalid_format": "Date not recognised (format YYYY-MM-DD).", + "date_range.custom.invalid_missing": "Please fill in both date fields.", }, }; diff --git a/frontend/src/client/projects-detail.ts b/frontend/src/client/projects-detail.ts index 9bf2465..185c64b 100644 --- a/frontend/src/client/projects-detail.ts +++ b/frontend/src/client/projects-detail.ts @@ -397,18 +397,26 @@ function applyVerlaufFilters(rows: ProjectEvent[]): ProjectEvent[] { // horizons that show up on the Verlauf bar. Forward-looking horizons // (next_*) are absent on this surface — the timePresets override hides // them — but the function tolerates them for forward-compatibility with -// the SmartTimeline redesign. +// the SmartTimeline redesign. Open-ended ranges (next_all / past_all) +// leave the matching bound undefined; the upstream filter treats that +// as "no narrowing in that direction". function horizonBounds(horizon: string): { from?: Date; to?: Date } { const now = new Date(); const day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate())); const offset = (days: number) => new Date(day.getTime() + days * 86400000); switch (horizon) { + case "past_1d": return { from: offset(-1), to: offset(1) }; case "past_7d": return { from: offset(-7), to: offset(1) }; + case "past_14d": return { from: offset(-14), to: offset(1) }; case "past_30d": return { from: offset(-30), to: offset(1) }; case "past_90d": return { from: offset(-90), to: offset(1) }; + case "past_all": return { to: offset(1) }; + case "next_1d": return { from: day, to: offset(1) }; case "next_7d": return { from: day, to: offset(7) }; + case "next_14d": return { from: day, to: offset(14) }; case "next_30d": return { from: day, to: offset(30) }; case "next_90d": return { from: day, to: offset(90) }; + case "next_all": return { from: day }; default: return {}; } } diff --git a/frontend/src/client/views/types.ts b/frontend/src/client/views/types.ts index e7f5a12..86483a9 100644 --- a/frontend/src/client/views/types.ts +++ b/frontend/src/client/views/types.ts @@ -19,8 +19,8 @@ export interface ScopeSpec { } export type TimeHorizon = - | "next_7d" | "next_30d" | "next_90d" - | "past_7d" | "past_30d" | "past_90d" + | "next_1d" | "next_7d" | "next_14d" | "next_30d" | "next_90d" | "next_all" + | "past_1d" | "past_7d" | "past_14d" | "past_30d" | "past_90d" | "past_all" | "any" | "all" | "custom"; export type TimeField = "auto" | "created_at"; diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 70aa2cb..3d98928 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -1137,6 +1137,33 @@ export type I18nKey = | "dashboard.urgency.urgent" | "dashboard.when.today" | "dashboard.when.tomorrow" + | "date_range.button.label" + | "date_range.button.label.custom_range" + | "date_range.center.label" + | "date_range.custom.apply" + | "date_range.custom.cancel" + | "date_range.custom.from" + | "date_range.custom.invalid" + | "date_range.custom.invalid_format" + | "date_range.custom.invalid_missing" + | "date_range.custom.to" + | "date_range.dialog.label" + | "date_range.fan.future.label" + | "date_range.fan.past.label" + | "date_range.horizon.any" + | "date_range.horizon.custom" + | "date_range.horizon.next_14d" + | "date_range.horizon.next_1d" + | "date_range.horizon.next_30d" + | "date_range.horizon.next_7d" + | "date_range.horizon.next_90d" + | "date_range.horizon.next_all" + | "date_range.horizon.past_14d" + | "date_range.horizon.past_1d" + | "date_range.horizon.past_30d" + | "date_range.horizon.past_7d" + | "date_range.horizon.past_90d" + | "date_range.horizon.past_all" | "deadlines.action.reopen" | "deadlines.adjusted" | "deadlines.adjusted.holiday" @@ -2714,16 +2741,6 @@ export type I18nKey = | "views.bar.shape.list" | "views.bar.sort.date_asc" | "views.bar.sort.date_desc" - | "views.bar.time.all" - | "views.bar.time.any" - | "views.bar.time.custom" - | "views.bar.time.custom.coming_soon" - | "views.bar.time.next_30d" - | "views.bar.time.next_7d" - | "views.bar.time.next_90d" - | "views.bar.time.past_30d" - | "views.bar.time.past_7d" - | "views.bar.time.past_90d" | "views.bar.timeline_status.court_set" | "views.bar.timeline_status.done" | "views.bar.timeline_status.macro.future" @@ -2796,11 +2813,18 @@ export type I18nKey = | "views.horizon.all" | "views.horizon.any" | "views.horizon.custom" + | "views.horizon.next_14d" + | "views.horizon.next_1d" | "views.horizon.next_30d" | "views.horizon.next_7d" | "views.horizon.next_90d" + | "views.horizon.next_all" + | "views.horizon.past_14d" + | "views.horizon.past_1d" | "views.horizon.past_30d" + | "views.horizon.past_7d" | "views.horizon.past_90d" + | "views.horizon.past_all" | "views.kind.appointment" | "views.kind.approval_request" | "views.kind.deadline" diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index ec0e816..d68e4fa 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -17525,3 +17525,258 @@ dialog.quick-add-sheet::backdrop { white-space: pre; margin: 0; } + +/* Date-range picker (t-paliad-248) ------------------------------------ + Symmetric past/future chip fan around an ALLES centre, in a popover + anchored under a closed-state trigger button. Reuses .agenda-chip / + .agenda-chip-active for the fan chips so the active state lights up + with the same lime accent as every other paliad filter-chip. The + popover scaffold reuses .multi-panel for shadow + border + z-index, + and .multi-anchor for the top:100% / left:0 positioning anchor. */ + +.date-range-anchor { + position: relative; + display: inline-flex; + align-items: center; +} + +.date-range-trigger { + appearance: none; + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: var(--color-surface-muted); + border: 1px solid var(--color-border); + border-radius: 999px; + padding: 0.35rem 0.85rem; + font-size: 0.85rem; + font-weight: 500; + color: var(--color-text); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.date-range-trigger:hover { + background: var(--color-overlay-subtle); + border-color: var(--color-accent-light); +} +.date-range-trigger:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} +.date-range-trigger[aria-expanded="true"] { + background: var(--color-bg-lime-tint); + border-color: var(--color-accent); +} + +.date-range-trigger-dot { + display: inline-block; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background: var(--color-accent); + flex-shrink: 0; +} + +.date-range-trigger-label { + white-space: nowrap; +} + +.date-range-trigger-chev { + font-size: 0.75rem; + color: var(--color-text-muted, #71717a); + margin-left: 0.1rem; +} + +.date-range-panel { + /* Inherits .multi-panel positioning + border + shadow. Widen it so + the symmetric fan + the custom editor have room to breathe. */ + width: 32rem; + max-width: calc(100vw - 1rem); + top: 100%; + left: 0; + padding: 0.75rem; + gap: 0.75rem; +} + +.date-range-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: stretch; +} + +.date-range-fan { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + align-content: flex-start; + flex: 1 1 12rem; + min-width: 0; +} + +.date-range-fan--past { + /* Past fan: outermost chip (Ganze Vergangenheit) leftmost. */ + justify-content: flex-end; +} + +.date-range-fan--next { + /* Future fan: innermost chip (Morgen / next_1d) leftmost. */ + justify-content: flex-start; +} + +.date-range-center { + display: flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + padding: 0 0.25rem; +} + +.date-range-center-btn { + appearance: none; + display: inline-flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.1rem; + background: var(--color-surface-muted); + border: 1px solid var(--color-border); + border-radius: 0.6rem; + min-width: 4.5rem; + padding: 0.55rem 0.75rem; + font-size: 0.75rem; + font-weight: 600; + color: var(--color-text); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.date-range-center-btn:hover { + background: var(--color-overlay-subtle); + border-color: var(--color-accent-light); +} +.date-range-center-btn:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; +} +.date-range-center-btn--active { + background: var(--color-accent); + border-color: var(--color-accent); + color: var(--color-accent-dark); +} + +.date-range-center-glyph { + font-size: 1.4rem; + line-height: 1; +} + +.date-range-center-label { + font-size: 0.7rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.date-range-chip { + /* .agenda-chip provides bg/border/radius/typography; this modifier + only tightens horizontal padding so more chips fit per row. */ + padding: 0.3rem 0.65rem; + font-size: 0.8rem; +} + +.date-range-chip--custom { + margin-top: 0.25rem; +} + +.date-range-custom { + border-top: 1px solid var(--color-border); + padding-top: 0.6rem; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.date-range-custom-editor { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 0.5rem; +} + +.date-range-custom-field { + display: flex; + flex-direction: column; + gap: 0.2rem; + font-size: 0.8rem; + color: var(--color-text); +} + +.date-range-custom-label { + font-weight: 500; + color: var(--color-text-muted, #71717a); +} + +.date-range-custom-from, +.date-range-custom-to { + appearance: none; + background: var(--color-bg); + border: 1px solid var(--color-border); + border-radius: 0.375rem; + padding: 0.3rem 0.4rem; + font-size: 0.85rem; + color: var(--color-text); + color-scheme: light dark; +} +.date-range-custom-from:focus-visible, +.date-range-custom-to:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 1px; +} + +.date-range-custom-apply, +.date-range-custom-cancel { + appearance: none; + background: var(--color-surface-muted); + border: 1px solid var(--color-border); + border-radius: 999px; + padding: 0.35rem 0.85rem; + font-size: 0.8rem; + font-weight: 500; + color: var(--color-text); + cursor: pointer; + transition: background 0.15s, border-color 0.15s, color 0.15s; +} +.date-range-custom-apply { + background: var(--color-accent); + border-color: var(--color-accent); + color: var(--color-accent-dark); +} +.date-range-custom-apply:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.date-range-custom-apply:hover:not(:disabled) { + background: var(--color-accent-light); +} +.date-range-custom-cancel:hover { + background: var(--color-overlay-subtle); +} + +.date-range-custom-error { + width: 100%; + font-size: 0.75rem; + color: var(--status-red-fg, #b91c1c); +} + +/* Mobile: stack past / centre / next vertically so each fan gets + the full popover width. */ +@media (max-width: 540px) { + .date-range-panel { + width: calc(100vw - 1rem); + } + .date-range-row { + flex-direction: column; + } + .date-range-fan--past, + .date-range-fan--next { + justify-content: flex-start; + } +}