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:
@@ -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)",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user