feat(t-paliad-177): chart exports — SVG/PNG/CSV/JSON + browser-print CSS

Five client-side export paths per design §7 (faraday-Q4: rule out
chromedp, browser-print is good enough).

- SVG: XMLSerializer over a clone of the live SVGSVGElement, with
  --chart-* tokens inlined so the standalone file paints the same way
  when opened in an image viewer (no document.css context).
- PNG: SVG → Image → Canvas at 2× DPR, toBlob("image/png"). White
  background painted first so transparent SVG stays printable.
- PDF: window.print() → @media print stylesheet hides chrome, forces
  the print palette tokens, locks A4 landscape via @page. User picks
  "Save as PDF" in the browser print dialog. No chromedp dep.
- CSV: 20-column flat schema mirroring TimelineEvent, UTF-8 BOM for
  Excel-DE, RFC 4180 escaping.
- JSON: events + lanes envelope + export-metadata header (project_id,
  project_title, exported_at).

Export menu uses native <details>/<summary> so it's keyboard-accessible
without JS. The chart handle exposes getSVGElement() + getData() so
chart-export.ts stays pure: it never reads DOM state outside the SVG
it's handed.

Filenames are sanitised + dated: paliad-{title}-{yyyy-mm-dd}.{ext}.

i18n: 7 new keys DE+EN under projects.chart.export.*.

Design ref: docs/design-project-chart-2026-05-09.md §7.
This commit is contained in:
mAi
2026-05-13 00:08:28 +02:00
parent b24063bee1
commit 98a51faa66
6 changed files with 496 additions and 3 deletions

View File

@@ -1171,6 +1171,12 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.chart.density.compact": "Kompakt",
"projects.chart.density.standard": "Standard",
"projects.chart.density.spacious": "Großzügig",
"projects.chart.export.menu": "⇓ Export",
"projects.chart.export.svg": "SVG (Vektorgrafik)",
"projects.chart.export.png": "PNG (Bild, 2× HiDPI)",
"projects.chart.export.print": "PDF (Drucken)",
"projects.chart.export.csv": "CSV (Excel-Tabelle)",
"projects.chart.export.json": "JSON (Rohdaten)",
"projects.detail.edit": "Bearbeiten",
"projects.detail.edit.modal.title": "Projekt bearbeiten",
"projects.detail.save": "Speichern",
@@ -3483,6 +3489,12 @@ const translations: Record<Lang, Record<string, string>> = {
"projects.chart.density.compact": "Compact",
"projects.chart.density.standard": "Standard",
"projects.chart.density.spacious": "Spacious",
"projects.chart.export.menu": "⇓ Export",
"projects.chart.export.svg": "SVG (vector graphic)",
"projects.chart.export.png": "PNG (raster, 2× HiDPI)",
"projects.chart.export.print": "PDF (print)",
"projects.chart.export.csv": "CSV (Excel table)",
"projects.chart.export.json": "JSON (raw data)",
"projects.detail.edit": "Edit",
"projects.detail.edit.modal.title": "Edit project",
"projects.detail.save": "Save",

View File

@@ -8,6 +8,14 @@ import {
type Density,
type Palette,
} 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
@@ -170,6 +178,36 @@ async function boot(): Promise<void> {
});
}
// 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");
}
});
}
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());
// After the first paint, surface the undated hint when the renderer
// reports clipped/undated rows. Re-checked on resize-debounced repaint.
const checkUndated = () => {
@@ -189,6 +227,19 @@ async function boot(): Promise<void> {
setTimeout(checkUndated, 1500);
}
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");
});
}
document.addEventListener("DOMContentLoaded", () => {
void boot();
});

View File

