Inventor design pass for m/paliad#35. NO IMPLEMENTATION. Pinned premises: - SmartTimeline data substrate (projection_service.go, ResponseEnvelope) is shipped through Slice 4. Chart is a presentation-only layer. - No chart libs / PDF libs / headless browser in repo. Bun + std-Go only. - Custom Views shapes today are list/cards/calendar; "timeline" slot reserved by t-paliad-169 §8.6 but not registered. Recommended: - Two renderers coexist: existing DOM/CSS shape-timeline.ts (vertical embed, Verlauf tab, no changes) + new hand-rolled SVG shape-timeline- chart.ts (horizontal Gantt, /projects/{id}/chart standalone). Both consume the same TimelineEvent[] + LaneInfo[] substrate. - Lane model = substrate's existing LaneInfo. No new lane axis. Chart adds only render-side state (layout, columns, density, palette, zoom). - Five built-in palette presets via CSS-var swap (default / kind-coded / track-coded / high-contrast / print). No per-user picker in v1. - Export pipeline: - Client-side: SVG (serialize → blob), PNG (drawImage), PDF (window.print() + @media print stylesheet). - Server-side: CSV (encoding/csv), JSON (alt content type on existing /timeline endpoint), iCal (extends caldav_ical.go formatter). - Reject chromedp / server-side PDF for v1 — Chromium runtime weight not justified by browser-print quality gap. - Mobile: vertical-only on <640px (horizontal Gantt unreadable on phone). Phasing (4 sequential slices): 1. Standalone /chart page + horizontal SVG renderer. 2. Export pipeline (SVG/PNG/PDF/CSV/JSON/iCal). 3. Density / palette / zoom controls. 4. Custom Views shape="timeline" registration (cross-project chart). 12 open questions for m's gate. Files implementer touches in Slice 1 listed (~700 LoC frontend, ~50 LoC backend, zero migrations). Doc: docs/design-project-chart-2026-05-09.md (607 lines).
42 KiB
Design — Project Timeline / Chart (visualisation layer above SmartTimeline)
Author: faraday (inventor) Date: 2026-05-09 Task: t-paliad-177 Issue: m/paliad#35 Status: READY FOR REVIEW — m gates inventor → coder transition.
0. Premises verified live (before designing)
Before anchoring the design, I checked the live state — CLAUDE.md / memory / issue body can drift, the live system can't.
- SmartTimeline data substrate is shipped through Slice 4.
internal/services/projection_service.go:287 (For)returns([]TimelineEvent, ProjectionMeta, error). The wire envelope (ResponseEnvelope) is{events: TimelineEvent[], lanes: LaneInfo[]}—Lanesis the load-bearing primitive for parent-node aggregation (one column per direct child case / patent / litigation).LevelPolicyalready differentiatesself_plus_ccr(Case) /child_case(Patent) /child_patent(Litigation) /child_litigation(Client). Recent commits7da8802,7e57507,7930ee0confirm — design merge is onmain(b4f4b3 baseline as of this branch). - Frontend renderer for the SmartTimeline is
frontend/src/client/views/shape-timeline.ts(960 LoC, hand-rolled DOM viadocument.createElement). It already implements: vertical flow, parallel-track CSS-grid for CCR (renderParallelTracks), lane-strip CSS-grid for parent-node aggregation (renderLaneStrip), click-to-anchor inline editor,[Track ▼]chip, lane-filter chip multiselect, lookahead toggle. The "horizontal Gantt" mode m's brief asks about does not exist. - No chart library is in the repo.
package.jsonhas only@types/bun. No D3, no Chart.js, no Apache ECharts, no plotly, no chartjs-node-canvas. Frontend is hand-rolled DOM/SVG via the custom TSX renderer described in.claude/CLAUDE.md. Adding a runtime dep would need m's explicit approval (per global rules). - No PDF / image-export pipeline exists either.
internal/services/caldav_ical.gogenerates VCALENDAR strings (BEGIN:VCALENDAR / BEGIN:VEVENT) for CalDAV PUT bodies, but there is no public iCal-feed download endpoint, no headless-browser dep (chromedpnot ingo.sum), no Go PDF lib. The only existingContent-Disposition: attachmentheader is ininternal/handlers/files.gofor the Gitea Downloads proxy. - Custom Views render shapes are list / cards / calendar.
internal/services/render_spec.godeclaresRenderShape=ShapeList | ShapeCards | ShapeCalendar. There is noShapeTimelineregistered yet — t-paliad-169 §8.6 reserved the slot but didn't claim it. A new chart shape would extend this enum and growfrontend/src/views.tsxhost accordingly. - Mobile breakpoints in use today are 640px / 720px / 768px / 1023px (
frontend/src/styles/global.css). Lime green primary token is--color-accent: var(--hlc-lime)with light/dark variants and a--color-accent-fgforeground token. There is@media printalready in the stylesheet — printing is on the table. - Project hierarchy depth in prod = 4 levels, 11 projects total. A loaded Patent at the upper end has 5 child cases; a hypothetical Client could have 100+ matters. Any chart layout must answer "how does this look on a page with 5 cases × 30 events" and "with 100+ matters" — see §10.
If the live state above contradicts a memory or issue note, the live state wins.
1. Vision + scope
m's brief (verbatim 2026-05-09 18:32):
One could chose to show the timeline in one or in separate columns and with different colors even... bigger feature development but ... a project timeline / chart would be nice in general. So we need to make some considerations on how to design one. Another aspect to this is vertical or horizontal... and an export functionality would also be great.
The Project Timeline / Chart is the visualisation layer above the SmartTimeline data substrate. Where SmartTimeline answers "what is the data", the Chart answers "how does the lawyer want to see it today, on what surface, in what shape, exported to whom".
What this design covers
| Axis | Choices |
|---|---|
| Layout direction | Vertical (today) / Horizontal Gantt-strip / Hybrid |
| Column model | Single-column flow / Multi-column (lanes — already in substrate) |
| Visual customisation | Color schemes per track / kind / status / party; density modes (compact/standard/spacious); status pill / kind chip / shape variants |
| Export | SVG (vector) / PNG (raster) / PDF (browser-print or rasterised) / CSV (data) / JSON (data) / iCal (deadlines+appointments feed) |
| Surfaces | Verlauf-tab embed (existing) / /projects/{id}/chart standalone full-page / RenderShape="timeline" Custom Views |
What stays
projection_service.gois the only data source. No new query path. The chart is a presentation-level concern; data composition is solved.shape-timeline.ts(vertical DOM renderer) stays as the embed default for the Verlauf tab. We add modes alongside it; we don't tear it out.paliad.deadlines,paliad.appointments,paliad.project_events,paliad.deadline_rulesschemas — unchanged. Zero migrations in this design.- Color tokens (
--color-accent,--color-bg-lime-tint, …) — anchor every chart palette, light/dark mode + WCAG follow for free.
Out of scope (v1 of this feature)
- Cross-matter chart on
/projectslist page — bundled under the Custom-Views path (§8.3) onceRenderShape="timeline"lands. Not v1. - Live collaborative cursors / annotation pins — presentation features for a later phase, not for shipping the chart itself.
- Rich-text editing of chart entries from inside the chart canvas — clicks deep-link to existing detail pages. Edit-in-place is the SmartTimeline's anchor affordance and stays there.
- Server-side PDF rendering via headless browser — adding
chromedpintroduces a Chromium runtime dependency on the Dokploy compose host. Recommend client-sidewindow.print()for v1; revisit only if user feedback says "PDFs differ across employees' browsers". See §7.3 for the trade-off in full. - Theming UI for end users to pick palettes — v1 gives a small fixed palette set; a colour-picker is v2 nice-to-have only if real users ask for it.
2. Renderer choice — SVG for the Gantt mode, DOM for the flow mode
This is the load-bearing call. Five candidates surveyed:
| Renderer | Pros | Cons | Fit |
|---|---|---|---|
| DOM/CSS grid (existing) | Accessible by default; themable via CSS vars; free dark-mode + i18n; exportable via window.print() |
Hard to do continuous date-axis math (Gantt scaling); heavy reflow on resize; html-to-PNG via foreignObject is browser-quirky | Best for vertical flow ✓ |
| SVG hand-rolled | Vector by construction → free SVG / PNG export via canvas drawImage; precise positioning math; one paint call; printable | Manual ARIA scaffolding; no automatic text-wrapping; need a layout pass | Best for horizontal Gantt ✓ |
<canvas> |
Top performance for 1000+ nodes | Zero accessibility; manual hit-testing for clicks; export needs separate path | Overkill for our scale (≤150 nodes typical) ✗ |
| D3.js | Battle-tested abstractions for axes / scales | ~250 KB minified, runtime data-driven DOM mutation conflicts with our IIFE-bundle pattern, would need m's package approval | Overkill, runtime cost ✗ |
| SVG + foreignObject for text | Vector with native HTML text wrapping | Spotty PDF and Safari support; defeats the export-for-free pitch | Avoid ✗ |
2.1 Recommendation
Two renderers coexist. Same data, different DOM:
shape-timeline.ts(existing DOM/CSS grid, vertical) keeps powering the Verlauf-tab embed — it's small, accessible, themed.shape-timeline-chart.ts(new SVG) powers the standalone/projects/{id}/chartpage in horizontal Gantt mode. Hand-rolled, no library, ~500 LoC for v1.
The horizontal Gantt page is also where the export buttons live (§7) — exporting a vertical DOM list is "open browser print and cmd-P" already, no new code needed; the Gantt is the genuinely new surface and brings PDF/SVG/PNG with it.
2.2 Why hand-rolled SVG over D3
We have ≤150 nodes per project, two axes (date + lane), three primitives (bar, dot, label) and one expanding need (zoom + pan, eventually). D3 ships ~250 KB to give us scales + axis generators + zoom. Our scale is (date - earliestDate) / dayWidthPx, a one-liner; our axis is a year/quarter tick generator, ~30 LoC; pan + zoom is addEventListener("wheel"|"pointermove"), ~50 LoC. The lift to write it ourselves is real but small, the runtime cost saving is real, and we keep the single-file IIFE bundle pattern intact.
If we ever hit "the layout math is too painful to maintain", D3-only-the-axis-helper or an axes.ts module is a refactor we can do then. v1 ships without.
2.3 What hand-rolled SVG looks like
One root SVG element, three layered groups:
<svg viewBox="0 0 W H">
<defs>
<pattern id="weekend"…/> # weekend background stripe
<linearGradient id="proj"…/> # projected-row gradient
</defs>
<g class="chart-grid"> # lane separators + date-axis ticks + today rule
<g class="chart-bars"> # one rect/g per event
<g class="chart-labels"> # text labels (kind chip, title)
<g class="chart-overlay"> # tooltip + selection scrim
</svg>
Coordinates are computed by a layout(events, lanes, viewport) pure function — testable, deterministic, the same on screen and on export.
3. Layout — vertical (existing) + horizontal (new)
3.1 Vertical (DOM, existing — no changes)
Embedded on /projects/{id} Verlauf tab. Today's shape-timeline.ts flow with date column / event card right column, "Heute →" rule, parallel tracks for CCR, lane-strip for parent-node aggregation. Nothing changes in this design — I'm explicit about that so the implementer doesn't accidentally rewrite working code.
3.2 Horizontal Gantt-strip (SVG, new)
The /projects/{id}/chart page. Time on the X axis, lanes on the Y axis. Each lane is a horizontal row; events plot as either a dot (point-in-time: deadline due-date, milestone, appointment) or a bar (range: future-projected sequence between two anchors, or appointment with end_at). Today's rule = vertical line.
←──────── 2026 ────────→ 2027 ─────→
┌────────────────────────────────────────┐
Self │ ✓ ●─────●────────────● ░──░──░──░ │
Hauptverf. │ Klage Antw. HV R29a R29c │
│ ↑Heute │
├────────────────────────────────────────┤
Widerklage │ ⊕──────░───░──░ │
(CCR) │ Filed R29d R32 │
│ │
└────────────────────────────────────────┘
Date axis: Q1 Q2 Q3 Q4 Q1 Q2 Q3
│ │
└ year border └ Today rule (lime)
3.3 Layout invariants (both modes)
These rules must hold across both renderers — they're the contract that lets us swap modes without surprising the user:
- Past = left/below; Future = right/above; Today = lime separator. Vertical: future at top per existing convention. Horizontal: future on right per Gantt convention. The convention flip is fine because the "today" lime separator orients the user instantly.
- One row = one event in vertical; one bar/dot = one event in horizontal. We never group two events into one mark. Lane (column in horizontal, parallel-track-column in vertical) is the only grouping primitive.
Kinddrives shape / glyph;Statusdrives color saturation;Trackdrives column placement. This composes orthogonally — see §5.
3.4 Hybrid not in v1
A "compact horizontal-strip-on-top + vertical-detail-below" hybrid (think Gmail conversation view but for matters) is a tempting third mode. Not in v1 — adds a third renderer with no clear user request behind it. Revisit if a partner asks "I want both at once".
3.5 Single-column vs multi-column on horizontal
Multi-column = lanes, identical to the substrate's LaneInfo already. The horizontal Gantt always multi-lanes when there's more than one lane; collapsing all events into one row just to give a "single-column" version produces visual chaos with overlapping bars on the same date. The [Track ▼] filter (existing) lets the user collapse to a single track if they want a single-row view. So:
- Substrate has 1 lane (Case-level, no CCR): single horizontal row.
- Substrate has 2+ lanes (Case + CCR sub-project, OR Patent / Litigation / Client level): horizontal multi-lane Gantt with one row per lane.
This mirrors the lane-mode the vertical renderer already uses (renderLaneStrip) — same data shape, different rendering.
4. Column model — extend LaneInfo, no new substrate concept
The substrate already discriminates lanes via levelPolicy(projectType) returning LaneAxis. The chart inherits that vocabulary for free.
4.1 What the chart adds
Two read-only filters at chart mount time, both client-side (no backend changes):
interface ChartViewState {
layout: "vertical" | "horizontal"; // default "horizontal" on /chart, "vertical" on Verlauf
columns: "auto" | "single" | "lanes"; // "auto" reads lanes.length from substrate
density: "compact" | "standard" | "spacious";
palette: "default" | "high-contrast" | "print" | "kind-coded" | "track-coded";
zoom: number; // px-per-day; default 4
range?: { from: string; to: string }; // ISO; defaults to substrate's earliest..latest+30d
}
columns="auto" is the default — the substrate decides. columns="single" collapses everything into one row (useful when comparing dates across CCR + parent on horizontal). columns="lanes" forces lane mode even when only one lane exists (useful for screenshot consistency).
4.2 What the chart does not add to the substrate
No new lane axis. If the brief later wants "lanes per party" (claimant vs defendant) or "lanes per court country", that becomes a new LaneAxis value in levelPolicy — substrate work, not chart work. The chart is a render of whatever lanes the substrate produced.
This boundary is important: the chart can be improved / re-skinned / re-renderered without touching the data layer, and substrate improvements (new lane axes, new event kinds) automatically reach both renderers.
5. Color schemes
The brief asks for "different colors even". Three palette dimensions are useful — and they're orthogonal, so a user picks one at a time.
5.1 Palette presets (built-in, fixed)
| Preset | What's color-coded by | Use case |
|---|---|---|
default |
Lane (--color-accent for parent, neutral grey for CCR/parent_context) |
Embed in Verlauf, partner glance |
kind-coded |
Event kind (deadline = blue, appointment = amber, milestone = lime, projected = soft-grey) | "Show me what's a hearing vs a deadline at a glance" |
track-coded |
Track tag (parent / counterclaim / parent_context — three distinct hues) | CCR-heavy projects where the track is the most important axis |
high-contrast |
Status only (done = green ✓; open = amber; overdue = red; predicted = light-grey) | Print-friendly, accessibility-first, screenshot for client |
print |
Black / white / one-stripe-pattern (no color at all) | Faxable, b&w-printable, redactable |
All five palettes are CSS custom-property swaps on the chart root — the renderer reads var(--chart-bar-deadline), the palette CSS file defines what each is. No JS branching in the renderer.
5.2 Token surface (CSS vars)
.smart-timeline-chart {
--chart-bar-deadline: var(--color-accent);
--chart-bar-appointment: #f5a623;
--chart-bar-milestone: var(--hlc-midnight);
--chart-bar-projected: var(--color-text-subtle);
--chart-bar-overdue: #d62828;
--chart-track-parent: var(--color-accent);
--chart-track-counterclaim: #6e8a8c; /* desaturated teal */
--chart-track-parent-context: var(--color-text-subtle);
--chart-today-rule: var(--color-accent);
--chart-grid-line: var(--color-border);
--chart-bg: var(--color-bg);
--chart-bg-weekend: var(--color-bg-subtle);
}
.smart-timeline-chart[data-palette="kind-coded"] {
/* override --chart-bar-* — track tokens stay neutral so kind dominates */
--chart-track-parent: var(--color-text-subtle);
--chart-track-counterclaim: var(--color-text-subtle);
}
.smart-timeline-chart[data-palette="print"] {
--chart-bar-deadline: #000;
--chart-bar-appointment: #555;
--chart-bar-milestone: #000;
--chart-bar-projected: #aaa;
/* …and so on; the palette is a pure CSS swap */
}
5.3 Why no per-user color picker in v1
A per-user palette picker is a feature with a long tail (storage in user prefs, defaults vs overrides, migration when palette tokens change names, theme conflicts with light/dark). The fixed-preset surface answers 90 % of "I want different colors" with 10 % of the cost. If real users say "I want my-firm-blue", we add a v2 admin-level palette override (paliad.firm_palette row keyed by FIRM_NAME).
5.4 Light / dark / print
Existing dark-mode flip works automatically — the chart palette tokens reference --color-* family which is already dark-mode-aware. No extra surface. @media print overrides force the print palette regardless of the user-selected one — a print-out is always b&w-friendly.
6. Density + visual variants
6.1 Density modes
type Density = "compact" | "standard" | "spacious";
compact: lane height 24px, bar height 12px, label inline-only (no description). Use for "1000-row birds-eye" lane mode.standard(default): lane height 40px, bar height 20px, label + status pill.spacious: lane height 64px, bar height 28px, label + pill + description below.
CSS-driven via [data-density="…"] on the chart root. The bar & dot SVG geometry is computed from a single --lane-height var; switching density is a re-layout pass, not a re-render.
6.2 Status / kind / shape variants
The visual encoding stays consistent with shape-timeline.ts:
| Kind | Vertical glyph | Horizontal mark |
|---|---|---|
deadline |
… / ! (open / overdue) |
Filled circle on due date; ring around it for "open" |
appointment |
▢ |
Bar from start_at to end_at (or fixed-width if same-day) |
milestone |
⊕ |
Diamond at the date |
projected |
░ |
Hatched circle (predicted), dashed-circle (court_set), amber-outlined (predicted_overdue) |
Colour saturation drives Status independently: done = full color; open = lighter; predicted = 50% opacity; overdue = red overlay.
The CSS for the vertical mode already has these variants — the SVG mode replicates them via <circle> / <rect> + fill / stroke-dasharray attributes. Same visual language across modes is a non-negotiable.
7. Export pipeline
This is the most-requested part of the brief. Five formats; client-side only (no Go PDF dep, no headless browser).
7.1 The five formats
| Format | Content | Path | Why this path |
|---|---|---|---|
| SVG | Vector chart as-rendered | Browser: new XMLSerializer().serializeToString(svgEl) → Blob → download |
Free — SVG IS our render. |
| PNG | Raster chart at 2× device pixel ratio | Browser: SVG → <img> → <canvas>.drawImage → canvas.toBlob() |
One stdlib API call chain. |
| Print-formatted page | window.print() with @media print stylesheet; user picks "Save as PDF" |
Reuses browser's hardened PDF engine — no Go PDF dep, no Chromium pinned to Dokploy. | |
| CSV | Tabular data, flat | Server: GET /api/projects/{id}/timeline.csv → text/csv |
Cleanest for "Excel this" use case. |
| JSON | Data-as-stored | Server: GET /api/projects/{id}/timeline?format=json (existing endpoint, alt content type) |
Zero new code beyond a Content-Disposition: attachment. |
| iCal | Deadlines + appointments as VEVENT | Server: GET /api/projects/{id}/timeline.ics reusing caldav_ical.go formatter |
Lawyers can subscribe in Outlook / Apple Calendar. |
7.2 Why client-side for SVG/PNG/PDF, server-side for CSV/JSON/iCal
- SVG/PNG/PDF need the rendered pixel layout. Client has it, server doesn't (without a headless browser). Doing it on the client is a 30 LoC flow per format using stdlib browser APIs.
- CSV/JSON/iCal are pure data. Server-side they hit the existing
ProjectionServiceand stream straight to the client. CSV isencoding/csv; JSON isjson.Marshal; iCal reuses the existing string-builder. Three new handlers, ~120 LoC total.
7.3 Why NOT server-side PDF
The clean alternative is "spin up chromedp on the Dokploy compose host, render the chart page, return PDF". Trade-off:
- Pro: one canonical PDF render, works the same regardless of user's browser.
- Con: adds a Chromium runtime dep to the paliad Docker image (~150 MB), spins up a child process per export, opens an attack surface (someone exports a hostile SVG → Chromium handles it → CVE), and needs a queue (PDF render is 1-3s; a clicky user can DoS the box).
Browser print, by contrast, is in-process, free, sandboxed, and produces fine-looking PDFs. It loses pixel-perfect cross-browser parity, but lawyers care about content, not subpixel kerning.
Recommend client-side print for v1. Revisit if lawyers complain about cross-browser PDF differences. Adding chromedp later is a one-PR move; designing it into v1 risks shipping infra weight we may never need.
7.4 Print-mode CSS
The PDF path needs a robust @media print:
- Fix the chart to fit on landscape A4 (1100 × 760 px viewport).
- Force
palette="print". - Hide chrome (sidebar, footer, header →
.print-hideclass on existing layout). - Show project metadata (title, parties, court, proceeding type) as a printed header.
- Page-break logic: each lane group fits on one page; if a lane has too many events, split horizontally by year.
This print stylesheet can be extracted as frontend/src/styles/chart-print.css so it's auditable separately from the screen styles.
7.5 Export menu UI
Single button on the chart page header opens a menu:
[ ⤓ Export ▼ ]
├─ SVG (Vektorgrafik)
├─ PNG (Bild, 2× HiDPI)
├─ PDF (Drucken)
├─ ───
├─ CSV (Excel-Tabelle)
├─ JSON (Rohdaten)
└─ iCal (.ics — Outlook / Apple)
Translated via existing i18n (projects.detail.chart.export.*). One menu, one keyboard shortcut (Cmd+E / Ctrl+E) opens it.
7.6 What's exported in CSV
Flat schema, one row per TimelineEvent:
project_id,project_title,kind,status,track,lane_id,lane_label,date,
title,description,rule_code,depends_on_rule_code,depends_on_date,
sub_project_id,sub_project_title,bubble_up,deadline_id,appointment_id,
project_event_id
Columns mirror the wire TimelineEvent struct. UTF-8 with BOM (Excel-DE compat). Date format ISO-8601.
7.7 What's exported in JSON
The wire ResponseEnvelope directly: {events: TimelineEvent[], lanes: LaneInfo[], meta: ProjectionMeta, exported_at, exported_by, project_id}. Stable JSON schema; meta lets a future re-importer reconstruct the projection state exactly.
7.8 What's exported in iCal
Only kind IN ("deadline", "appointment") (projected rows are not stable enough to commit to a calendar). VEVENT block per row reuses caldav_ical.go formatter; UID is paliad-deadline-<id>@paliad.de so re-export overwrites prior subscription. Future projected rows omitted by design — they would clutter every lawyer's Outlook with rule_code-derived events that may or may not fire on the predicted date.
8. Surfaces — three places the chart shows up
8.1 Verlauf tab embed (/projects/{id} — existing)
Vertical DOM mode only (existing shape-timeline.ts). Density standard. Palette default. Lane count obeys substrate. No changes in this design — the embed stays exactly as it is. The chart-mode opt-in lives below the tab.
A new "Als Chart anzeigen ↗" link in the SmartTimeline header opens /projects/{id}/chart in a new tab. Optionally (Q3 below) we could host a chart inline with a [Layout: ▽ Vertikal | ▷ Horizontal] toggle.
8.2 Standalone /projects/{id}/chart (new)
Full-page surface optimized for the horizontal SVG renderer. Layout:
┌───────────────────────────────────────────────────────────────────────┐
│ Siemens AG ./. Huawei — EP3456789 — UPC-CFI München │
│ Verfahrenstyp: UPC-Verletzung Anker: Klageschrift @ 2026-04-29 │
│ │
│ [Layout ▷] [Spalten Auto] [Dichte Standard] [Palette Default] [Export ⤓]│
├───────────────────────────────────────────────────────────────────────┤
│ ━━━━ FilterBar (existing primitive) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
├───────────────────────────────────────────────────────────────────────┤
│ │
│ ┌──── Horizontal SVG chart (full bleed) ───┐ │
│ │ │ │
│ │ ←─── 2026 ────→ 2027 ────→ │ │
│ │ Self ●─●───●──── ░──░──░ │ │
│ │ CCR ⊕────░───░──░ │ │
│ │ │ │
│ └──────────────────────────────────────────┘ │
│ │
└───────────────────────────────────────────────────────────────────────┘
URL convention: /projects/{id}/chart?layout=horizontal&palette=default&density=standard&zoom=4. State persists in URL so the link is shareable and copy-pasteable. localStorage caches the last chosen state per user as the default.
8.3 Custom Views shape (shape="timeline")
Registers ShapeTimeline RenderShape = "timeline" in internal/services/render_spec.go and adds a corresponding frontend/src/client/views/shape-timeline-chart.ts view-host wrapper that adapts a ViewRow[] → TimelineEvent[] array. This unlocks cross-project timelines as a Custom View — "all my UPC matters" or "everything where I'm in the team" rendered as one chart.
ViewRow → TimelineEvent is a lossy shim: kind and track map directly; date reuses event_date; cross-project lanes are auto-derived from project_id. Projected rows are not surfaced from ViewService (it doesn't run the calculator) — Custom Views show actuals only. We document that limitation, ship the shape, and revisit later if needed.
This is §8.3's gating: the standalone page (§8.2) and embed (§8.1) ship before the Custom Views shape. The shape is Slice 4 — last, optional, lower-priority.
9. Mobile behaviour
Three breakpoints, one rule:
| Width | Vertical embed | Standalone chart |
|---|---|---|
| ≥1024 px (desktop) | Existing | Horizontal SVG, full-bleed |
| 640–1023 px (tablet) | Existing | Horizontal SVG, narrower viewport, density auto-switches to compact |
| <640 px (phone) | Existing | Force vertical — horizontal Gantt on phone is unreadable |
The "force vertical on phone" rule is enforced server-side via the Accept-CH Sec-CH-UA-Mobile header (defensive) and client-side via window.matchMedia("(max-width: 640px)"). The user can override but the default flips.
A horizontal-on-phone variant with overflow-x: scroll is technically possible but UX-poor — date axis disappears off-screen, lawyer can't see context. Force vertical, force collapsing of lanes into stacked sections, keep the export menu reachable.
10. Performance
10.1 Current numbers
- Patent (5 child cases × 30 events) = 150 nodes typical
- Client (100+ matters) = 100s of lane rows; aggregation already sub-filters to milestones-only at Client level → <500 nodes
- Backend projection cost: ~285 ms cold cache for one project (per t-paliad-169 §13). Backend is not the bottleneck.
10.2 Where each renderer caps
| Renderer | Comfortable | Stressed | Breaks |
|---|---|---|---|
| DOM grid (vertical) | ≤300 nodes | 300-1000 (sluggish reflow) | 1000+ (frame drops on scroll) |
| Hand-rolled SVG | ≤1000 nodes | 1000-3000 (slow zoom / pan) | 3000+ (paint cost) |
| Canvas (not chosen) | ≤10 000 nodes | — | — |
We're sitting in the comfortable band for both for any plausible Paliad project. Numbers above 1000 happen only in pathological "show all my Client's matters" scenarios — and those are bound by levelPolicy aggregation already (Client-level Custom Views).
10.3 Mitigations if a real project exceeds the comfort zone
- Lookahead cap (existing):
?lookahead=Nkeeps projected nodes capped at 7 by default (50 max). Future-only, doesn't help if there are 1000 actuals. - Date-range filter: chart shows only events in a date window (defaults
earliest..latest+30d— no implicit cap). For pathological cases, user can narrow the range. - Lane filter (existing): hide / dim selected lanes on multi-lane render.
If a single matter genuinely has 1000+ actuals, the user has a deeper data-discipline problem and the right answer is to escalate, not to optimize a chart for it.
10.4 SVG paint budget
A 200-event chart in horizontal mode is ~600 SVG primitives (200 bars/dots × 3 elements: shape + label + tooltip-trigger). One initial paint = <50 ms on a low-end laptop. Subsequent zoom / pan re-runs the layout fn (10 ms) and re-attributes existing nodes (no re-create) — fast. We do not need virtualization in v1.
11. Phasing — 4 sequential slices
Each slice independently shippable. m's go/no-go gate after each.
Slice 1 — Standalone /projects/{id}/chart page + horizontal SVG renderer (no exports yet)
What lands:
- New page route
GET /projects/{id}/chart(handlerinternal/handlers/chart_pages.go, ~50 LoC). Reuses existing project gate. - New
frontend/src/projects-chart.tsxpage TSX (renders shell + mount target). ~100 LoC. - New
frontend/src/client/views/shape-timeline-chart.tsSVG renderer (~500 LoC). Pure-functionlayout(events, lanes, viewport)+paint(layout, palette, root). - Reuses the existing
GET /api/projects/{id}/timelineendpoint — no backend change. - Mode toggle on Verlauf tab:
[Als Chart anzeigen ↗]link → opens/chart. - Default palette + standard density + auto columns. No export, no palette picker, no density picker yet — controls render as inert chips.
What it gives m: the horizontal Gantt rendering, end-to-end. Lawyer can open /chart, see the matter in horizontal layout, share the URL.
Slice 2 — Export pipeline (SVG / PNG / PDF / CSV / JSON / iCal)
What lands:
- Client-side:
frontend/src/client/views/chart-export.ts(~150 LoC) handling SVG → PNG conversion, PDF print invocation, blob downloads. Three new i18n keys per format. - Server-side:
internal/handlers/projection.gogains 3 new handlers —handleProjectTimelineCSV,handleProjectTimelineJSON(alt?format=jsonon existing),handleProjectTimelineICS. Each ~30 LoC. - New
frontend/src/styles/chart-print.cssfor@media printand palette swap. - Export menu UI on chart page header.
What it gives m: every export format the brief asked for, no infra additions, lawyer-shareable PDFs.
Slice 3 — Density + palette + zoom controls
What lands:
- Density toggle (
compact / standard / spacious) — pure CSS-var +[data-density]attr swap, no re-fetch. - Palette picker (
default / kind-coded / track-coded / high-contrast / print) — same pattern. - Zoom in / out controls + pan (mousewheel + drag).
- Date-range narrower (FilterBar
timeaxis already exists — wire it to chart viewport). - localStorage persistence per-user-per-project.
What it gives m: full visual customisation per the brief.
Slice 4 — Custom Views integration (shape="timeline")
What lands:
- Register
ShapeTimeline RenderShape = "timeline"ininternal/services/render_spec.go+ validator. - New
frontend/src/client/views/shape-timeline-cv.tsview-host adapter. Reuses Slice 1's renderer; adaptsViewRow[]toTimelineEvent[]. frontend/src/views.tsxshape-switcher gets the 4th button.- Documented limitation: projected rows not surfaced in Custom Views.
What it gives m: "all my UPC matters as one chart" via Custom Views — cross-project chart on the existing CV substrate.
What's NOT in any slice (v2 nice-to-haves)
- Per-user palette picker beyond fixed presets.
- Server-side PDF render via
chromedp. - Live collaborative cursors / annotation pins.
- Animation / transitions when zoom changes.
- Hybrid layouts (compact-strip + detail-list).
- Color-coding with custom user-defined rules.
12. Files implementer will touch (Slice 1 only)
Backend (Go):
internal/handlers/chart_pages.go— new, ~50 LoC.handleProjectChartPage(w, r)returns the rendered TSX shell. Auth + project visibility gates as on/projects/{id}.internal/handlers/handlers.go— registerGET /projects/{id}/chart.
Frontend (TS / TSX):
frontend/src/projects-chart.tsx— new, ~100 LoC. Page shell with mount target + page-level controls scaffold (chips inert in Slice 1).frontend/src/client/views/shape-timeline-chart.ts— new, ~500 LoC. SVG renderer:layout(events: TimelineEvent[], lanes: LaneInfo[], viewport: Viewport): ChartLayout— pure function returning bar/dot positions + axis ticks + today-rule x.paint(layout: ChartLayout, palette: Palette, root: SVGSVGElement): void— DOM-mutates the root.mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle— composes layout + paint + interaction (click → deep-link, hover → tooltip).
frontend/src/client/projects-chart.ts— new, ~150 LoC. Page boot: fetch/api/projects/{id}/timeline, mount renderer, wire URL state ↔ control chips (inert), wire SmartTimeline embed's[Als Chart anzeigen ↗]link fromfrontend/src/client/projects-detail.ts.frontend/src/styles/global.css—.smart-timeline-chart-*CSS additions, ~120 LoC. Including the palette token swap CSS but not yet wired to a picker.frontend/src/client/i18n.ts— ~25 keys underprojects.detail.chart.*(page title, control labels, default-palette-name, etc.) DE+EN.frontend/build.ts— register the new page bundle.
Tests:
frontend/src/client/views/shape-timeline-chart.test.ts— new, pure-function tests forlayout()(ranges, tick generation, lane stacking, today-rule positioning, undated-row handling).
Slices 2-4 are scoped in §11; coder picks them up after m's gate.
13. Trade-offs flagged
- SVG accessibility. Hand-rolled SVG needs explicit ARIA scaffolding (
role="img"+<title>+<desc>per group,aria-labelper event mark) to be screen-reader-readable. This is real implementation work — DOM mode gets it for free. Mitigation: lockdownroleand label conventions in the renderer and test with VoiceOver / NVDA before Slice 1 merges. - Print-CSS quirks.
window.print()PDFs will look slightly different across Chrome / Safari / Firefox. Lawyers comparing two exports may notice. Mitigation: documentation states "use Chrome for archival exports". Pursue chromedp only if real complaints surface. - No virtualization in v1. A 1000-event chart is not virtualized — every node is in the DOM/SVG tree. Mitigation: existing levelPolicy aggregation + lookahead caps keep node counts bounded for plausible projects. Add virtualization only if a real project exceeds the comfort band.
- Two renderers means two paths to maintain. A bug in vertical-mode rendering doesn't auto-fix the horizontal mode. Mitigation: both render the same
TimelineEvent/LaneInfodata; the discriminator is just the layout fn. Rendering bugs tend to be in shared event-mark visual tokens (color, status pill) which CSS-token-swap centralizes anyway. - Custom Views adapter is lossy. Cross-project chart in CV doesn't show projected rows. Some users might expect them. Mitigation: in-page tooltip on first CV-chart open: "Custom Views show actual events only. Open the project's
/chartfor projected rules." A future v2 could push the projection through ViewService but the substrate redesign is non-trivial. - Date-range default. Defaulting to
earliest_event..latest_event+30dmeans a matter with one ancient deadline forces the whole span on every render. Mitigation: clamp default range totoday-1y..today+1y, with a chip for "Alles anzeigen" to expand. Keeps the typical render compact. /chartURL collision./projects/{id}/chartdoesn't conflict with any existing route, but adding/chartat the project level forces the route table to stay tidy. Defensive: implementer grepsinternal/handlers/handlers.gobefore adding to confirm no collision.- Browser-print PDF on Safari shows the menu bar. Cosmetic; print stylesheet's
@pagedirective helps, but Safari ignores some rules. Mitigation: documentation; lawyer-facing exports recommend Chrome.
14. Open questions for m
Listed with my (inventor) pick where I have one — m decides.
Q1 — Default landing on /projects/{id}/chart: horizontal Gantt or vertical (with a toggle)?
My pick: horizontal Gantt as the default. The whole reason /chart exists is the horizontal mode; defaulting to vertical would make it a duplicate of Verlauf. Add a [Layout ▷|▽] toggle for users who want vertical-on-bigscreen.
Q2 — Should the chart page replace Verlauf when accessed at desktop width, or stay a separate URL? My pick: separate URL. Verlauf is the "scan & action" tab (click rows to mark deadlines done, add notes). Chart is the "share & overview" surface. Conflating them risks losing the inline-action affordance Verlauf was built for.
Q3 — Should the chart be embeddable inside the Verlauf tab (with a layout toggle), or only standalone? My pick: standalone in Slice 1; if user feedback says "I want to see horizontal on the project page directly", add the embed in a follow-up slice. Embedding doubles render cost on every project page open and creates layout pressure on the existing tab UI.
Q4 — Chromedp / server-side PDF: rule out for v1, or design in? My pick: rule out. Browser-print PDFs are good enough; Chromium-on-Dokploy is a heavy dep. Keep the door open by abstracting the export-button handler so a future server-side path is a one-route addition.
Q5 — Color palette presets: ship the full 5 in Slice 3, or just default + print for safety?
My pick: ship all 5. The palette mechanism is just CSS-var swaps; adding the other three is hours of design polish, not weeks of work. More options give more lawyers their preferred read.
Q6 — iCal export: only deadlines + appointments (recommendation), or include projected too? My pick: only deadlines + appointments. Subscribing to a calendar that fills with rule_code-derived predicted dates that never fire would erode trust. Future projected = visualisation only, never calendar artifacts.
Q7 — Custom Views integration (shape="timeline"): Slice 4 priority, or descope?
My pick: keep as Slice 4 but explicit go/no-go after Slice 3 ships. The cross-project chart is a cool demo but not in the original brief — descoping if real users haven't asked is fine.
Q8 — Date-range default on /chart: data-driven (earliest..latest+30d) or fixed (today-1y..today+1y)?
My pick: fixed today-1y..today+1y, with a chip "Alles anzeigen" expanding. Old matters with one historical deadline shouldn't force a 5-year span on first render.
Q9 — Should the chart support project comparison (chart 2-3 projects side-by-side)? My pick: no — out of scope for this feature. That's a Custom Views job (multi-project query → chart shape), not a per-project surface concern.
Q10 — Should we expose a permalink that captures zoom + range + palette + density + lane-filter? My pick: yes, via URL query params (already designed in §8.2). Sharing a chart-URL via WhatsApp / email then renders the same view for the recipient.
Q11 — Mobile: vertical-only fallback, or horizontal-with-scroll? My pick: vertical-only on phones (<640px). Horizontal-with-scroll loses the date axis off-screen. Tablet (640-1023px) keeps horizontal in compact density.
Q12 — On the SmartTimeline (Verlauf embed), do we also add an inline horizontal mode (Q3 follow-up)?
My pick: NO in v1. The standalone /chart is the new surface; Verlauf stays vertical. Adding both modes inline-Verlauf doubles the test matrix without clear user demand yet.
15. Recommendation for implementer
Pattern-fluent Sonnet coder. Slice 1 is the heaviest (new SVG renderer, new page, new TSX shell). Slice 2 needs careful CSS print-mode tuning — best paired with browser-screenshot iteration. Slice 3 is mostly CSS-token plumbing + UI controls. Slice 4 is the lightest if Slice 1 left the renderer well-decomposed.
Before Slice 1, the coder should sketch the layout(events, lanes, viewport) function on paper / a tests file — that's where the math lives, and getting it right deterministically is the difference between "works" and "subtle render glitches in obscure date ranges". Pure-function with table-driven tests for layout() is the correct approach.
Faraday (this worktree) parks. Not pre-emptively flipping to coder — m gates.
DESIGN READY FOR REVIEW