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).
471 lines
16 KiB
TypeScript
471 lines
16 KiB
TypeScript
// 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<HTMLButtonElement>(".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<HTMLInputElement>(".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<typeof t>[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}`;
|
|
}
|