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.
490 lines
18 KiB
TypeScript
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();
|
|
});
|