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).
255 lines
9.5 KiB
TypeScript
255 lines
9.5 KiB
TypeScript
import {
|
||
mount,
|
||
type ChartHandle,
|
||
type Density,
|
||
type Palette,
|
||
type RangePreset,
|
||
} from "./shape-timeline-chart";
|
||
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
|
||
import type { RenderSpec, ViewRow } from "./types";
|
||
import { t } from "../i18n";
|
||
|
||
// shape-timeline-cv (t-paliad-177 Slice 4, faraday-Q7) — Custom Views
|
||
// host for the chart renderer.
|
||
//
|
||
// Adapter contract: ViewRow → TimelineEvent + LaneInfo.
|
||
// - deadline + appointment + project_event rows render as actual marks.
|
||
// - approval_request rows are skipped (no chart-meaningful date).
|
||
// - Lane axis = project_id; the cross-project chart use case (design
|
||
// §10) groups events by their owning project. Rows without a
|
||
// project_id collapse into a synthetic "self" lane.
|
||
// - NO projected rows. ViewService doesn't run the fristenrechner
|
||
// calculator, so the CV chart shows actuals only. The host page
|
||
// ships a one-time caveat tooltip (see C3) explaining this.
|
||
//
|
||
// Design ref: docs/design-project-chart-2026-05-09.md §8.3 + §11.5 + §13.4.
|
||
|
||
// Zoom levels in ascending span (t-paliad-211). Width-only — the chart's
|
||
// existing range presets already provide three meaningful zoom levels.
|
||
// Stored in URL as ?tl_zoom=1y|2y|all.
|
||
const ZOOM_LEVELS: RangePreset[] = ["1y", "2y", "all"];
|
||
const ZOOM_PARAM = "tl_zoom";
|
||
|
||
export function renderTimelineShape(
|
||
host: HTMLElement,
|
||
rows: ReadonlyArray<ViewRow>,
|
||
render: RenderSpec,
|
||
): ChartHandle {
|
||
// Tear down any previous mount so re-rendering the shape (e.g. shape
|
||
// chip switch on /views/{slug}) doesn't stack SVGs.
|
||
host.innerHTML = "";
|
||
|
||
const { events, lanes } = adapt(rows);
|
||
const cfg = render.timeline ?? {};
|
||
|
||
// Resolve the initial zoom: URL > render spec > "1y" default.
|
||
const initialZoom = resolveInitialZoom(cfg.range_preset);
|
||
|
||
// Toolbar lives above the chart in its own row so it doesn't compete
|
||
// with the date-axis / lane labels for space.
|
||
const toolbar = document.createElement("div");
|
||
toolbar.className = "views-timeline-toolbar";
|
||
host.appendChild(toolbar);
|
||
|
||
const chartHost = document.createElement("div");
|
||
chartHost.className = "views-timeline-chart-host-inner";
|
||
host.appendChild(chartHost);
|
||
|
||
// The CV adapter has no per-project "id" to fetch live timeline data
|
||
// for — we hand mount() a placeholder projectId and the staticData
|
||
// pre-loaded array so it skips the project endpoint entirely. If the
|
||
// user clicks a mark, the renderer's default click handler still
|
||
// resolves /deadlines/{id} / /appointments/{id} from the adapted
|
||
// event's id field, so deep-links land on the correct entity page.
|
||
const handle = mount(chartHost, {
|
||
projectId: "cv",
|
||
staticData: { events, lanes },
|
||
palette: (cfg.palette as Palette | undefined) ?? "default",
|
||
density: (cfg.density as Density | undefined) ?? "standard",
|
||
rangePreset: initialZoom,
|
||
rangeFrom: cfg.range_from,
|
||
rangeTo: cfg.range_to,
|
||
});
|
||
|
||
let currentZoom = initialZoom;
|
||
const setZoom = (next: RangePreset) => {
|
||
if (next === currentZoom) return;
|
||
currentZoom = next;
|
||
handle.setRange(next);
|
||
writeZoomURL(next);
|
||
paintToolbar();
|
||
};
|
||
|
||
const paintToolbar = () => {
|
||
toolbar.innerHTML = "";
|
||
|
||
const zoomGroup = document.createElement("div");
|
||
zoomGroup.className = "views-timeline-zoom-group";
|
||
|
||
const zoomLabel = document.createElement("span");
|
||
zoomLabel.className = "views-timeline-zoom-label";
|
||
zoomLabel.textContent = t("views.timeline.zoom.label");
|
||
zoomGroup.appendChild(zoomLabel);
|
||
|
||
const zoomOut = document.createElement("button");
|
||
zoomOut.type = "button";
|
||
zoomOut.className = "btn-secondary btn-small views-timeline-zoom-btn";
|
||
zoomOut.setAttribute("aria-label", t("views.timeline.zoom.out"));
|
||
zoomOut.title = t("views.timeline.zoom.out");
|
||
zoomOut.textContent = "−";
|
||
zoomOut.disabled = currentZoom === ZOOM_LEVELS[ZOOM_LEVELS.length - 1];
|
||
zoomOut.addEventListener("click", () => {
|
||
const idx = ZOOM_LEVELS.indexOf(currentZoom);
|
||
if (idx < ZOOM_LEVELS.length - 1) setZoom(ZOOM_LEVELS[idx + 1]);
|
||
});
|
||
zoomGroup.appendChild(zoomOut);
|
||
|
||
// Active-level chips (1y / 2y / all). Clicking jumps directly.
|
||
const chips = document.createElement("div");
|
||
chips.className = "views-timeline-zoom-chips agenda-chip-row";
|
||
for (const level of ZOOM_LEVELS) {
|
||
const chip = document.createElement("button");
|
||
chip.type = "button";
|
||
chip.className = "agenda-chip views-timeline-zoom-chip"
|
||
+ (level === currentZoom ? " agenda-chip-active" : "");
|
||
chip.dataset.zoom = level;
|
||
chip.textContent = t(zoomLevelKey(level));
|
||
chip.addEventListener("click", () => setZoom(level));
|
||
chips.appendChild(chip);
|
||
}
|
||
zoomGroup.appendChild(chips);
|
||
|
||
const zoomIn = document.createElement("button");
|
||
zoomIn.type = "button";
|
||
zoomIn.className = "btn-secondary btn-small views-timeline-zoom-btn";
|
||
zoomIn.setAttribute("aria-label", t("views.timeline.zoom.in"));
|
||
zoomIn.title = t("views.timeline.zoom.in");
|
||
zoomIn.textContent = "+";
|
||
zoomIn.disabled = currentZoom === ZOOM_LEVELS[0];
|
||
zoomIn.addEventListener("click", () => {
|
||
const idx = ZOOM_LEVELS.indexOf(currentZoom);
|
||
if (idx > 0) setZoom(ZOOM_LEVELS[idx - 1]);
|
||
});
|
||
zoomGroup.appendChild(zoomIn);
|
||
|
||
toolbar.appendChild(zoomGroup);
|
||
};
|
||
|
||
paintToolbar();
|
||
|
||
// Apply the URL zoom if it differed from the spec — mount() already
|
||
// used initialZoom so this is a no-op when URL was empty. But when URL
|
||
// disagreed with the spec, mount() honoured the URL and the toolbar
|
||
// already reflects that, so nothing extra to do here.
|
||
|
||
return handle;
|
||
}
|
||
|
||
function zoomLevelKey(level: RangePreset): "views.timeline.zoom.1y" | "views.timeline.zoom.2y" | "views.timeline.zoom.all" {
|
||
if (level === "1y") return "views.timeline.zoom.1y";
|
||
if (level === "2y") return "views.timeline.zoom.2y";
|
||
return "views.timeline.zoom.all";
|
||
}
|
||
|
||
function resolveInitialZoom(spec: string | undefined): RangePreset {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const raw = params.get(ZOOM_PARAM);
|
||
if (raw && (ZOOM_LEVELS as string[]).includes(raw)) return raw as RangePreset;
|
||
if (spec && (ZOOM_LEVELS as string[]).includes(spec)) return spec as RangePreset;
|
||
return "1y";
|
||
}
|
||
|
||
function writeZoomURL(zoom: RangePreset): void {
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.set(ZOOM_PARAM, zoom);
|
||
history.replaceState(null, "", url.toString());
|
||
}
|
||
|
||
export interface AdapterResult {
|
||
events: TimelineEvent[];
|
||
lanes: LaneInfo[];
|
||
}
|
||
|
||
/** Exported for tests (shape-timeline-cv.test.ts). Pure — no DOM. */
|
||
export function adapt(rows: ReadonlyArray<ViewRow>): AdapterResult {
|
||
const events: TimelineEvent[] = [];
|
||
// Lane order = first-seen order of project_ids in rows, so the user
|
||
// sees lanes in the order their data was returned (typically date-
|
||
// sorted). Deterministic, no surprise re-ordering on re-renders.
|
||
const laneIndex = new Map<string, LaneInfo>();
|
||
|
||
for (const row of rows) {
|
||
if (row.kind === "approval_request") {
|
||
// Approval requests have no event_date in the chart sense; they
|
||
// represent pending decisions, not scheduled work. Skip.
|
||
continue;
|
||
}
|
||
const laneId = row.project_id || "self";
|
||
if (!laneIndex.has(laneId)) {
|
||
laneIndex.set(laneId, {
|
||
id: laneId,
|
||
label: row.project_title || row.project_reference || laneLabelFallback(laneId),
|
||
project_id: row.project_id,
|
||
});
|
||
}
|
||
|
||
const event: TimelineEvent = {
|
||
kind: toTimelineKind(row.kind),
|
||
status: extractStatus(row),
|
||
track: laneId === "self" ? "parent" : "child:" + laneId,
|
||
date: row.event_date || null,
|
||
title: row.title,
|
||
description: row.subtitle,
|
||
lane_id: laneId,
|
||
};
|
||
// Set the right provenance id so the renderer's click handler can
|
||
// deep-link to /deadlines/{id} / /appointments/{id}.
|
||
switch (row.kind) {
|
||
case "deadline":
|
||
event.deadline_id = row.id;
|
||
break;
|
||
case "appointment":
|
||
event.appointment_id = row.id;
|
||
break;
|
||
case "project_event":
|
||
event.project_event_id = row.id;
|
||
break;
|
||
}
|
||
events.push(event);
|
||
}
|
||
|
||
return { events, lanes: [...laneIndex.values()] };
|
||
}
|
||
|
||
function toTimelineKind(kind: ViewRow["kind"]): TimelineEvent["kind"] {
|
||
// ViewRow "project_event" maps to chart "milestone" — they're the
|
||
// same underlying paliad.project_events row, the chart just uses a
|
||
// different name because milestones are the chart-meaningful subset.
|
||
if (kind === "project_event") return "milestone";
|
||
// Defensive: approval_request was filtered earlier, but TS doesn't
|
||
// know that. Default to "milestone" for any unexpected kind.
|
||
if (kind === "deadline" || kind === "appointment") return kind;
|
||
return "milestone";
|
||
}
|
||
|
||
/** Status defaults to "open" — ViewRow doesn't carry chart-status
|
||
* semantics directly, and the underlying detail json shape varies per
|
||
* kind. The chart's color saturation maps status → fill / ring style,
|
||
* so "open" gives every mark a sensible default (filled, full color).
|
||
* Detail-driven status lookup is a polish job for a future slice. */
|
||
function extractStatus(row: ViewRow): TimelineEvent["status"] {
|
||
if (row.kind === "deadline") {
|
||
const d = row.detail as { status?: string };
|
||
if (d.status === "done" || d.status === "overdue") {
|
||
return d.status as TimelineEvent["status"];
|
||
}
|
||
}
|
||
return "open";
|
||
}
|
||
|
||
function laneLabelFallback(id: string): string {
|
||
if (id === "self") return "(ohne Projekt)";
|
||
// Truncated UUID is more useful than a bare 36-char string.
|
||
return id.slice(0, 8);
|
||
}
|