Files
paliad/frontend/src/client/projects-chart.ts
mAi e9bcf3a7b6 feat(t-paliad-177): chart reciprocal "Zurück zum Verlauf" link
Slice 3 step 5 (optional). The back-link on the chart page now points
explicitly at /projects/{id}/history (Verlauf sub-path) instead of
the bare /projects/{id}. Today's projects-detail.ts treats both the
same — bare and /history land on the Verlauf tab — but /history is
the explicit form, so the link keeps working if Verlauf ever stops
being the default tab.

Label flips from "Zurück zum Projekt" → "Zurück zum Verlauf" so
users see exactly where they're heading. Pairs naturally with the
Slice 1 "Als Chart anzeigen ↗" affordance: the trip is round.

Design ref: docs/design-project-chart-2026-05-09.md §8.1.
2026-05-13 11:53:46 +02:00

490 lines
18 KiB
TypeScript

import { initI18n, t } from "./i18n";
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,
exportJSON,
exportPNG,
exportPrint,
exportSVG,
type ExportContext,
} from "./views/chart-export";
// t-paliad-177 Slice 1 — boot client for the standalone Project Timeline
// / Chart page. Reads the project id from the URL path, loads the
// project metadata (for title + breadcrumb), mounts the SVG renderer
// inside #projects-chart-host. Slice 1 keeps the controls inert; Slice 3
// wires density / palette / zoom against this same surface.
//
// Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
interface Project {
id: string;
title: string;
reference?: string;
client_matter?: string;
type?: string;
}
const PROJECT_ID_RE = /^\/projects\/([0-9a-fA-F-]{36})\/chart\/?$/;
function projectIdFromPath(): string | null {
const match = PROJECT_ID_RE.exec(window.location.pathname);
return match ? match[1] : null;
}
const PALETTE_SET: ReadonlySet<string> = new Set(ALL_PALETTES);
/** Reads ?palette=... from the URL; returns the default when missing /
* unknown so a hostile or stale URL can't break the chart. */
function paletteFromURL(): Palette {
const raw = new URLSearchParams(window.location.search).get("palette");
if (raw && PALETTE_SET.has(raw)) return raw as Palette;
return "default";
}
/** Mirrors paletteFromURL but for writing — pushes a new history entry
* so the URL stays bookmarkable / shareable per design §8.2. */
function writePaletteToURL(palette: Palette): void {
writeParamToURL("palette", palette, "default");
}
const DENSITY_SET: ReadonlySet<string> = new Set(ALL_DENSITIES);
function densityFromURL(): Density {
const raw = new URLSearchParams(window.location.search).get("density");
if (raw && DENSITY_SET.has(raw)) return raw as Density;
return "standard";
}
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);
}
/** Read ?lanes=id1,id2 from the URL. Empty / missing → null (show all).
* Defence: ids that look hostile (commas embedded, oversized) are dropped
* on render via the renderer's allow-set intersection. */
function lanesFromURL(): string[] | null {
const raw = new URLSearchParams(window.location.search).get("lanes");
if (!raw) return null;
const ids = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0 && s.length < 200);
return ids.length === 0 ? null : ids;
}
function writeLanesToURL(lanes: string[] | null): void {
const params = new URLSearchParams(window.location.search);
if (!lanes || lanes.length === 0) {
params.delete("lanes");
} else {
params.set("lanes", lanes.join(","));
}
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 {
const params = new URLSearchParams(window.location.search);
if (value === defaultValue) {
params.delete(name);
} else {
params.set(name, value);
}
const qs = params.toString();
const next = window.location.pathname + (qs ? "?" + qs : "");
window.history.replaceState(null, "", next);
}
async function loadProject(id: string): Promise<Project | null> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`);
if (!resp.ok) return null;
return (await resp.json()) as Project;
} catch {
return null;
}
}
function formatMeta(p: Project): string {
const parts: string[] = [];
if (p.reference) parts.push(p.reference);
if (p.client_matter) parts.push(p.client_matter);
return parts.join(" • ");
}
async function boot(): Promise<void> {
initI18n();
initSidebar();
const loadingEl = document.getElementById("projects-chart-loading");
const notfoundEl = document.getElementById("projects-chart-notfound");
const bodyEl = document.getElementById("projects-chart-body");
const titleEl = document.getElementById("projects-chart-title");
const metaEl = document.getElementById("projects-chart-meta");
const backLink = document.getElementById("projects-chart-back-link") as HTMLAnchorElement | null;
const host = document.getElementById("projects-chart-host");
const undatedHint = document.getElementById("projects-chart-undated");
const id = projectIdFromPath();
if (!id || !host || !bodyEl || !loadingEl || !notfoundEl) {
if (loadingEl) loadingEl.style.display = "none";
if (notfoundEl) notfoundEl.style.display = "block";
return;
}
const project = await loadProject(id);
if (!project) {
loadingEl.style.display = "none";
notfoundEl.style.display = "block";
return;
}
// Wire back-link to the Verlauf tab specifically — projects-detail.ts
// reads the /history sub-path on init and switches to that tab. Going
// back to the bare /projects/{id} also lands on Verlauf today, but the
// /history form is explicit + survives a future default-tab change.
if (backLink) backLink.href = `/projects/${encodeURIComponent(id)}/history`;
if (titleEl) titleEl.textContent = project.title || t("projects.chart.title");
if (metaEl) metaEl.textContent = formatMeta(project);
loadingEl.style.display = "none";
bodyEl.style.display = "";
const initialPalette = paletteFromURL();
const initialDensity = densityFromURL();
const initialRange = rangeFromURL();
const initialLanes = lanesFromURL();
let handle: ChartHandle | null = null;
// Module-scope mirrors so the chip click handlers (rendered later)
// can reach the live state without threading it through callbacks.
moduleVisibleLanes = initialLanes;
try {
handle = mount(host, {
projectId: id,
palette: initialPalette,
density: initialDensity,
rangePreset: initialRange.preset,
rangeFrom: initialRange.from,
rangeTo: initialRange.to,
visibleLanes: initialLanes,
onDataLoaded: ({ lanes }) => {
renderLaneFilter(lanes);
},
});
} catch (err) {
console.error("chart mount failed", err);
host.textContent = t("projects.chart.error.mount");
return;
}
moduleHandleRef = handle;
// Wire the palette picker. Reflect the URL-decoded initial value, then
// re-write the URL + flip the data-palette attribute on every change.
const paletteSel = document.getElementById("projects-chart-palette") as HTMLSelectElement | null;
if (paletteSel) {
paletteSel.value = initialPalette;
paletteSel.addEventListener("change", () => {
const next = paletteSel.value;
if (!PALETTE_SET.has(next)) return;
const p = next as Palette;
handle!.setPalette(p);
writePaletteToURL(p);
});
}
// Density picker — same URL-state pattern. Density triggers a repaint
// (lane height + mark radius change), palette is a pure CSS swap.
const densitySel = document.getElementById("projects-chart-density") as HTMLSelectElement | null;
if (densitySel) {
densitySel.value = initialDensity;
densitySel.addEventListener("change", () => {
const next = densitySel.value;
if (!DENSITY_SET.has(next)) return;
const d = next as Density;
handle!.setDensity(d);
writeDensityToURL(d);
});
}
// 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
// user gets feedback instead of a silent failure.
function ctxNow(): ExportContext {
const data = handle!.getData();
return {
projectId: id,
projectTitle: project.title || t("projects.chart.title"),
svgEl: handle!.getSVGElement(),
events: data.events,
lanes: data.lanes,
};
}
function runExport(fn: (ctx: ExportContext) => void | Promise<void>): void {
void Promise.resolve()
.then(() => fn(ctxNow()))
.catch((err) => {
console.error("export failed", err);
if (host) {
host.setAttribute("data-export-error", "1");
}
});
}
wirePermalinkCopy("projects-chart-copylink");
wireExport("projects-chart-export-svg", () => runExport(exportSVG));
wireExport("projects-chart-export-png", () => runExport(exportPNG));
wireExport("projects-chart-export-csv", () => runExport(exportCSV));
wireExport("projects-chart-export-json", () => runExport(exportJSON));
wireExport("projects-chart-export-print", () => exportPrint());
// iCal goes server-side so it reuses the existing caldav_ical formatter
// (faraday-Q6 / m's pick: deadlines + appointments only — no projected).
wireExport("projects-chart-export-ics", () => {
window.location.href = `/api/projects/${encodeURIComponent(id)}/timeline.ics`;
});
// After the first paint, surface the undated hint when the renderer
// reports clipped/undated rows. Re-checked on resize-debounced repaint.
const checkUndated = () => {
if (!undatedHint || !handle) return;
const layout = handle.getLayout();
if (!layout) return;
if (layout.undatedCount > 0) {
undatedHint.style.display = "";
undatedHint.textContent = `${layout.undatedCount} Ereignis(se) ohne Datum (links angeheftet).`;
} else {
undatedHint.style.display = "none";
}
};
// Poll once after the initial fetch settles. mount() kicks the fetch
// synchronously; layout becomes available after the network round-trip.
setTimeout(checkUndated, 400);
setTimeout(checkUndated, 1500);
}
/** Render the lane-filter chip group once the renderer has lanes from
* the server. One toggle button per lane; clicking flips inclusion in
* the visible-lane allow-set. Hidden when there's only one lane (or
* none) — the filter is pointless on a single-track render. */
function renderLaneFilter(lanes: ReadonlyArray<{ id: string; label: string }>): void {
const container = document.getElementById("projects-chart-lanes-filter");
if (!container) return;
// Hide and bail when the filter wouldn't add value.
if (lanes.length < 2) {
container.innerHTML = "";
container.style.display = "none";
return;
}
container.style.display = "";
container.innerHTML = "";
const titleEl = document.createElement("span");
titleEl.className = "smart-timeline-chart-lanes-label";
titleEl.textContent = "Spuren:";
container.appendChild(titleEl);
for (const lane of lanes) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "smart-timeline-chart-lane-chip";
const isVisible = laneIsVisible(lane.id);
btn.setAttribute("aria-pressed", isVisible ? "true" : "false");
btn.dataset.laneId = lane.id;
btn.textContent = lane.label || lane.id;
btn.addEventListener("click", () => {
toggleLane(lane.id, lanes);
// Reflect new state immediately on this button + siblings.
for (const sibling of container.querySelectorAll<HTMLButtonElement>("button[data-lane-id]")) {
const sid = sibling.dataset.laneId || "";
sibling.setAttribute("aria-pressed", laneIsVisible(sid) ? "true" : "false");
}
});
container.appendChild(btn);
}
}
/** Permalink copy. The URL already aggregates every chip's state via the
* individual writeParamToURL writers (palette + density + range + lanes),
* so window.location.href IS the canonical shareable link. We copy it
* to the clipboard and flash a "kopiert" confirmation on the button. */
function wirePermalinkCopy(buttonId: string): void {
const btn = document.getElementById(buttonId) as HTMLButtonElement | null;
if (!btn) return;
const originalLabel = btn.textContent || "";
let resetTimer: ReturnType<typeof setTimeout> | null = null;
btn.addEventListener("click", async () => {
const url = window.location.href;
const ok = await copyToClipboard(url);
if (resetTimer) clearTimeout(resetTimer);
btn.textContent = ok ? "✓ Kopiert" : "⚠ Konnte nicht kopieren";
btn.classList.add(ok ? "is-success" : "is-error");
resetTimer = setTimeout(() => {
btn.textContent = originalLabel;
btn.classList.remove("is-success", "is-error");
}, 1800);
});
}
async function copyToClipboard(text: string): Promise<boolean> {
// Prefer the async Clipboard API. Falls back to the legacy exec hack
// for browsers / contexts where it's unavailable (some iframes, file://).
if (navigator.clipboard && window.isSecureContext) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
// fall through
}
}
try {
const textarea = document.createElement("textarea");
textarea.value = text;
textarea.style.position = "fixed";
textarea.style.opacity = "0";
document.body.appendChild(textarea);
textarea.select();
const ok = document.execCommand("copy");
document.body.removeChild(textarea);
return ok;
} catch {
return false;
}
}
function wireExport(buttonId: string, handler: () => void): void {
const btn = document.getElementById(buttonId) as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", (e) => {
e.preventDefault();
handler();
// Close the <details> dropdown so the user sees the chart-area
// update (download notification, print preview, etc).
const details = btn.closest("details");
if (details) details.removeAttribute("open");
});
}
// Lane-filter mutable state lives at module scope so renderLaneFilter
// closures over the same set as toggleLane / laneIsVisible. We can't
// access boot()'s `visibleLanes` from here cleanly, so we mirror it.
let moduleVisibleLanes: string[] | null = null;
let moduleHandleRef: ChartHandle | null = null;
function laneIsVisible(id: string): boolean {
if (moduleVisibleLanes === null) return true;
return moduleVisibleLanes.includes(id);
}
function toggleLane(id: string, allLanes: ReadonlyArray<{ id: string }>): void {
if (moduleVisibleLanes === null) {
// Currently "show all" — turning a chip off means everyone except this one.
moduleVisibleLanes = allLanes.map((l) => l.id).filter((l) => l !== id);
} else if (moduleVisibleLanes.includes(id)) {
moduleVisibleLanes = moduleVisibleLanes.filter((l) => l !== id);
} else {
moduleVisibleLanes = [...moduleVisibleLanes, id];
}
// If user toggled every lane back on, collapse to null (show all).
if (moduleVisibleLanes.length === allLanes.length) {
moduleVisibleLanes = null;
}
// If user toggled every lane off, snap back to null too — an empty
// chart is never useful, treat as "you didn't mean that, show all".
if (moduleVisibleLanes !== null && moduleVisibleLanes.length === 0) {
moduleVisibleLanes = null;
}
if (moduleHandleRef) {
moduleHandleRef.setVisibleLanes(moduleVisibleLanes);
}
writeLanesToURL(moduleVisibleLanes);
}
document.addEventListener("DOMContentLoaded", () => {
void boot();
});