feat(t-paliad-177): chart range chips + custom-range URL state

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.
This commit is contained in:
mAi
2026-05-13 11:49:24 +02:00
parent 8f5b83ec93
commit 581fbe7d92
6 changed files with 205 additions and 9 deletions

View File

@@ -1177,6 +1177,13 @@ const translations: Record<Lang, Record<string, string>> = {
"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<Lang, Record<string, string>> = {
"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)",

View File

@@ -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<string> = 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<void> {
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<void> {
});
}
// 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

View File

@@ -631,13 +631,28 @@ export const ALL_DENSITIES: ReadonlyArray<Density> = [
"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<RangePreset> = [
"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<TimelineEvent>,
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();

View File

@@ -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"

View File

@@ -64,8 +64,22 @@ export function renderProjectsChart(): string {
<span className="chip-inert" data-i18n="projects.chart.control.layout.horizontal" title="Slice 3">
Layout: Horizontal
</span>
<span className="chip-inert" data-i18n="projects.chart.control.columns.auto" title="Slice 3">
Spalten: Auto
<span className="smart-timeline-chart-picker">
<label htmlFor="projects-chart-range" data-i18n="projects.chart.control.range.label">
Zeitraum:
</label>
<select id="projects-chart-range">
<option value="1y" data-i18n="projects.chart.range.1y">1 Jahr</option>
<option value="2y" data-i18n="projects.chart.range.2y">2 Jahre</option>
<option value="all" data-i18n="projects.chart.range.all">Alles anzeigen</option>
<option value="custom" data-i18n="projects.chart.range.custom">Eigener Bereich</option>
</select>
</span>
<span className="smart-timeline-chart-picker smart-timeline-chart-range-custom" id="projects-chart-range-custom" style="display:none">
<label htmlFor="projects-chart-range-from" data-i18n="projects.chart.range.from">Von:</label>
<input type="date" id="projects-chart-range-from" />
<label htmlFor="projects-chart-range-to" data-i18n="projects.chart.range.to">Bis:</label>
<input type="date" id="projects-chart-range-to" />
</span>
<span className="smart-timeline-chart-picker">
<label htmlFor="projects-chart-density" data-i18n="projects.chart.control.density.label">

View File

@@ -14501,6 +14501,13 @@ dialog.quick-add-sheet::backdrop {
outline: 2px solid var(--color-accent, #c6f41c);
outline-offset: 2px;
}
.smart-timeline-chart-range-custom input[type="date"] {
border: none;
background: transparent;
color: inherit;
font: inherit;
padding: 0 0.25rem;
}
/* ---- Print stylesheet (t-paliad-177 Slice 2, design §7.4) ----
When the user hits "PDF (Drucken)", the browser invokes print() and