From 4f910e31ea849d02050da0b5944c4502f09575ef Mon Sep 17 00:00:00 2001 From: mAi Date: Mon, 25 May 2026 16:45:30 +0200 Subject: [PATCH] =?UTF-8?q?mAi:=20#110=20-=20t-paliad-278=20=E2=80=94=203-?= =?UTF-8?q?column=20date-range=20picker=20(Past/NOW/Future,=20closeness-to?= =?UTF-8?q?-NOW=20sort)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures atlas's #79 horizontal row into 3 vertical columns: Past (left), NOW (middle), Future (right). Each column sorts by closeness to NOW (closest at top, farthest at bottom) — the picker now reads as a spatial map of time around the current moment instead of a flat horizontal fan. Layout Vergangenheit ⌖ Zukunft Letzte 7 Tage Heute Nächste 7 Tage Letzte 30 Tage Alles Nächste 30 Tage Letzte 90 Tage Nächste 90 Tage Ganze Vergangenheit Ganze Zukunft Changes - date-range-picker.ts — renderPanel builds .date-range-grid with three vertical .date-range-col children. Past column iterates PAST_HORIZONS reversed (past_1d → past_all top-to-bottom). NOW column hosts next_1d ("Heute") + any ("Alles") plus a ⌖ glyph header. Future column iterates NEXT_HORIZONS minus next_1d (which moved to NOW). Legacy "all" horizon still lights up the Alles chip for saved-Custom-View back-compat. - global.css — replace .date-range-row/.date-range-fan/.date-range- center{,-btn,-glyph,-label} with .date-range-grid + .date-range-col + .date-range-col-heading. Chips stretch to 100% column width for a clean vertical stack. Panel widened from 32rem to 34rem so "Ganze Vergangenheit" never wraps. Mobile (max-width 540px) collapses the grid to a single column, preserving in-column sort. - i18n.ts — next_1d label fixed from "Morgen"/"Tomorrow" to "Heute"/ "Today". next_1d's bounds are [today, tomorrow) = single-day today, so the prior label was semantically wrong; renaming aligns the label with the bounds and matches m's "Heute" spec for the NOW column. - axes.ts — DEFAULT_TIME_PRESETS updated to match m's spec (4 past + Heute + Alles + 4 future + custom). projects-detail.ts continues to override via timePresets for its past-only Verlauf surface. 12 horizon values in the union remain unchanged — PAST_HORIZONS / NEXT_HORIZONS registries and parseURL still accept past_1d / past_14d / next_14d for back-compat with saved URLs; the default picker UI just no longer surfaces chips for them. Surfaces that want the finer granularity can opt back in via timePresets. Verification - bun test src/client/date-range-picker-pure.test.ts — 38 pass - bun run build — i18n + branding + bundle clean - go build ./... — clean - go test ./internal/... — pass --- frontend/src/client/date-range-picker.ts | 114 ++++++++++++--------- frontend/src/client/filter-bar/axes.ts | 15 +-- frontend/src/client/i18n.ts | 4 +- frontend/src/styles/global.css | 120 ++++++++--------------- 4 files changed, 120 insertions(+), 133 deletions(-) diff --git a/frontend/src/client/date-range-picker.ts b/frontend/src/client/date-range-picker.ts index 67fb196..76d5d3e 100644 --- a/frontend/src/client/date-range-picker.ts +++ b/frontend/src/client/date-range-picker.ts @@ -191,25 +191,37 @@ export function mountDateRangePicker(opts: MountOpts): PickerHandle { 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"; + // Three vertical columns: Past (closest→farthest top→bottom), + // NOW (Heute + Alles), Future (closest→farthest). The grid + // visualises time as space around NOW — each column's top is + // closest to the current moment, bottom is furthest away. + const grid = document.createElement("div"); + grid.className = "date-range-grid"; - const pastGroup = renderFan( - PAST_HORIZONS.filter((h) => presets.includes(h)), + // Past column: PAST_HORIZONS registry is outermost→innermost + // (past_all → past_1d); reverse for closeness-to-NOW ordering + // (past_1d at top, past_all at bottom). + const pastCol = renderColumn( "past", + t("date_range.fan.past.label"), + [...PAST_HORIZONS].reverse().filter((h) => presets.includes(h)), ); - const centerGroup = renderCenter(); - const nextGroup = renderFan( - NEXT_HORIZONS.filter((h) => presets.includes(h)), - "next", + const nowCol = renderNowColumn(); + // Future column: NEXT_HORIZONS registry is already in closeness + // order (next_1d → next_all). next_1d moves to the NOW column as + // "Heute" (semantically just-today, single-day window), so the + // future column skips it. + const futureCol = renderColumn( + "future", + t("date_range.fan.future.label"), + NEXT_HORIZONS.filter((h) => h !== "next_1d" && presets.includes(h)), ); - if (pastGroup) row.appendChild(pastGroup); - if (centerGroup) row.appendChild(centerGroup); - if (nextGroup) row.appendChild(nextGroup); + if (pastCol) grid.appendChild(pastCol); + if (nowCol) grid.appendChild(nowCol); + if (futureCol) grid.appendChild(futureCol); - panel.appendChild(row); + panel.appendChild(grid); // Custom-range section ("Anpassen"). Toggle button + collapsible // date-pair editor below. @@ -218,49 +230,57 @@ export function mountDateRangePicker(opts: MountOpts): PickerHandle { } } - function renderFan(horizons: readonly TimeHorizon[], side: "past" | "next"): HTMLElement | null { + function renderColumn( + side: "past" | "future", + heading: string, + horizons: readonly TimeHorizon[], + ): 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")); + const col = document.createElement("div"); + col.className = `date-range-col date-range-col--${side}`; + col.setAttribute("role", "group"); + col.setAttribute("aria-label", heading); + + const head = document.createElement("div"); + head.className = "date-range-col-heading"; + head.textContent = heading; + col.appendChild(head); + for (const h of horizons) { - group.appendChild(makeChip(h)); + col.appendChild(makeChip(h)); } - return group; + return col; } - 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`; + function renderNowColumn(): HTMLElement | null { + const showHeute = presets.includes("next_1d"); + const showAlles = presets.includes("any"); + if (!showHeute && !showAlles) return null; - const glyph = document.createElement("span"); - glyph.className = "date-range-center-glyph"; + const col = document.createElement("div"); + col.className = "date-range-col date-range-col--now"; + col.setAttribute("role", "group"); + col.setAttribute("aria-label", t("date_range.center.label")); + + const glyph = document.createElement("div"); + glyph.className = "date-range-col-heading date-range-col-heading--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); + col.appendChild(glyph); - btn.addEventListener("click", () => { - commit({ horizon: "any" }, /*closeAfter*/ true); - }); - - wrap.appendChild(btn); - return wrap; + if (showHeute) col.appendChild(makeChip("next_1d")); + if (showAlles) { + const allesChip = makeChip("any"); + // Legacy "all" horizon also lights up Alles for back-compat + // with saved Custom Views that store the bidirectional-unbounded + // value (Q26 — parser preserves it, picker surfaces it here). + if (value.horizon === "all") { + allesChip.classList.add("agenda-chip-active"); + allesChip.setAttribute("aria-pressed", "true"); + } + col.appendChild(allesChip); + } + return col; } function makeChip(h: TimeHorizon): HTMLButtonElement { diff --git a/frontend/src/client/filter-bar/axes.ts b/frontend/src/client/filter-bar/axes.ts index 7119616..2743639 100644 --- a/frontend/src/client/filter-bar/axes.ts +++ b/frontend/src/client/filter-bar/axes.ts @@ -73,13 +73,16 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts): type TimeHorizonValue = NonNullable["horizon"]; -// 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. +// Default chip set when the surface doesn't override. Mirrors m's +// 3-column picker spec (t-paliad-278): symmetric 7d/30d/90d/all fan +// per side, plus Heute (next_1d) + Alles (any) in the centre column, +// plus Anpassen. Surfaces with a tighter scope (project history is +// past-only) keep overriding via `timePresets`. const DEFAULT_TIME_PRESETS: TimeHorizonValue[] = [ - "past_30d", "past_7d", "any", "next_7d", "next_30d", "next_90d", "custom", + "past_7d", "past_30d", "past_90d", "past_all", + "next_1d", "any", + "next_7d", "next_30d", "next_90d", "next_all", + "custom", ]; function renderTimeAxis(ctx: AxisCtx, presetOverride?: TimeHorizonValue[]): HTMLElement { diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 6eff7bd..060b123 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -3044,7 +3044,7 @@ const translations: Record> = { // /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_1d": "Heute", "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", @@ -6074,7 +6074,7 @@ const translations: Record> = { // 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_1d": "Today", "date_range.horizon.next_7d": "Next 7 days", "date_range.horizon.next_14d": "Next 14 days", "date_range.horizon.next_30d": "Next 30 days", diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 4501da9..b8de29a 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -17633,9 +17633,10 @@ dialog.quick-add-sheet::backdrop { } .date-range-panel { - /* Inherits .multi-panel positioning + border + shadow. Widen it so - the symmetric fan + the custom editor have room to breathe. */ - width: 32rem; + /* Inherits .multi-panel positioning + border + shadow. Sized so the + 3-column grid holds the widest chip text ("Ganze Vergangenheit") + without wrapping while staying within the viewport on tablets. */ + width: 34rem; max-width: calc(100vw - 1rem); top: 100%; left: 0; @@ -17643,88 +17644,54 @@ dialog.quick-add-sheet::backdrop { gap: 0.75rem; } -.date-range-row { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - align-items: stretch; +.date-range-grid { + /* Past / NOW / Future as three equal vertical columns. Each column + is a top-aligned chip stack so closeness-to-NOW (closest at top, + farthest at bottom) reads spatially. */ + display: grid; + grid-template-columns: 1fr 1fr 1fr; + gap: 0.75rem; + align-items: start; } -.date-range-fan { +.date-range-col { display: flex; - flex-wrap: wrap; - gap: 0.3rem; - align-content: flex-start; - flex: 1 1 12rem; + flex-direction: column; + gap: 0.35rem; min-width: 0; } -.date-range-fan--past { - /* Past fan: outermost chip (Ganze Vergangenheit) leftmost. */ - justify-content: flex-end; +.date-range-col--now { + align-items: stretch; } -.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 { +.date-range-col-heading { font-size: 0.7rem; + font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; + color: var(--color-text-muted, #71717a); + text-align: center; + padding-bottom: 0.15rem; +} + +.date-range-col-heading--glyph { + font-size: 1.3rem; + line-height: 1; + letter-spacing: 0; + text-transform: none; + color: var(--color-text-muted, #71717a); } .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; + /* .agenda-chip provides bg/border/radius/typography; in the + 3-column stack each chip fills its column so the closeness-to-NOW + ordering reads as a single vertical column rather than a ragged + row. */ + padding: 0.35rem 0.65rem; font-size: 0.8rem; + width: 100%; + text-align: center; } .date-range-chip--custom { @@ -17811,17 +17778,14 @@ dialog.quick-add-sheet::backdrop { color: var(--status-red-fg, #b91c1c); } -/* Mobile: stack past / centre / next vertically so each fan gets - the full popover width. */ +/* Mobile: stack the 3 columns vertically (one column per row), + preserving the closeness-to-NOW sort within each column. */ @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; + .date-range-grid { + grid-template-columns: 1fr; + gap: 0.5rem; } }