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).
293 lines
9.9 KiB
TypeScript
293 lines
9.9 KiB
TypeScript
// 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<string> = 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<TimeHorizon, I18nKey> = {
|
|
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`, `<key>_from`, `<key>_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;
|
|
}
|