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 = 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 = 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 = 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 { 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 { 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 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("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 | 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 { // 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
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(); });