Files
paliad/docs/design-project-chart-2026-05-09.md
m 84020022a6 design(t-paliad-177): Project Timeline / Chart — visualisation layer above SmartTimeline
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).
2026-05-09 18:44:27 +02:00

42 KiB
Raw Blame History

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[]}Lanes is the load-bearing primitive for parent-node aggregation (one column per direct child case / patent / litigation). LevelPolicy already differentiates self_plus_ccr (Case) / child_case (Patent) / child_patent (Litigation) / child_litigation (Client). Recent commits 7da8802, 7e57507, 7930ee0 confirm — design merge is on main (b4f4b3 baseline as of this branch).
  • Frontend renderer for the SmartTimeline is frontend/src/client/views/shape-timeline.ts (960 LoC, hand-rolled DOM via document.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.json has 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.go generates VCALENDAR strings (BEGIN:VCALENDAR / BEGIN:VEVENT) for CalDAV PUT bodies, but there is no public iCal-feed download endpoint, no headless-browser dep (chromedp not in go.sum), no Go PDF lib. The only existing Content-Disposition: attachment header is in internal/handlers/files.go for the Gitea Downloads proxy.
  • Custom Views render shapes are list / cards / calendar. internal/services/render_spec.go declares RenderShape = ShapeList | ShapeCards | ShapeCalendar. There is no ShapeTimeline registered yet — t-paliad-169 §8.6 reserved the slot but didn't claim it. A new chart shape would extend this enum and grow frontend/src/views.tsx host 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-fg foreground token. There is @media print already 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.go is 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_rules schemas — 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 /projects list page — bundled under the Custom-Views path (§8.3) once RenderShape="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 chromedp introduces a Chromium runtime dependency on the Dokploy compose host. Recommend client-side window.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}/chart page 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:

  1. 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.
  2. 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.
  3. Kind drives shape / glyph; Status drives color saturation; Track drives 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>.drawImagecanvas.toBlob() One stdlib API call chain.
PDF 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 ProjectionService and stream straight to the client. CSV is encoding/csv; JSON is json.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-hide class 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
6401023 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=N keeps 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 (handler internal/handlers/chart_pages.go, ~50 LoC). Reuses existing project gate.
  • New frontend/src/projects-chart.tsx page TSX (renders shell + mount target). ~100 LoC.
  • New frontend/src/client/views/shape-timeline-chart.ts SVG renderer (~500 LoC). Pure-function layout(events, lanes, viewport) + paint(layout, palette, root).
  • Reuses the existing GET /api/projects/{id}/timeline endpoint — 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.go gains 3 new handlers — handleProjectTimelineCSV, handleProjectTimelineJSON (alt ?format=json on existing), handleProjectTimelineICS. Each ~30 LoC.
  • New frontend/src/styles/chart-print.css for @media print and 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 time axis 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" in internal/services/render_spec.go + validator.
  • New frontend/src/client/views/shape-timeline-cv.ts view-host adapter. Reuses Slice 1's renderer; adapts ViewRow[] to TimelineEvent[].
  • frontend/src/views.tsx shape-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 — register GET /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 from frontend/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 under projects.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 for layout() (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-label per event mark) to be screen-reader-readable. This is real implementation work — DOM mode gets it for free. Mitigation: lockdown role and 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 / LaneInfo data; 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 /chart for 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+30d means a matter with one ancient deadline forces the whole span on every render. Mitigation: clamp default range to today-1y..today+1y, with a chip for "Alles anzeigen" to expand. Keeps the typical render compact.
  • /chart URL collision. /projects/{id}/chart doesn't conflict with any existing route, but adding /chart at the project level forces the route table to stay tidy. Defensive: implementer greps internal/handlers/handlers.go before adding to confirm no collision.
  • Browser-print PDF on Safari shows the menu bar. Cosmetic; print stylesheet's @page directive 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