From 581fbe7d923bcdb7aae68505ee7569f725644575 Mon Sep 17 00:00:00 2001 From: mAi Date: Wed, 13 May 2026 11:49:24 +0200 Subject: [PATCH] feat(t-paliad-177): chart range chips + custom-range URL state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slice 3 step 1. Four range presets per design §10 + faraday-Q8 default: 1y (today-1y..today+1y, default), 2y, all (derives bounds from loaded events with a +30d right pad), and custom (date-pair inputs). mount() grows currentRangePreset + customRangeFrom + customRangeTo so the layout-time viewport is computed from the live preset, not the constructor-time opts. resolveRange() handles the four cases; "all" calls rangeFromEvents() over the last fetched timeline so completing or adding a row reflows on next repaint. URL state in ?range=1y|2y|all|custom (omit when 1y); custom adds ?from=&to=. ISO_DATE_RE guards malformed input. Custom date-pair shows / hides based on the preset. i18n: 7 new keys DE+EN under projects.chart.range.*. Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §10 + §14 Q8. --- frontend/src/client/i18n.ts | 14 +++ frontend/src/client/projects-chart.ts | 81 +++++++++++++++++ .../src/client/views/shape-timeline-chart.ts | 87 +++++++++++++++++-- frontend/src/i18n-keys.ts | 7 ++ frontend/src/projects-chart.tsx | 18 +++- frontend/src/styles/global.css | 7 ++ 6 files changed, 205 insertions(+), 9 deletions(-) diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 3eb4a76..c753469 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -1177,6 +1177,13 @@ const translations: Record> = { "projects.chart.density.compact": "Kompakt", "projects.chart.density.standard": "Standard", "projects.chart.density.spacious": "Großzügig", + "projects.chart.control.range.label": "Zeitraum:", + "projects.chart.range.1y": "1 Jahr", + "projects.chart.range.2y": "2 Jahre", + "projects.chart.range.all": "Alles anzeigen", + "projects.chart.range.custom": "Eigener Bereich…", + "projects.chart.range.from": "Von:", + "projects.chart.range.to": "Bis:", "projects.chart.export.menu": "⇓ Export", "projects.chart.export.svg": "SVG (Vektorgrafik)", "projects.chart.export.png": "PNG (Bild, 2× HiDPI)", @@ -3502,6 +3509,13 @@ const translations: Record> = { "projects.chart.density.compact": "Compact", "projects.chart.density.standard": "Standard", "projects.chart.density.spacious": "Spacious", + "projects.chart.control.range.label": "Range:", + "projects.chart.range.1y": "1 year", + "projects.chart.range.2y": "2 years", + "projects.chart.range.all": "Show all", + "projects.chart.range.custom": "Custom range…", + "projects.chart.range.from": "From:", + "projects.chart.range.to": "To:", "projects.chart.export.menu": "⇓ Export", "projects.chart.export.svg": "SVG (vector graphic)", "projects.chart.export.png": "PNG (raster, 2× HiDPI)", diff --git a/frontend/src/client/projects-chart.ts b/frontend/src/client/projects-chart.ts index 7731f94..3f49dec 100644 --- a/frontend/src/client/projects-chart.ts +++ b/frontend/src/client/projects-chart.ts @@ -3,10 +3,12 @@ import { initSidebar } from "./sidebar"; import { ALL_DENSITIES, ALL_PALETTES, + ALL_RANGE_PRESETS, mount, type ChartHandle, type Density, type Palette, + type RangePreset, } from "./views/shape-timeline-chart"; import { exportCSV, @@ -68,6 +70,53 @@ function writeDensityToURL(density: Density): void { writeParamToURL("density", density, "standard"); } +const RANGE_SET: ReadonlySet = new Set(ALL_RANGE_PRESETS); + +interface RangeState { + preset: RangePreset; + from?: string; + to?: string; +} + +const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/; + +function rangeFromURL(): RangeState { + const params = new URLSearchParams(window.location.search); + const raw = params.get("range"); + const preset: RangePreset = raw && RANGE_SET.has(raw) ? (raw as RangePreset) : "1y"; + if (preset === "custom") { + const from = params.get("from") || ""; + const to = params.get("to") || ""; + return { + preset, + from: ISO_DATE_RE.test(from) ? from : undefined, + to: ISO_DATE_RE.test(to) ? to : undefined, + }; + } + return { preset }; +} + +function writeRangeToURL(state: RangeState): void { + const params = new URLSearchParams(window.location.search); + if (state.preset === "1y") { + params.delete("range"); + } else { + params.set("range", state.preset); + } + if (state.preset === "custom") { + if (state.from) params.set("from", state.from); + else params.delete("from"); + if (state.to) params.set("to", state.to); + else params.delete("to"); + } else { + params.delete("from"); + params.delete("to"); + } + const qs = params.toString(); + const next = window.location.pathname + (qs ? "?" + qs : ""); + window.history.replaceState(null, "", next); +} + /** Shared URL writer — omits the param when it equals its default, so the * canonical URL stays short and dedupable. */ function writeParamToURL(name: string, value: string, defaultValue: string): void { @@ -137,12 +186,16 @@ async function boot(): Promise { const initialPalette = paletteFromURL(); const initialDensity = densityFromURL(); + const initialRange = rangeFromURL(); let handle: ChartHandle | null = null; try { handle = mount(host, { projectId: id, palette: initialPalette, density: initialDensity, + rangePreset: initialRange.preset, + rangeFrom: initialRange.from, + rangeTo: initialRange.to, }); } catch (err) { console.error("chart mount failed", err); @@ -178,6 +231,34 @@ async function boot(): Promise { }); } + // Range chips — 4-option select plus a custom date-pair that shows + // only when preset === "custom". Per design §8.2 + faraday-Q8 default. + const rangeSel = document.getElementById("projects-chart-range") as HTMLSelectElement | null; + const rangeCustomWrap = document.getElementById("projects-chart-range-custom"); + const rangeFromInput = document.getElementById("projects-chart-range-from") as HTMLInputElement | null; + const rangeToInput = document.getElementById("projects-chart-range-to") as HTMLInputElement | null; + if (rangeSel && rangeCustomWrap && rangeFromInput && rangeToInput) { + rangeSel.value = initialRange.preset; + if (initialRange.preset === "custom") { + rangeCustomWrap.style.display = ""; + if (initialRange.from) rangeFromInput.value = initialRange.from; + if (initialRange.to) rangeToInput.value = initialRange.to; + } + const applyRange = () => { + const preset = rangeSel.value; + if (!RANGE_SET.has(preset)) return; + const p = preset as RangePreset; + rangeCustomWrap.style.display = p === "custom" ? "" : "none"; + const from = rangeFromInput.value || undefined; + const to = rangeToInput.value || undefined; + handle!.setRange(p, from, to); + writeRangeToURL({ preset: p, from, to }); + }; + rangeSel.addEventListener("change", applyRange); + rangeFromInput.addEventListener("change", applyRange); + rangeToInput.addEventListener("change", applyRange); + } + // Export menu. Each button maps to one chart-export function; the // handle exposes the live SVG + last-fetched data needed to compose // an ExportContext. Errors land in the host's message area so the diff --git a/frontend/src/client/views/shape-timeline-chart.ts b/frontend/src/client/views/shape-timeline-chart.ts index b439513..904494c 100644 --- a/frontend/src/client/views/shape-timeline-chart.ts +++ b/frontend/src/client/views/shape-timeline-chart.ts @@ -631,13 +631,28 @@ export const ALL_DENSITIES: ReadonlyArray = [ "spacious", ]; +/** Range presets from design §10 + faraday-Q8 default. The chart caller + * drives the active preset via setRange; "all" derives bounds from the + * loaded events at repaint time so adding / completing a row reflows. */ +export type RangePreset = "1y" | "2y" | "all" | "custom"; + +export const ALL_RANGE_PRESETS: ReadonlyArray = [ + "1y", + "2y", + "all", + "custom", +]; + export interface ChartMountOpts { projectId: string; todayISO?: string; density?: Density; palette?: Palette; - /** Optional ISO YYYY-MM-DD overrides for the date range. When omitted, - * mount picks `today-1y .. today+1y` per design Q8. */ + /** Initial range preset. Default "1y" (today-1y..today+1y) per design Q8. */ + rangePreset?: RangePreset; + /** When rangePreset === "custom", these supply the bounds. Ignored for + * preset values — those derive bounds from the preset + todayISO (or, + * for "all", from the loaded events). */ rangeFrom?: string; rangeTo?: string; /** Optional callback fired when the user clicks a mark with a known @@ -656,6 +671,10 @@ export interface ChartHandle { setPalette: (palette: Palette) => void; /** Swap density. Re-runs layout() since lane height / mark radius change. */ setDensity: (density: Density) => void; + /** Switch range preset. "all" derives bounds from the loaded events; + * "custom" expects customFrom + customTo (otherwise it falls back to + * today-1y..today+1y). All others are time-shifted from todayISO. */ + setRange: (preset: RangePreset, customFrom?: string, customTo?: string) => void; /** The raw SVG node — chart-export.ts reads this for SVG / PNG / print. */ getSVGElement: () => SVGSVGElement; /** Last-loaded data — chart-export.ts reads this for CSV / JSON / iCal. */ @@ -692,24 +711,38 @@ export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle { let lastLayout: ChartLayout | null = null; const todayISO = opts.todayISO ?? today(); - const rangeFrom = opts.rangeFrom ?? shiftYears(todayISO, -1); - const rangeTo = opts.rangeTo ?? shiftYears(todayISO, 1); - let currentDensity: Density = opts.density ?? "standard"; + let currentRangePreset: RangePreset = opts.rangePreset ?? "1y"; + let customRangeFrom: string = opts.rangeFrom ?? shiftYears(todayISO, -1); + let customRangeTo: string = opts.rangeTo ?? shiftYears(todayISO, 1); + + function resolveRange(): { from: string; to: string } { + switch (currentRangePreset) { + case "1y": + return { from: shiftYears(todayISO, -1), to: shiftYears(todayISO, 1) }; + case "2y": + return { from: shiftYears(todayISO, -2), to: shiftYears(todayISO, 2) }; + case "all": + return rangeFromEvents(lastEvents, todayISO); + case "custom": + return { from: customRangeFrom, to: customRangeTo }; + } + } function repaint(): void { const rect = host.getBoundingClientRect(); // Minimum width keeps the canvas usable when the host is hidden / // about to be sized; resize listener will repaint on real layout. const width = Math.max(640, rect.width || 1000); + const { from, to } = resolveRange(); const viewport: ChartViewport = { width, height: 400, laneLabelWidth: 200, dateAxisHeight: 40, todayISO, - rangeFrom, - rangeTo, + rangeFrom: from, + rangeTo: to, density: currentDensity, }; const chart = layout(lastEvents, [...currentLanes], viewport); @@ -805,6 +838,15 @@ export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle { svgEl.setAttribute("data-density", density); repaint(); }, + setRange: (preset: RangePreset, customFrom?: string, customTo?: string) => { + currentRangePreset = preset; + if (preset === "custom") { + if (customFrom) customRangeFrom = customFrom; + if (customTo) customRangeTo = customTo; + } + svgEl.setAttribute("data-range-preset", preset); + repaint(); + }, getSVGElement: () => svgEl, getData: () => ({ events: lastEvents, lanes: currentLanes }), dispose: () => { @@ -817,6 +859,37 @@ export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle { }; } +/** Resolve the "all" preset bounds from the loaded events. Empty data + * falls back to the 1y default so the chart canvas isn't degenerate. */ +function rangeFromEvents( + events: ReadonlyArray, + todayISO: string, +): { from: string; to: string } { + let minMs: number | null = null; + let maxMs: number | null = null; + for (const ev of events) { + if (!ev.date) continue; + const ms = parseISODay(ev.date); + if (ms === null) continue; + if (minMs === null || ms < minMs) minMs = ms; + if (maxMs === null || ms > maxMs) maxMs = ms; + } + if (minMs === null || maxMs === null) { + return { from: shiftYears(todayISO, -1), to: shiftYears(todayISO, 1) }; + } + // Pad +30d at the right so the last event isn't flush against the edge. + const fromDate = new Date(minMs); + const toDate = new Date(maxMs + 30 * 86_400_000); + return { + from: toISO(fromDate), + to: toISO(toDate), + }; +} + +function toISO(d: Date): string { + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`; +} + function today(): string { const d = new Date(); const y = d.getFullYear(); diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 6fcb43a..75d9cc2 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -1631,6 +1631,7 @@ export type I18nKey = | "projects.chart.control.layout.horizontal" | "projects.chart.control.palette.default" | "projects.chart.control.palette.label" + | "projects.chart.control.range.label" | "projects.chart.density.compact" | "projects.chart.density.spacious" | "projects.chart.density.standard" @@ -1649,6 +1650,12 @@ export type I18nKey = | "projects.chart.palette.kind_coded" | "projects.chart.palette.print" | "projects.chart.palette.track_coded" + | "projects.chart.range.1y" + | "projects.chart.range.2y" + | "projects.chart.range.all" + | "projects.chart.range.custom" + | "projects.chart.range.from" + | "projects.chart.range.to" | "projects.chart.title" | "projects.chip.all" | "projects.chip.has_open_deadlines" diff --git a/frontend/src/projects-chart.tsx b/frontend/src/projects-chart.tsx index 1931d4c..7666a2e 100644 --- a/frontend/src/projects-chart.tsx +++ b/frontend/src/projects-chart.tsx @@ -64,8 +64,22 @@ export function renderProjectsChart(): string { Layout: Horizontal - - Spalten: Auto + + + + +