import type { LaneInfo, TimelineEvent } from "./shape-timeline"; // chart-export (t-paliad-177 Slice 2) — client-side export helpers for // the Project Timeline / Chart page. // // Five formats land in Slice 2 (per design §7.1, m's pick on faraday-Q4 // to rule out server-side PDF via chromedp): // // SVG — XMLSerializer of the live SVG element // PNG — SVG → at 2× HiDPI, toBlob("image/png") // PDF — window.print() with @media print stylesheet (browser handles // the PDF engine; no chromedp dep on Dokploy) // CSV — flat tabular dump of TimelineEvent[] (UTF-8 BOM for Excel-DE) // JSON — wire envelope verbatim + export-metadata header // // iCal lands in a follow-up commit (C5) and goes via a server-side // endpoint that reuses internal/services/caldav_ical.go (faraday-Q6). // // Design ref: docs/design-project-chart-2026-05-09.md §7. export interface ExportContext { projectId: string; projectTitle: string; svgEl: SVGSVGElement; events: ReadonlyArray; lanes: ReadonlyArray; } // --------------------------------------------------------------------------- // Public surface // --------------------------------------------------------------------------- export async function exportSVG(ctx: ExportContext): Promise { const svgString = serialiseSVG(ctx.svgEl); const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" }); triggerDownload(blob, filename(ctx, "svg")); } export async function exportPNG(ctx: ExportContext): Promise { const svgString = serialiseSVG(ctx.svgEl); const blob = await rasterise(svgString, ctx.svgEl); if (!blob) { throw new Error("PNG raster failed"); } triggerDownload(blob, filename(ctx, "png")); } export function exportCSV(ctx: ExportContext): void { const rows: string[][] = [csvHeader()]; for (const event of ctx.events) { rows.push(csvRow(event, ctx)); } // UTF-8 BOM keeps Excel-DE from mis-detecting ANSI; ISO-8601 dates // round-trip correctly into German Excel as text. const text = "" + rows.map(csvLine).join("\r\n") + "\r\n"; const blob = new Blob([text], { type: "text/csv;charset=utf-8" }); triggerDownload(blob, filename(ctx, "csv")); } export function exportJSON(ctx: ExportContext): void { const envelope = { project_id: ctx.projectId, project_title: ctx.projectTitle, exported_at: new Date().toISOString(), events: ctx.events, lanes: ctx.lanes, }; const text = JSON.stringify(envelope, null, 2) + "\n"; const blob = new Blob([text], { type: "application/json;charset=utf-8" }); triggerDownload(blob, filename(ctx, "json")); } export function exportPrint(): void { // The @media print stylesheet in global.css does the layout work; // we just invoke the browser's print dialog. User picks "Save as PDF" // (Chrome/Edge), "Drucken in Datei" (Firefox), etc. window.print(); } // --------------------------------------------------------------------------- // SVG / PNG plumbing // --------------------------------------------------------------------------- function serialiseSVG(svgEl: SVGSVGElement): string { // Clone so we can inline computed styles without polluting the live DOM. // For a true cross-environment-portable SVG, we'd compute every used // CSS-var into a literal value. v1 keeps it light: the receiver inherits // colours via document context when opened standalone, and the rendered // bars still work because palette tokens fall through to the .smart- // timeline-chart root selector via inline class. Add a fallback width / // height attribute so headless viewers don't render 0×0. const clone = svgEl.cloneNode(true) as SVGSVGElement; if (!clone.getAttribute("width") && svgEl.getAttribute("width")) { clone.setAttribute("width", svgEl.getAttribute("width") || "1000"); } if (!clone.getAttribute("height") && svgEl.getAttribute("height")) { clone.setAttribute("height", svgEl.getAttribute("height") || "400"); } clone.setAttribute("xmlns", "http://www.w3.org/2000/svg"); clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink"); // Inline the chart's computed palette tokens so the standalone SVG // paints the same way when opened in an image viewer (which has no // document.css). Read every --chart-* property off the live element. const computed = window.getComputedStyle(svgEl); const styleLines: string[] = []; for (const prop of [ "--chart-mark-deadline", "--chart-mark-appointment", "--chart-mark-milestone", "--chart-mark-projected", "--chart-mark-overdue", "--chart-mark-done", "--chart-today-rule", "--chart-grid-line", "--chart-lane-label", "--chart-tick-label", "--chart-bg", ]) { const val = computed.getPropertyValue(prop).trim(); if (val) styleLines.push(`${prop}: ${val};`); } if (styleLines.length > 0) { const existing = clone.getAttribute("style") || ""; clone.setAttribute("style", existing + styleLines.join(" ")); } return new XMLSerializer().serializeToString(clone); } async function rasterise(svgString: string, svgEl: SVGSVGElement): Promise { const widthAttr = svgEl.getAttribute("width") || "1000"; const heightAttr = svgEl.getAttribute("height") || "400"; const width = Number(widthAttr) || 1000; const height = Number(heightAttr) || 400; // 2× device pixel ratio for HiDPI exports (design §7.1 "PNG, 2× HiDPI"). const scale = 2; const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" }); const url = URL.createObjectURL(blob); try { const img = await loadImage(url); const canvas = document.createElement("canvas"); canvas.width = Math.round(width * scale); canvas.height = Math.round(height * scale); const ctx = canvas.getContext("2d"); if (!ctx) return null; ctx.fillStyle = "#ffffff"; ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.drawImage(img, 0, 0, canvas.width, canvas.height); return await new Promise((resolve) => { canvas.toBlob((b) => resolve(b), "image/png"); }); } finally { URL.revokeObjectURL(url); } } function loadImage(src: string): Promise { return new Promise((resolve, reject) => { const img = new Image(); img.onload = () => resolve(img); img.onerror = () => reject(new Error("Image load failed")); img.src = src; }); } // --------------------------------------------------------------------------- // CSV plumbing // --------------------------------------------------------------------------- const CSV_COLUMNS = [ "project_id", "project_title", "kind", "status", "track", "lane_id", "date", "title", "description", "rule_code", "depends_on_rule_code", "depends_on_date", "depends_on_rule_name", "sub_project_id", "sub_project_title", "bubble_up", "deadline_id", "appointment_id", "project_event_id", "project_event_type", ] as const; function csvHeader(): string[] { return [...CSV_COLUMNS]; } function csvRow(event: TimelineEvent, ctx: ExportContext): string[] { return [ ctx.projectId, ctx.projectTitle, event.kind, event.status, event.track, event.lane_id ?? "", isoOnly(event.date), event.title, event.description ?? "", event.rule_code ?? "", event.depends_on_rule_code ?? "", isoOnly(event.depends_on_date), event.depends_on_rule_name ?? "", event.sub_project_id ?? "", event.sub_project_title ?? "", event.bubble_up ? "true" : "false", event.deadline_id ?? "", event.appointment_id ?? "", event.project_event_id ?? "", event.project_event_type ?? "", ]; } function csvLine(fields: string[]): string { return fields.map(csvEscape).join(","); } /** RFC 4180 quoting: double quotes inside the field are doubled; wrap * the whole field in quotes if it contains comma / quote / newline. */ function csvEscape(value: string): string { if (/[,"\r\n]/.test(value)) { return '"' + value.replace(/"/g, '""') + '"'; } return value; } function isoOnly(date: string | null | undefined): string { if (!date) return ""; return date.slice(0, 10); } // --------------------------------------------------------------------------- // Download trigger // --------------------------------------------------------------------------- function triggerDownload(blob: Blob, name: string): void { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = name; // Some browsers (Safari < 14) ignore the download attribute unless // the link is in the document tree. Inserting + removing is cheap. a.style.display = "none"; document.body.appendChild(a); a.click(); document.body.removeChild(a); // Give the browser a tick to start the download before we revoke. setTimeout(() => URL.revokeObjectURL(url), 1000); } function filename(ctx: ExportContext, ext: string): string { // Keep filenames diff-friendly + filesystem-safe. Replace anything that // isn't ASCII alnum/dot/hyphen with "_". Truncate the title to 60 chars. const safeTitle = (ctx.projectTitle || "timeline") .normalize("NFKD") .replace(/[^\x20-\x7e]/g, "") .replace(/[^A-Za-z0-9.-]+/g, "_") .replace(/_+/g, "_") .replace(/^_|_$/g, "") .slice(0, 60) || "timeline"; const dateStr = new Date().toISOString().slice(0, 10); return `paliad-${safeTitle}-${dateStr}.${ext}`; }