@@ -0,0 +1,274 @@
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 → <img> → <canvas> 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<TimelineEvent>;
lanes: ReadonlyArray<LaneInfo>;
}
// ---------------------------------------------------------------------------
// Public surface
// ---------------------------------------------------------------------------
export async function exportSVG(ctx: ExportContext): Promise<void> {
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<void> {
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<Blob | null> {
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<Blob | null>((resolve) => {
canvas.toBlob((b) => resolve(b), "image/png");
});
} finally {
URL.revokeObjectURL(url);
}
}
function loadImage(src: string): Promise<HTMLImageElement> {
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}`;
}

View File

@@ -1635,6 +1635,12 @@ export type I18nKey =
| "projects.chart.density.spacious"
| "projects.chart.density.standard"
| "projects.chart.error.mount"
| "projects.chart.export.csv"
| "projects.chart.export.json"
| "projects.chart.export.menu"
| "projects.chart.export.png"
| "projects.chart.export.print"
| "projects.chart.export.svg"
| "projects.chart.loading"
| "projects.chart.notfound"
| "projects.chart.palette.default"

View File

@@ -89,9 +89,39 @@ export function renderProjectsChart(): string {
<option value="print" data-i18n="projects.chart.palette.print">Druck (S/W)</option>
</select>
</span>
<span className="chip-inert" data-i18n="projects.chart.control.export.soon" title="Slice 2">
Export &darr; (Slice 2)
</span>
<details className="smart-timeline-chart-export">
<summary data-i18n="projects.chart.export.menu">
&dArr; Export
</summary>
<menu className="smart-timeline-chart-export-menu">
<li>
<button type="button" id="projects-chart-export-svg" data-i18n="projects.chart.export.svg">
SVG (Vektorgrafik)
</button>
</li>
<li>
<button type="button" id="projects-chart-export-png" data-i18n="projects.chart.export.png">
PNG (Bild, 2&times; HiDPI)
</button>
</li>
<li>
<button type="button" id="projects-chart-export-print" data-i18n="projects.chart.export.print">
PDF (Drucken)
</button>
</li>
<li className="smart-timeline-chart-export-divider" />
<li>
<button type="button" id="projects-chart-export-csv" data-i18n="projects.chart.export.csv">
CSV (Excel-Tabelle)
</button>
</li>
<li>
<button type="button" id="projects-chart-export-json" data-i18n="projects.chart.export.json">
JSON (Rohdaten)
</button>
</li>
</menu>
</details>
</div>
<div id="projects-chart-host" className="smart-timeline-chart-host" />

View File

@@ -14410,6 +14410,71 @@ dialog.quick-add-sheet::backdrop {
fill: #fff;
}
/* Export dropdown — uses native <details>/<summary> so it's keyboard-
accessible without JS. The menu only renders when open=true, which
the <details> element manages itself. */
.smart-timeline-chart-export {
position: relative;
display: inline-block;
}
.smart-timeline-chart-export > summary {
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.35rem 0.85rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 999px;
background: var(--color-bg, #fff);
cursor: pointer;
list-style: none;
font-size: 0.85rem;
}
.smart-timeline-chart-export > summary::-webkit-details-marker {
display: none;
}
.smart-timeline-chart-export[open] > summary {
background: var(--color-bg-subtle, #f5f5f5);
border-color: var(--color-accent, #c6f41c);
}
.smart-timeline-chart-export-menu {
position: absolute;
top: calc(100% + 4px);
left: 0;
margin: 0;
padding: 0.35rem 0;
list-style: none;
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #ddd);
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
z-index: 100;
min-width: 240px;
}
.smart-timeline-chart-export-menu li {
margin: 0;
}
.smart-timeline-chart-export-menu button {
display: block;
width: 100%;
text-align: left;
padding: 0.45rem 1rem;
border: none;
background: transparent;
color: inherit;
font: inherit;
cursor: pointer;
}
.smart-timeline-chart-export-menu button:hover,
.smart-timeline-chart-export-menu button:focus-visible {
background: var(--color-bg-subtle, #f5f5f5);
outline: none;
}
.smart-timeline-chart-export-divider {
height: 1px;
background: var(--color-border, #e0e0e0);
margin: 0.35rem 0.5rem;
}
/* Palette picker chip group on the chart page. */
.smart-timeline-chart-picker {
display: inline-flex;
@@ -14436,3 +14501,58 @@ dialog.quick-add-sheet::backdrop {
outline: 2px solid var(--color-accent, #c6f41c);
outline-offset: 2px;
}
/* ---- Print stylesheet (t-paliad-177 Slice 2, design §7.4) ----
When the user hits "PDF (Drucken)", the browser invokes print() and
reads these rules. Strategy:
- Force the print palette regardless of the user's screen choice
(B&W shows nothing the user didn't intend, redactable).
- Hide chrome (sidebar, footer, header, bottom-nav, control chips).
- Let the chart fill landscape A4 width.
- Add a printed header with project meta on the chart page. */
@media print {
@page {
size: A4 landscape;
margin: 1.5cm;
}
body.has-sidebar > aside.sidebar,
body.has-sidebar > .bottom-nav,
body.has-sidebar > footer,
body.has-sidebar .paliadin-widget,
.smart-timeline-chart-page .back-link,
.smart-timeline-chart-controls,
.smart-timeline-chart-page .entity-loading,
.smart-timeline-chart-undated-hint {
display: none !important;
}
.smart-timeline-chart-page main,
.smart-timeline-chart-page .container {
max-width: none !important;
padding: 0 !important;
margin: 0 !important;
}
.smart-timeline-chart-host {
border: none !important;
overflow: visible !important;
}
.smart-timeline-chart {
/* Force the print palette tokens regardless of data-palette. */
--chart-mark-deadline: #000 !important;
--chart-mark-appointment: #555 !important;
--chart-mark-milestone: #000 !important;
--chart-mark-projected: #777 !important;
--chart-mark-overdue: #000 !important;
--chart-mark-done: #000 !important;
--chart-today-rule: #000 !important;
--chart-grid-line: #ccc !important;
--chart-lane-label: #000 !important;
--chart-tick-label: #000 !important;
}
.smart-timeline-chart .chart-mark--deadline.chart-mark--status-open .chart-mark-dot {
fill: #fff !important;
stroke: #000 !important;
}
.smart-timeline-chart-header h1 {
font-size: 1.2rem;
}
}