Files
paliad/frontend/src/client/views/shape-timeline-chart.ts
mAi 8020cb2ddb feat(t-paliad-211): timeline shape adds zoom toolbar and clamped lane labels
shape-timeline-cv now wraps the chart host with a toolbar carrying
+/- zoom buttons and 1y/2y/all chips. Active zoom persists in the URL as
?tl_zoom=1y|2y|all (URL > render-spec range_preset > "1y" default), so
saved views still control the initial zoom but per-session navigation is
deep-linkable.

shape-timeline-chart paints lane labels inside a foreignObject containing
an HTML <div> with overflow:hidden + text-overflow:ellipsis + a title
attribute carrying the full text. Long project names no longer bleed
across the chart canvas; hover reveals the full label.

i18n: views.timeline.zoom.{label,in,out,1y,2y,all} (DE+EN).
2026-05-18 17:45:30 +02:00

986 lines
32 KiB
TypeScript

import type { LaneInfo, TimelineEvent } from "./shape-timeline";
// shape-timeline-chart (t-paliad-177 Slice 1) — horizontal SVG Gantt
// renderer for the standalone Project Timeline / Chart page.
//
// Split into two concerns:
//
// layout(events, lanes, viewport): ChartLayout
// pure function — translates the wire shape into deterministic
// SVG-ready geometry (axis ticks, lane row y/height, mark x/y/shape,
// today-rule x). No DOM access. Table-driven tests pin this in
// shape-timeline-chart.test.ts.
//
// paint(layout, root): void (Slice 1, next commit)
// DOM-mutates an SVGSVGElement. Reads layout, never recomputes
// positions. Idempotent — calling twice with the same layout
// produces the same DOM.
//
// mount(host, opts): ChartHandle (Slice 1, next commit)
// End-to-end: fetches /api/projects/{id}/timeline, computes layout,
// paints, returns a handle with .refresh() / .dispose().
//
// Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §3.2 + §12.
// ---------------------------------------------------------------------------
// Public types
// ---------------------------------------------------------------------------
export type Density = "compact" | "standard" | "spacious";
export interface ChartViewport {
width: number;
height: number;
/** Reserved on the left for lane labels (and the undated zone). */
laneLabelWidth: number;
/** Reserved on top for the date axis. */
dateAxisHeight: number;
/** Today's date as ISO YYYY-MM-DD. Used to position the today rule. */
todayISO: string;
/** Inclusive ISO YYYY-MM-DD start of the chart's date range. */
rangeFrom: string;
/** Inclusive ISO YYYY-MM-DD end of the chart's date range. */
rangeTo: string;
density: Density;
}
export interface AxisTick {
x: number;
label: string;
kind: "year" | "quarter" | "month";
isYearBoundary: boolean;
}
export interface LaneRow {
id: string;
label: string;
y: number;
height: number;
}
export type MarkShape =
| "dot"
| "diamond"
| "hatched-dot"
| "dashed-dot";
export interface Mark {
/** Index into the original events array — paint() reuses this for tooltips + deep-links. */
eventIndex: number;
x: number;
y: number;
/** Radius for dot / hatched-dot / dashed-dot, half-diagonal for diamond. */
radius: number;
shape: MarkShape;
kind: TimelineEvent["kind"];
status: TimelineEvent["status"];
laneId: string;
undated: boolean;
}
export interface ChartLayout {
viewport: ChartViewport;
pxPerDay: number;
chartLeft: number;
chartTop: number;
chartWidth: number;
chartHeight: number;
axisTicks: AxisTick[];
laneRows: LaneRow[];
marks: Mark[];
/** Pixel x of the today rule, or null when today is outside the range. */
todayX: number | null;
undatedCount: number;
}
// ---------------------------------------------------------------------------
// Density tokens — single source of truth, used by layout() and CSS swap.
// ---------------------------------------------------------------------------
const LANE_HEIGHT: Record<Density, number> = {
compact: 24,
standard: 40,
spacious: 64,
};
const MARK_RADIUS: Record<Density, number> = {
compact: 5,
standard: 7,
spacious: 10,
};
// ---------------------------------------------------------------------------
// Date helpers — UTC throughout to avoid DST drift in day-math.
// ---------------------------------------------------------------------------
const DAY_MS = 86_400_000;
function parseISODay(iso: string): number | null {
// Accept "YYYY-MM-DD" and "YYYY-MM-DDTHH:MM:SSZ" (substrate marshals
// deadline.due_date as the UTC-midnight form — see format.ts).
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
if (!m) return null;
const y = Number(m[1]);
const mo = Number(m[2]);
const d = Number(m[3]);
if (
!Number.isFinite(y) || !Number.isFinite(mo) || !Number.isFinite(d) ||
mo < 1 || mo > 12 || d < 1 || d > 31
) {
return null;
}
return Date.UTC(y, mo - 1, d);
}
function dayDelta(fromMs: number, toMs: number): number {
return Math.round((toMs - fromMs) / DAY_MS);
}
// ---------------------------------------------------------------------------
// Mark shape resolution — single mapping table, mirrors §6.2 of the design.
// ---------------------------------------------------------------------------
function markShape(kind: TimelineEvent["kind"], status: TimelineEvent["status"]): MarkShape {
if (kind === "milestone") return "diamond";
if (kind === "projected") {
if (status === "court_set") return "dashed-dot";
return "hatched-dot"; // predicted, predicted_overdue, off_script
}
// deadline + appointment + everything else → plain dot. Status drives
// colour saturation (see CSS palette tokens), not shape.
return "dot";
}
// ---------------------------------------------------------------------------
// Axis tick generation — granularity by total span.
// ---------------------------------------------------------------------------
function generateTicks(
fromMs: number,
toMs: number,
chartLeft: number,
pxPerDay: number,
): AxisTick[] {
const totalDays = dayDelta(fromMs, toMs);
const ticks: AxisTick[] = [];
// Walk from the first day-of-month >= fromMs forward.
const start = new Date(fromMs);
const yStart = start.getUTCFullYear();
const mStart = start.getUTCMonth();
// Density rules:
// <90d → month ticks (every month-start)
// 90-730 → quarter ticks (Jan, Apr, Jul, Oct)
// >730 → year ticks (Jan only)
let kind: AxisTick["kind"];
let monthStep: number;
if (totalDays < 90) {
kind = "month";
monthStep = 1;
} else if (totalDays <= 730) {
kind = "quarter";
monthStep = 3;
} else {
kind = "year";
monthStep = 12;
}
// For quarter/year ticks, snap the starting month to the next aligned
// boundary so the labels are calendar-aligned (Jan/Apr/Jul/Oct, not
// Feb/May/Aug/Nov).
let mCursor = mStart;
let yCursor = yStart;
if (kind === "quarter") {
const offset = mCursor % 3;
if (offset !== 0) mCursor += 3 - offset;
} else if (kind === "year") {
if (mCursor !== 0) {
mCursor = 0;
yCursor += 1;
}
}
// If the first day of fromMs is not month-1, advance by one month so we
// don't double-print the partial month at the very start.
if (kind === "month" && start.getUTCDate() !== 1) {
mCursor += 1;
}
while (mCursor >= 12) {
mCursor -= 12;
yCursor += 1;
}
// Emit ticks until past toMs.
while (true) {
const tickMs = Date.UTC(yCursor, mCursor, 1);
if (tickMs > toMs) break;
const days = dayDelta(fromMs, tickMs);
const x = chartLeft + days * pxPerDay;
const label = formatTickLabel(yCursor, mCursor, kind);
ticks.push({
x,
label,
kind,
isYearBoundary: mCursor === 0,
});
mCursor += monthStep;
while (mCursor >= 12) {
mCursor -= 12;
yCursor += 1;
}
}
return ticks;
}
const MONTH_DE = [
"Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez",
];
function formatTickLabel(year: number, month: number, kind: AxisTick["kind"]): string {
if (kind === "year") return String(year);
if (kind === "quarter") {
const q = Math.floor(month / 3) + 1;
return `Q${q} ${year}`;
}
return MONTH_DE[month];
}
// ---------------------------------------------------------------------------
// Public: layout
// ---------------------------------------------------------------------------
export function layout(
events: ReadonlyArray<TimelineEvent>,
lanes: ReadonlyArray<LaneInfo>,
viewport: ChartViewport,
): ChartLayout {
// -- Canvas geometry --------------------------------------------------
const chartLeft = viewport.laneLabelWidth;
const chartTop = viewport.dateAxisHeight;
const chartWidth = Math.max(0, viewport.width - chartLeft);
// chartHeight is derived from the number of lane rows so the SVG grows
// / shrinks vertically with the data, not the supplied viewport.height
// (which the caller uses as a hint — actual height comes back in
// viewport.height after the paint pass).
const laneCount = Math.max(1, lanes.length);
const laneHeight = LANE_HEIGHT[viewport.density];
const chartHeight = laneCount * laneHeight;
// -- Date math --------------------------------------------------------
const fromMs = parseISODay(viewport.rangeFrom);
const toMsRaw = parseISODay(viewport.rangeTo);
if (fromMs === null || toMsRaw === null) {
// Degenerate input — return an empty layout rather than NaN-paint.
return {
viewport,
pxPerDay: 0,
chartLeft,
chartTop,
chartWidth,
chartHeight,
axisTicks: [],
laneRows: synthLaneRows(lanes, chartTop, laneHeight),
marks: [],
todayX: null,
undatedCount: 0,
};
}
// Guard against to < from. Clamp the inverted case to a 1-day span so
// pxPerDay stays positive and finite.
const toMs = toMsRaw <= fromMs ? fromMs + DAY_MS : toMsRaw;
const totalDays = Math.max(1, dayDelta(fromMs, toMs));
const pxPerDay = chartWidth / totalDays;
// -- Today rule -------------------------------------------------------
const todayMs = parseISODay(viewport.todayISO);
let todayX: number | null = null;
if (todayMs !== null && todayMs >= fromMs && todayMs <= toMs) {
todayX = chartLeft + dayDelta(fromMs, todayMs) * pxPerDay;
}
// -- Lane rows --------------------------------------------------------
const laneRows = synthLaneRows(lanes, chartTop, laneHeight);
const laneIndex = new Map<string, LaneRow>();
for (const row of laneRows) laneIndex.set(row.id, row);
const fallbackLane = laneRows[0];
// -- Marks ------------------------------------------------------------
const marks: Mark[] = [];
let undatedCount = 0;
const radius = MARK_RADIUS[viewport.density];
for (let i = 0; i < events.length; i++) {
const event = events[i];
const laneRow = (event.lane_id && laneIndex.get(event.lane_id)) || fallbackLane;
if (!event.date) {
// Undated rows live in a gutter to the left of the chart canvas.
// We pile them up vertically inside the lane label area so they
// remain hover-/click-targets, but they don't compete with the
// date-axis-positioned marks for screen space.
undatedCount++;
const undatedX = chartLeft - viewport.laneLabelWidth * 0.25;
marks.push({
eventIndex: i,
x: undatedX,
y: laneRow.y + laneRow.height / 2,
radius,
shape: markShape(event.kind, event.status),
kind: event.kind,
status: event.status,
laneId: laneRow.id,
undated: true,
});
continue;
}
const ms = parseISODay(event.date);
if (ms === null) continue; // unparseable date, drop defensively
if (ms < fromMs || ms > toMs) continue; // outside range — clipped
const x = chartLeft + dayDelta(fromMs, ms) * pxPerDay;
const y = laneRow.y + laneRow.height / 2;
marks.push({
eventIndex: i,
x,
y,
radius,
shape: markShape(event.kind, event.status),
kind: event.kind,
status: event.status,
laneId: laneRow.id,
undated: false,
});
}
// -- Axis ticks -------------------------------------------------------
const axisTicks = generateTicks(fromMs, toMs, chartLeft, pxPerDay);
return {
viewport,
pxPerDay,
chartLeft,
chartTop,
chartWidth,
chartHeight,
axisTicks,
laneRows,
marks,
todayX,
undatedCount,
};
}
function synthLaneRows(
lanes: ReadonlyArray<LaneInfo>,
chartTop: number,
laneHeight: number,
): LaneRow[] {
if (lanes.length === 0) {
return [{ id: "self", label: "", y: chartTop, height: laneHeight }];
}
return lanes.map((lane, idx) => ({
id: lane.id,
label: lane.label,
y: chartTop + idx * laneHeight,
height: laneHeight,
}));
}
// ---------------------------------------------------------------------------
// Public: paint
// ---------------------------------------------------------------------------
const SVG_NS = "http://www.w3.org/2000/svg";
function svg(name: string, attrs: Record<string, string | number> = {}): SVGElement {
const el = document.createElementNS(SVG_NS, name);
for (const [k, v] of Object.entries(attrs)) {
el.setAttribute(k, String(v));
}
return el;
}
/**
* paint mutates an existing SVGSVGElement to reflect a ChartLayout.
* Idempotent: clears prior children before painting, so calling twice
* with the same layout produces the same DOM.
*
* Events are *not* wired here — mount() attaches the delegated listeners
* after paint() returns. paint() stays pure-render so it stays cheap to
* call from a resize / palette swap.
*/
export function paint(
chart: ChartLayout,
root: SVGSVGElement,
events: ReadonlyArray<TimelineEvent>,
): void {
// Clear prior contents.
while (root.firstChild) root.removeChild(root.firstChild);
const totalHeight = chart.chartTop + chart.chartHeight + 24; // 24px bottom pad for axis labels
root.setAttribute("viewBox", `0 0 ${chart.viewport.width} ${totalHeight}`);
root.setAttribute("preserveAspectRatio", "xMinYMin meet");
root.setAttribute("role", "img");
root.setAttribute("aria-label", "Project Timeline / Chart");
// <defs> — hatched pattern for projected marks.
const defs = svg("defs");
const pattern = svg("pattern", {
id: "chart-hatch",
patternUnits: "userSpaceOnUse",
width: 4,
height: 4,
});
pattern.appendChild(svg("path", {
d: "M0,4 L4,0",
stroke: "currentColor",
"stroke-width": 1,
fill: "none",
}));
defs.appendChild(pattern);
root.appendChild(defs);
// Layer order: grid → lane separators → today rule → marks → labels.
const gGrid = svg("g", { class: "chart-grid" });
root.appendChild(gGrid);
// Date axis ticks — vertical guidelines + labels at top.
for (const tick of chart.axisTicks) {
gGrid.appendChild(svg("line", {
class: tick.isYearBoundary
? "chart-tick chart-tick--year"
: "chart-tick",
x1: tick.x,
y1: chart.chartTop,
x2: tick.x,
y2: chart.chartTop + chart.chartHeight,
}));
const label = svg("text", {
class: "chart-tick-label",
x: tick.x + 4,
y: chart.chartTop - 8,
});
label.textContent = tick.label;
gGrid.appendChild(label);
}
// Lane separators — horizontal lines between rows + labels in the gutter.
// Labels live inside <foreignObject> so HTML/CSS handles ellipsis +
// tooltip cleanly. SVG <text> has no auto-clipping and long titles
// would bleed into the chart canvas (t-paliad-211).
const labelPadding = 8;
const labelMaxWidth = Math.max(0, chart.viewport.laneLabelWidth - labelPadding * 2);
for (let i = 0; i < chart.laneRows.length; i++) {
const row = chart.laneRows[i];
if (i > 0) {
gGrid.appendChild(svg("line", {
class: "chart-lane-separator",
x1: 0,
y1: row.y,
x2: chart.viewport.width,
y2: row.y,
}));
}
if (row.label) {
const fo = svg("foreignObject", {
class: "chart-lane-label-fo",
x: labelPadding,
y: row.y,
width: labelMaxWidth,
height: row.height,
});
const div = document.createElement("div");
div.className = "chart-lane-label";
div.textContent = row.label;
div.title = row.label;
fo.appendChild(div);
gGrid.appendChild(fo);
}
}
// Today rule — vertical lime line + "Heute" label.
if (chart.todayX !== null) {
gGrid.appendChild(svg("line", {
class: "chart-today-rule",
x1: chart.todayX,
y1: chart.chartTop - 4,
x2: chart.todayX,
y2: chart.chartTop + chart.chartHeight + 4,
}));
const todayLabel = svg("text", {
class: "chart-today-label",
x: chart.todayX + 4,
y: chart.chartTop + chart.chartHeight + 18,
});
todayLabel.textContent = "Heute";
gGrid.appendChild(todayLabel);
}
// Marks.
const gMarks = svg("g", { class: "chart-marks" });
root.appendChild(gMarks);
for (const mark of chart.marks) {
const event = events[mark.eventIndex];
const markEl = paintMark(mark, event);
gMarks.appendChild(markEl);
}
}
function paintMark(mark: Mark, event: TimelineEvent): SVGElement {
// Wrap every mark in a <g> with data-* attributes so mount() can do
// event-delegation off the top-level <svg> without per-mark listeners.
const g = svg("g", {
class: markClassName(mark),
"data-event-index": mark.eventIndex,
"data-kind": mark.kind,
"data-status": mark.status,
"data-lane": mark.laneId,
"data-undated": mark.undated ? "1" : "0",
"data-deadline-id": event.deadline_id || "",
"data-appointment-id": event.appointment_id || "",
"data-project-event-id": event.project_event_id || "",
role: "img",
tabindex: 0,
});
// ARIA label so screen-readers can read each mark (§13).
const title = svg("title");
title.textContent = markAriaLabel(mark, event);
g.appendChild(title);
// Generous invisible hit-target so dots are easy to click without
// hunting (12px hit halo around a 7px standard radius).
g.appendChild(svg("circle", {
class: "chart-mark-hit",
cx: mark.x,
cy: mark.y,
r: mark.radius + 6,
fill: "transparent",
}));
switch (mark.shape) {
case "dot": {
const c = svg("circle", {
class: "chart-mark-dot",
cx: mark.x,
cy: mark.y,
r: mark.radius,
});
g.appendChild(c);
break;
}
case "diamond": {
const r = mark.radius;
g.appendChild(svg("polygon", {
class: "chart-mark-diamond",
points: `${mark.x},${mark.y - r} ${mark.x + r},${mark.y} ${mark.x},${mark.y + r} ${mark.x - r},${mark.y}`,
}));
break;
}
case "hatched-dot": {
g.appendChild(svg("circle", {
class: "chart-mark-hatched",
cx: mark.x,
cy: mark.y,
r: mark.radius,
fill: "url(#chart-hatch)",
}));
break;
}
case "dashed-dot": {
g.appendChild(svg("circle", {
class: "chart-mark-dashed",
cx: mark.x,
cy: mark.y,
r: mark.radius,
}));
break;
}
}
return g;
}
function markClassName(mark: Mark): string {
const parts = ["chart-mark", `chart-mark--${mark.kind}`, `chart-mark--status-${mark.status}`];
if (mark.undated) parts.push("chart-mark--undated");
return parts.join(" ");
}
function markAriaLabel(mark: Mark, event: TimelineEvent): string {
const dateStr = event.date ? event.date.slice(0, 10) : "Datum offen";
return `${event.title}${event.kind} (${event.status}) — ${dateStr}`;
}
// ---------------------------------------------------------------------------
// Public: mount
// ---------------------------------------------------------------------------
/** Palette presets from design §5.1. Each is a CSS-var override hung off
* `.smart-timeline-chart[data-palette="<name>"]`; the renderer never
* reads palette state directly. */
export type Palette =
| "default"
| "kind-coded"
| "track-coded"
| "high-contrast"
| "print";
export const ALL_PALETTES: ReadonlyArray<Palette> = [
"default",
"kind-coded",
"track-coded",
"high-contrast",
"print",
];
export const ALL_DENSITIES: ReadonlyArray<Density> = [
"compact",
"standard",
"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;
/** 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
* deep-link target. Receives the underlying TimelineEvent. */
onMarkClick?: (event: TimelineEvent) => void;
/** Optional callback fired after every refresh() so the host can
* re-render dynamic UI (e.g. lane filter chips). */
onDataLoaded?: (data: { events: TimelineEvent[]; lanes: LaneInfo[] }) => void;
/** Initial visible-lane allowlist. null = show all (default).
* Lane ids not present in the response are silently dropped. */
visibleLanes?: string[] | null;
/** Pre-loaded data — used by Custom Views (Slice 4) where the rows
* come from ViewService not /api/projects/{id}/timeline. When set,
* mount() skips the initial fetch and paints from this data; the
* handle's refresh() still hits the project endpoint (caller can
* swap the chart back to project-mode via the standalone /chart URL). */
staticData?: { events: TimelineEvent[]; lanes: LaneInfo[] };
}
export interface ChartHandle {
/** Re-fetches the timeline and re-paints. */
refresh: () => Promise<void>;
/** Removes event listeners + tears down the SVG. */
dispose: () => void;
/** Returns the last computed layout (useful for tests / debugging). */
getLayout: () => ChartLayout | null;
/** Swap palette via data-palette attribute. Pure CSS-var swap — no repaint. */
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;
/** Set the lane allowlist. null = show all lanes (default). Unknown
* ids in the passed array are silently dropped on repaint. */
setVisibleLanes: (lanes: string[] | null) => 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. */
getData: () => { events: TimelineEvent[]; lanes: LaneInfo[] };
}
interface TimelineEnvelope {
events: TimelineEvent[];
lanes: LaneInfo[];
}
/**
* mount builds a chart inside the given host element. The host's
* dimensions drive the SVG width; height grows from the lane row count.
* Returns a handle for refresh / dispose.
*/
export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle {
host.classList.add("smart-timeline-chart-host");
// Empty / error placeholders.
const messageEl = document.createElement("div");
messageEl.className = "smart-timeline-chart-message";
messageEl.textContent = "";
host.appendChild(messageEl);
// The SVG root we paint into.
const svgEl = document.createElementNS(SVG_NS, "svg") as SVGSVGElement;
svgEl.classList.add("smart-timeline-chart");
svgEl.setAttribute("data-palette", opts.palette ?? "default");
svgEl.setAttribute("data-density", opts.density ?? "standard");
host.appendChild(svgEl);
let lastEvents: TimelineEvent[] = [];
let lastLayout: ChartLayout | null = null;
const todayISO = opts.todayISO ?? today();
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);
let currentVisibleLanes: Set<string> | null = opts.visibleLanes
? new Set(opts.visibleLanes)
: null;
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: from,
rangeTo: to,
density: currentDensity,
};
// Lane allowlist filter. null = show all; otherwise drop both the
// lane rows AND the events whose lane_id sits outside the allowlist.
// (We don't fall back to "first lane" here — that's only sensible
// when a stale id slips through; an explicit hide is a hide.)
let renderLanes = [...currentLanes];
let renderEvents: TimelineEvent[] = lastEvents;
if (currentVisibleLanes !== null) {
const allow = currentVisibleLanes;
renderLanes = currentLanes.filter((l) => allow.has(l.id));
renderEvents = lastEvents.filter((e) => {
// Empty / missing lane_id is treated as "self" — included only
// when the synthetic "self" lane is allowed.
const id = e.lane_id || "self";
return allow.has(id);
});
}
const chart = layout(renderEvents, renderLanes, viewport);
lastLayout = chart;
paint(chart, svgEl, renderEvents);
svgEl.setAttribute("width", String(width));
svgEl.setAttribute("height", String(chart.chartTop + chart.chartHeight + 32));
}
let currentLanes: LaneInfo[] = [];
async function refresh(): Promise<void> {
messageEl.textContent = "Lädt …";
messageEl.classList.remove("smart-timeline-chart-message--error");
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(opts.projectId)}/timeline`,
);
if (!resp.ok) {
messageEl.textContent = "Timeline konnte nicht geladen werden.";
messageEl.classList.add("smart-timeline-chart-message--error");
return;
}
const body = await resp.json();
// Defensive: tolerate the legacy []TimelineEvent shape (pre-Slice-4)
// even though the Slice-4 envelope is the contract today.
if (Array.isArray(body)) {
lastEvents = body as TimelineEvent[];
currentLanes = [];
} else {
const env = body as TimelineEnvelope;
lastEvents = env.events ?? [];
currentLanes = env.lanes ?? [];
}
if (lastEvents.length === 0) {
messageEl.textContent = "Keine Ereignisse im gewählten Zeitraum.";
} else {
messageEl.textContent = "";
}
// Drop stale lane ids from the allowlist — a deleted CCR / child
// case shouldn't keep its lane id alive across re-fetches.
if (currentVisibleLanes !== null) {
const valid = new Set(currentLanes.map((l) => l.id));
valid.add("self"); // synthetic lane always allowed
const trimmed = new Set<string>();
for (const id of currentVisibleLanes) {
if (valid.has(id)) trimmed.add(id);
}
currentVisibleLanes = trimmed.size === 0 ? null : trimmed;
}
repaint();
if (opts.onDataLoaded) {
opts.onDataLoaded({ events: lastEvents, lanes: currentLanes });
}
} catch (err) {
messageEl.textContent = "Netzwerkfehler beim Laden der Timeline.";
messageEl.classList.add("smart-timeline-chart-message--error");
}
}
// Click delegation — read data-* attrs to deep-link.
function handleClick(e: Event) {
const target = e.target as Element | null;
if (!target) return;
const g = target.closest("g.chart-mark") as Element | null;
if (!g) return;
const indexAttr = g.getAttribute("data-event-index");
if (!indexAttr) return;
const idx = Number(indexAttr);
const event = lastEvents[idx];
if (!event) return;
if (opts.onMarkClick) {
opts.onMarkClick(event);
return;
}
if (event.deadline_id) {
window.location.href = `/deadlines/${encodeURIComponent(event.deadline_id)}`;
} else if (event.appointment_id) {
window.location.href = `/appointments/${encodeURIComponent(event.appointment_id)}`;
}
// Milestones + projected rows have no detail page today — no-op.
}
// Resize handler — debounced.
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
function handleResize() {
if (resizeTimer) clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
repaint();
}, 120);
}
svgEl.addEventListener("click", handleClick);
window.addEventListener("resize", handleResize);
// If the caller supplied data up front (Custom Views host path), skip
// the project-timeline fetch entirely — paint from the supplied rows.
// Otherwise kick off the initial /api/projects/{id}/timeline load.
if (opts.staticData) {
lastEvents = opts.staticData.events;
currentLanes = opts.staticData.lanes;
if (lastEvents.length === 0) {
messageEl.textContent = "Keine Ereignisse im gewählten Zeitraum.";
} else {
messageEl.textContent = "";
}
repaint();
if (opts.onDataLoaded) {
opts.onDataLoaded({ events: lastEvents, lanes: currentLanes });
}
} else {
void refresh();
}
return {
refresh,
getLayout: () => lastLayout,
setPalette: (palette: Palette) => {
svgEl.setAttribute("data-palette", palette);
},
setDensity: (density: Density) => {
currentDensity = density;
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();
},
setVisibleLanes: (lanes: string[] | null) => {
currentVisibleLanes = lanes ? new Set(lanes) : null;
repaint();
},
getSVGElement: () => svgEl,
getData: () => ({ events: lastEvents, lanes: currentLanes }),
dispose: () => {
svgEl.removeEventListener("click", handleClick);
window.removeEventListener("resize", handleResize);
if (resizeTimer) clearTimeout(resizeTimer);
if (svgEl.parentNode) svgEl.parentNode.removeChild(svgEl);
if (messageEl.parentNode) messageEl.parentNode.removeChild(messageEl);
},
};
}
/** 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();
const m = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${dd}`;
}
function shiftYears(iso: string, delta: number): string {
const ms = parseISODay(iso);
if (ms === null) return iso;
const d = new Date(ms);
return `${d.getUTCFullYear() + delta}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
}