Compare commits
15 Commits
mai/maxwel
...
mai/noethe
| Author | SHA1 | Date | |
|---|---|---|---|
| f5eb84718a | |||
| 1255ee049f | |||
| 0105d35f0c | |||
| 0531e5dbf6 | |||
| 0099e2f28c | |||
| cd1a70d08c | |||
| bdb3d8a425 | |||
| 30f7031e99 | |||
| 8e9cde6d52 | |||
| a3adb6b13b | |||
| ed4e731333 | |||
| b0a6b0998f | |||
|
|
54b227ce7b | ||
|
|
17e96b7a1c | ||
|
|
84020022a6 |
607
docs/design-project-chart-2026-05-09.md
Normal file
607
docs/design-project-chart-2026-05-09.md
Normal file
@@ -0,0 +1,607 @@
|
||||
# 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):
|
||||
|
||||
```ts
|
||||
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)
|
||||
|
||||
```css
|
||||
.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
|
||||
|
||||
```ts
|
||||
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. |
|
||||
| **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 |
|
||||
| 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=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**
|
||||
569
docs/design-tools-cleanup-2026-05-12.md
Normal file
569
docs/design-tools-cleanup-2026-05-12.md
Normal file
@@ -0,0 +1,569 @@
|
||||
# Design — Tools surface cleanup (Fristenrechner vs Verfahrensablauf split)
|
||||
|
||||
**Author:** kelvin (inventor)
|
||||
**Date:** 2026-05-12
|
||||
**Task:** t-paliad-178
|
||||
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live (before designing)
|
||||
|
||||
CLAUDE.md / memory / the task brief can all drift. Each anchor below is verified against the live codebase or DB on `mai/kelvin/inventor-tools-surface` (baseline commit `54b227c`).
|
||||
|
||||
- **One route + one TSX serve both nav entries today.** `/tools/fristenrechner` is the only registered page route (`internal/handlers/handlers.go:162`). Both sidebar entries (Fristenrechner + Verfahrensablauf) target the same Bun-built `dist/fristenrechner.html` and disambiguate purely through `?path=a` and a client-side active-class fix-up (`frontend/src/client/sidebar.ts:447 fixVerfahrensablaufActive`). Confirmed: the live HTML pulled from paliad.de (auth-gated → 302 to login, served-bytes match) is the shell rendered by `frontend/src/fristenrechner.tsx:87 renderFristenrechner`.
|
||||
- **The client runtime is 3 559 lines, not the 2 700+ quoted in the task brief.** `frontend/src/client/fristenrechner.ts` carries Step 1 / Step 2 / Step 3a / Pathway A wizard / Pathway B cascade + filter / search + cascade engines / column + timeline result-card renderers in one IIFE bundle (`Pathway` type at line 2315, `showPathway()` at line 2370, `showBMode()` at line 2406). Any "separate route" path must either lift code out of this bundle into a shared module or accept a larger duplicated bundle on the new page.
|
||||
- **Sidebar deep-link `?path=a` lands on Pathway A directly, NOT on the Akte picker.** I traced `initPathwayFork → readPathwayFromURL → showPathway("a")`: it sets `step1.style.display = "none"`, `step2.hidden = true`, `step3a.hidden = true`, `pathway-a.hidden = false`. The user sees the wizard's "Verfahrensart wählen" tile picker first. The task brief's phrasing — "still drops users at Step 1 (Akte-Picker)" — is the perceived UX from the wizard's own internal "wizard-step-1" labelled "Verfahrensart wählen". Mental model: two surfaces with the same nav label "Step 1" muddy intent; the fix m wants is structural (a dedicated route), not a JS bug fix.
|
||||
- **`paliad.projects.court` is a free-text column, NOT an FK to `paliad.courts`.** Confirmed in `information_schema.columns`. Live values: `LG München I` (1 row), `UPC` (2), `UPC CoA` (1). The task brief's "project has a court FK" is **wrong**; only `proceeding_type_id` is a real FK. The design must NOT silently auto-pick a `paliad.courts.id` from `projects.court` — fuzzy mapping is best-effort + always overridable, never silent.
|
||||
- **`paliad.projects.proceeding_type_id` points at `category='litigation'` rows (7 codes: INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL).** The Fristenrechner wizard accepts `category='fristenrechner'` codes (20 codes: UPC_INF, DE_INF, EPA_OPP, …). These overlap conceptually (`INF` is the abstract noun behind both `UPC_INF` and `DE_INF`) but are different rows. Auto-derivation needs a small mapping: `litigation_code × jurisdiction → fristenrechner_code`. Example: `INF + UPC → UPC_INF`. `INF + DE → DE_INF` (first instance). The instance dimension (LG / OLG / BGH) is **not** on `paliad.projects` today, so DE_INF_OLG / DE_INF_BGH cannot be inferred — only the first-instance code can be.
|
||||
- **`paliad.projects` carries no `priority_date` or `trigger_date` column.** It does have `filing_date` and `grant_date`. Only EP_GRANT.ep_grant.publish (Art. 93 EPÜ) is anchored on `priority_date` today (via `anchor_alt`). For Akte-driven prefill, `priority_date` stays blank by default and the user fills it.
|
||||
- **`paliad.projects.our_side` and `paliad.projects.counterclaim_of` exist** (already exploited by t-paliad-164 perspective-chip predefine and the parent-counterclaim link respectively). These two columns are the actual hooks for "consolidated timeline" vs "side-by-side lanes" — see §6.
|
||||
- **`deadline_rules.condition_flag` is a real text[] column with exactly 4 distinct value-sets in production:** `[with_amend]` (4 rows), `[with_cci]` (4), `[with_ccr]` (5), `[with_ccr, with_amend]` (4). Only `UPC_INF` (proceeding_type_id=8) and `UPC_REV` (proceeding_type_id=9) carry variant-flagged rules. Every other proceeding type renders a single canonical timeline today. **This is the hard data bound on the variant-chip design** — chips beyond these three flags would have no rules to flip and must be marked "future".
|
||||
- **Court-specific rule overrides do not exist as a mechanism.** `CourtID` in `CalcOptions` (`internal/services/fristenrechner.go:107`) only switches the holiday calendar (via `courts.CountryRegime`). There is no per-court rule branch. "UPC LD Mü vs LD Düsseldorf" overrides are NOT a thing — they'd need a new column on `deadline_rules`.
|
||||
- **Expedited-vs-standard distinctions do not exist** either. No `condition_flag` row matches an expedited concept. Adding one is a schema-and-seed change, out of scope here.
|
||||
- **Result rendering today** lives in `renderTimelineBody` and `renderColumnsBody` (`frontend/src/client/fristenrechner.ts:637 / :664`). The user toggles between the two with a radio (`#fristen-view-toggle`). Both renderers take a single `DeadlineResponse` and emit DOM strings; neither knows about "two timelines side by side". A consolidated-vs-lane view (§5–§6) is a renderer-level change, not a backend one.
|
||||
- **The Step 1/Step 2/Step 3a/Pathway A/B layout shipped under t-paliad-133 + t-paliad-168.** The "Verfahrensablauf einsehen" card (Step 2 third option, lines 215-223 of fristenrechner.tsx) was added in t-paliad-168 specifically to give the abstract-browse case a discoverable entry. If Verfahrensablauf moves to its own route, the third card becomes redundant (§9).
|
||||
|
||||
If any of these conflict with what the task brief asserts, **the live state wins** and the brief is the bug — flagged in §13 for m.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision + scope
|
||||
|
||||
m's framing (verbatim from the task brief):
|
||||
|
||||
> Users want to **either** (1) determine a deadline — possibly Akte-scoped, possibly abstract — **or** (2) browse a typical Verfahrensablauf abstractly with variant options.
|
||||
|
||||
The two intents are **fundamentally different**:
|
||||
|
||||
- **Determine a deadline** ends with a save (or a print, or a manual transcription) of a *specific* date attached to *something* — a project, or a sticky-note in the user's head.
|
||||
- **Browse a Verfahrensablauf** ends with the user understanding the *shape* of a proceeding — no date binding required.
|
||||
|
||||
Today both intents collapse onto one URL because the wizard infrastructure is shared. The cost: two sidebar entries pointing at the same shell, an active-class fix-up script (`fixVerfahrensablaufActive`), and a Step 1 ("Welche Akte?") frame that doesn't match the abstract-browse intent.
|
||||
|
||||
### Scope of this design
|
||||
|
||||
1. **Page surface split** — separate routes per intent. `/tools/fristenrechner` keeps the deadline-determination intent (Akte-scoped *or* abstract). `/tools/verfahrensablauf` becomes the dedicated abstract-browse surface with variant chips + side-by-side compare.
|
||||
2. **Step 0 "Abstrakt oder Akte?"** as the FIRST affordance on `/tools/fristenrechner`. Pick → narrows downstream inputs.
|
||||
3. **Akte-driven auto-derivation** — map project columns to wizard inputs and flag the gaps.
|
||||
4. **Variant chips + consolidated-vs-lane view** for `/tools/verfahrensablauf`.
|
||||
5. **Side-by-side compare** on `/tools/verfahrensablauf` (max 2 timelines for v1).
|
||||
6. **Sidebar labels + URL conventions** post-split.
|
||||
7. **Mobile responsive** plan.
|
||||
8. **What gets dropped** (Step 2 browse card, sidebar fix-up script).
|
||||
|
||||
### Explicitly out of scope (per task brief)
|
||||
|
||||
- Deadline-rule data-model changes (court-specific overrides, expedited-flag, new condition_flag values). Audited in §0, propose nothing here.
|
||||
- t-paliad-166 Determinator B1 cascade redesign — separate ticket, on-hold. Pathway B continues to exist inside `/tools/fristenrechner`; we note interplay in §11 but do not pre-empt.
|
||||
- t-paliad-157 Fristenrechner interactive-UX pair session — on-hold. The cleanup here may inform it, but we don't dictate it.
|
||||
- Project Verlauf tab (`/projects/{id}` → Verlauf). Stays as-is. SmartTimeline renders concrete-per-case via `internal/services/projection_service.go`; no Tool-side mirror.
|
||||
- New backend services. The split runs on the existing `POST /api/tools/fristenrechner` + `POST /api/tools/event-deadlines` endpoints; we add at most one helper for Akte → fristenrechner-code mapping.
|
||||
- Backend rule changes — touch the substrate only enough to verify what the design needs is already there.
|
||||
|
||||
---
|
||||
|
||||
## 2. Page surfaces + route split
|
||||
|
||||
m has already chosen **Option A** in the task brief: split by intent, separate URLs. The design here implements that choice. For honesty I also note the alternatives I considered and why A still wins after audit.
|
||||
|
||||
### 2.1 Three options weighed
|
||||
|
||||
| Option | URL shape | Trade-off | Verdict |
|
||||
|---|---|---|---|
|
||||
| **A — Two routes** | `/tools/fristenrechner` + `/tools/verfahrensablauf` | Clean mental model. Sidebar entries map 1:1 to URLs. `fixVerfahrensablaufActive` dies. Two HTML files; shared client code lifted into a module. | **Picked.** Aligns with intent split. |
|
||||
| **B — One route, `?mode=` fork** | `/tools/fristenrechner?mode=calc` vs `?mode=browse` | Single HTML bundle, no shared-module lift. But: sidebar entries still alias the same page; muddled intent stays in the user's head; we'd still need a Step 0 inside the calc mode. | Rejected by m. Verifies on second look: it just moves `?path=a` to `?mode=browse`, doesn't fix the problem. |
|
||||
| **C — Move into Patentglossar** | Verfahrensablauf renders inline on glossary pages | Discoverability shrinks. Glossary entries are concept-bounded; Verfahrensablauf is procedure-bounded. The two indexes don't map. | Rejected by m. |
|
||||
|
||||
### 2.2 Code-reuse strategy under Option A
|
||||
|
||||
The honest cost of splitting routes is shared-client-code duplication. Today `client/fristenrechner.ts` (3 559 LoC) bundles everything. The Verfahrensablauf-only surface needs:
|
||||
|
||||
- The proceeding-type tile picker (`UPC_TYPES`, `DE_TYPES`, `EPA_TYPES`, `DPMA_TYPES` arrays in `fristenrechner.tsx`).
|
||||
- The timeline + columns result renderers (`renderTimelineBody`, `renderColumnsBody`).
|
||||
- The `POST /api/tools/fristenrechner` calc invocation.
|
||||
- Court picker + holiday-calendar pickup (read-only).
|
||||
- DE/EN i18n for the timeline rows.
|
||||
|
||||
It does NOT need:
|
||||
|
||||
- Step 1 Akte picker / ad-hoc chip / Step 1 summary.
|
||||
- Step 2 file/happened/browse cards.
|
||||
- Step 3a outgoing-intent chooser.
|
||||
- Pathway B cascade + filter + perspective + inbox chips (~1 200 LoC).
|
||||
- Save-to-Akte modal.
|
||||
- Trigger-event mode (`mode-event-panel`).
|
||||
|
||||
**Plan:** lift the deadline-timeline core (proceeding picker + calc + render) into `frontend/src/client/views/verfahrensablauf-core.ts`. Both pages import it. Pathway B + Save modal + Step machinery stay in `client/fristenrechner.ts`. Estimated lifted surface: ~700–900 LoC. New code on `verfahrensablauf.ts` (variant chips + lane mode + compare): ~400–600 LoC.
|
||||
|
||||
This keeps the IIFE per-page bundle pattern intact (one entry per route in `frontend/build.ts:228`). No runtime npm dep added.
|
||||
|
||||
### 2.3 The two pages in one sentence each
|
||||
|
||||
- **`/tools/fristenrechner`** — Deadline determination. Optional Akte scope. Ends in "save / print / done".
|
||||
- **`/tools/verfahrensablauf`** — Procedural shape browser. No Akte. Ends in "now I understand the shape".
|
||||
|
||||
### 2.4 Sidebar
|
||||
|
||||
```text
|
||||
Werkzeuge
|
||||
Fristenrechner → /tools/fristenrechner
|
||||
Verfahrensablauf → /tools/verfahrensablauf
|
||||
Kostenrechner → /tools/kostenrechner
|
||||
…
|
||||
```
|
||||
|
||||
`fixVerfahrensablaufActive` deletes; the SSR-time `navItem` helper handles both active classes natively because the hrefs differ on pathname.
|
||||
|
||||
---
|
||||
|
||||
## 3. Step 0 "Abstrakt oder Akte?" on `/tools/fristenrechner`
|
||||
|
||||
m's lock-in: Step 0 comes FIRST. Today's Step 1 (Akte picker) forces the user to either commit to an Akte or escape via ad-hoc chips before anything else moves. Step 0 makes the binary choice explicit.
|
||||
|
||||
### 3.1 Affordance — three sketches considered
|
||||
|
||||
**Sketch A — Radio toggle (Recommended).**
|
||||
A pair-of-toggle at the top of the page, wide on desktop, stacked on mobile. The currently-active half expands into its full picker; the inactive half collapses to a slim header that the user can click to flip.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Schritt 0 — Wie wollen Sie die Frist bestimmen? │
|
||||
│ │
|
||||
│ ◉ Mit Akte verknüpfen ○ Abstrakt — ohne Akte │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ │
|
||||
│ 🔍 Akte suchen… │
|
||||
│ [Akte 1 · CLI-2024 — Foo GmbH vs Bar Ltd. — UPC LD Mü] │
|
||||
│ [Akte 2 · …] │
|
||||
│ ──── │
|
||||
│ + Neue Akte anlegen │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
When the user picks "Abstrakt":
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Schritt 0 — Wie wollen Sie die Frist bestimmen? │
|
||||
│ │
|
||||
│ ○ Mit Akte verknüpfen ◉ Abstrakt — ohne Akte │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ │
|
||||
│ Verfahrensart wählen: │
|
||||
│ [UPC] [DE] [EPA] [DPMA] ← jurisdiction picker (4 tabs) │
|
||||
│ (then proceeding-type tiles within the chosen tab) │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Why I'd recommend this:** the toggle is a single decision, declared up-front, with the consequence visible inline. No modal dismissal cost. Keyboard navigation natural. On mobile it stacks to two stacked rows where the active row expands and the inactive row stays a touch-target.
|
||||
|
||||
**Sketch B — Two big cards.** Like today's Step 2 cards but at the very top. Pro: pretty + tappable. Con: click-and-commit feels heavier than a toggle; "going back" reads as undoing a choice instead of flipping it.
|
||||
|
||||
**Sketch C — Modal-before-render.** Most decisive, also most annoying — the user can't even see the page before the dialog clears. Reject. (Modals interrupt; we want the user oriented before they're asked.)
|
||||
|
||||
### 3.2 URL state
|
||||
|
||||
Step 0 binds to `?mode=akte|abstract` in the URL.
|
||||
|
||||
- `?mode=akte&project=<uuid>` — Akte selected. Court / proceeding-type / our_side auto-derived (§4).
|
||||
- `?mode=abstract&forum=upc|de|epa|dpma` — abstract. Jurisdiction tab selected; proceeding-type tiles below.
|
||||
- `?mode=` absent — render Step 0 with no preselection.
|
||||
|
||||
Deep-link from `/projects/{id}` → "Frist berechnen" button passes `?mode=akte&project=<id>` and lands on Step 0 with Akte branch already filled.
|
||||
|
||||
`localStorage["paliad.fristen.mode"]` remembers the user's last choice for soft re-entry (the `PATHWAY_STORAGE_KEY` pattern already exists).
|
||||
|
||||
### 3.3 Removal of today's Step 2 fork (file / happened / browse)
|
||||
|
||||
With Step 0 making the intent binary, the file-vs-happened branching collapses into one wizard with two anchor sources:
|
||||
|
||||
- **Akte mode** — wizard pre-filled. After calc, the save CTA is "An Akte hängen". `?path=` machinery shrinks because Pathway A vs Pathway B becomes a wizard *step* (incoming-event vs outgoing-event), not a top-level path.
|
||||
- **Abstract mode** — wizard takes proceeding-type + date as today. After calc, save CTA disabled (no Akte to save against); `Drucken` remains.
|
||||
|
||||
The "Verfahrensablauf einsehen" card is gone from `/tools/fristenrechner` (its purpose lives on `/tools/verfahrensablauf` now — §9).
|
||||
|
||||
Pathway B (the cascade) is **kept** as a separate entry-flow inside Akte-mode for "Etwas ist passiert" — the t-paliad-166 redesign is on-hold and we don't pre-empt it. In abstract mode Pathway B is reachable via a "Frist aufgrund Ereignis (Determinator)" link in the result panel; the cascade itself unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 4. Akte-driven auto-derivation
|
||||
|
||||
When `mode=akte&project=<uuid>`, the wizard prefills as much as it honestly can from `paliad.projects`. The rest stays empty + visible.
|
||||
|
||||
### 4.1 Mapping table
|
||||
|
||||
| Wizard input | Project source | Confidence | Behaviour |
|
||||
|---|---|---|---|
|
||||
| **proceeding_type_code** (UPC_INF, DE_INF, …) | `proceeding_types.code` via `projects.proceeding_type_id` + jurisdiction disambiguation | medium-high | Best-effort pick + the proceeding-tile picker stays visible with the picked tile pre-selected. User can flip. |
|
||||
| **trigger_date** | None today | low | Always empty. User fills. |
|
||||
| **priority_date** (EP_GRANT only) | `projects.grant_date` or `projects.filing_date` (parent patent project's filing) | low-medium | Pre-fill only when the chosen proceeding is `EP_GRANT`. Field stays visible + editable. |
|
||||
| **court_id** | `projects.court` (free text) — fuzzy match against `paliad.courts.code` | low | Pre-select if string-match is exact-or-trivial-canon (e.g. `"UPC"` → `upc-cd-...`? **No** — too ambiguous; leave blank); else leave blank. Picker visible + required for UPC where holiday calendar differs. |
|
||||
| **our_side** (perspective chip) | `projects.our_side` | high | Already wired (t-paliad-164). Predefine + show "vorgegeben durch Akte" hint. |
|
||||
| **condition_flag** (with_ccr, with_cci, with_amend) | None today | low | Stays user-driven. Flag checkboxes appear conditionally on UPC_INF/UPC_REV. |
|
||||
| **counterclaim sibling info** | `projects.counterclaim_of` | medium | If set, the result panel shows a small "Verbundenes Verfahren: <parent>" line with a deep-link to the parent's Verlauf tab. Informational only — doesn't change calc. |
|
||||
|
||||
### 4.2 Litigation → fristenrechner code mapping
|
||||
|
||||
`projects.proceeding_type_id` points to `category='litigation'` rows. The wizard wants `category='fristenrechner'`. The mapping is multi-key:
|
||||
|
||||
| litigation code | jurisdiction | resolved fristenrechner code |
|
||||
|---|---|---|
|
||||
| `INF` | UPC | `UPC_INF` (id 8) |
|
||||
| `INF` | DE | `DE_INF` (id 12) — first instance only; OLG/BGH not derivable |
|
||||
| `REV` | UPC | `UPC_REV` (id 9) |
|
||||
| `REV` | DE | `DE_NULL` (id 13) |
|
||||
| `CCR` | UPC | `UPC_REV` (id 9) + `with_cci` flag suggested |
|
||||
| `APM` | UPC | `UPC_PI` (id 10) |
|
||||
| `APP` | UPC | `UPC_APP` (id 11) |
|
||||
| `AMD` | UPC | (no direct fristenrechner code; suggest UPC_INF with `with_amend`) |
|
||||
| `ZPO_CIVIL` | DE | `DE_INF` (id 12) — fallback |
|
||||
|
||||
The jurisdiction comes from `proceeding_types.jurisdiction` (UPC / DE / EPA / DPMA) on the project's own proceeding_type row, not from `projects.country` directly (which is a different axis — country of patent, not of forum).
|
||||
|
||||
Implementation: a helper `services.ResolveFristenrechnerCodeForProject(projectID)` returning `(code, confidence, reason)` so the UI can render "Vorgeschlagen: UPC_INF (aus Akte abgeleitet — Sie können umstellen)". Where confidence is `low`, no preselect — user picks.
|
||||
|
||||
### 4.3 Court free-text — no silent FK promotion
|
||||
|
||||
`projects.court` is a free-text field. Live values include `"UPC"` (ambiguous: which division?), `"UPC CoA"` (matches `upc-coa-luxembourg`), `"LG München I"` (matches `de-lg-muenchen1`). I deliberately do NOT auto-pick a `paliad.courts.id` from this string in v1: the cost of a wrong silent pick (a holiday-calendar mismatch invalidating a calculated date) is high; the benefit of saving one click is low. The Court picker stays visible and **required** for UPC proceedings (already today's behaviour via the `isCourtDeterminedRule` check in `internal/services/fristenrechner.go:779`).
|
||||
|
||||
If the free-text value matches a canonical `paliad.courts.code` exactly (case-insensitive), we *highlight* the matching option but do not auto-select. The user clicks to confirm.
|
||||
|
||||
Follow-up ticket worth filing (out of scope here): migrate `projects.court` from text to `court_id` FK. That'd land a real auto-derivation. Until then, this design treats it as a hint.
|
||||
|
||||
### 4.4 Edge case — Akte without a proceeding_type_id
|
||||
|
||||
11 of 11 live projects today have no `proceeding_type_id` set yet. Behaviour: the wizard renders with all proceeding-type tiles selectable, no preselect, no hint. Functionally identical to abstract mode but with the Akte locked for save-CTA. No error state — silent graceful degradation.
|
||||
|
||||
---
|
||||
|
||||
## 5. Variant chips on `/tools/verfahrensablauf`
|
||||
|
||||
The new dedicated route renders proceeding-shape with the user toggling "what variant am I looking at?". Variants are the live `condition_flag` mechanism.
|
||||
|
||||
### 5.1 Variants that exist today (audited live)
|
||||
|
||||
Only **UPC_INF** (id 8) and **UPC_REV** (id 9) carry `condition_flag` rules. The flags themselves:
|
||||
|
||||
- `with_ccr` — Klägerseite, infringement claim met with revocation counterclaim. Adds `inf.def_to_ccr`, `inf.reply`, `inf.reply_def_ccr`, `inf.rejoin`, `inf.rejoin_reply_ccr` (5 rules) to UPC_INF.
|
||||
- `with_cci` — Beklagtenseite on revocation answered with infringement counterclaim. Adds `rev.cc_inf`, `rev.def_cci`, `rev.reply_def_cci`, `rev.rejoin_cci` (4 rules) to UPC_REV.
|
||||
- `with_amend` — Patent amendment proposed. Adds `inf.app_to_amend`, `inf.def_to_amend`, `inf.reply_def_amd`, `inf.rejoin_amd` to UPC_INF; `rev.app_to_amend`, `rev.def_to_amend`, `rev.reply_def_amd`, `rev.rejoin_amd` to UPC_REV. Composes with `with_ccr` / `with_cci`.
|
||||
|
||||
Every other proceeding type (DE_INF, DE_NULL, EPA_OPP, EPA_APP, EP_GRANT, DPMA_*, UPC_APP, UPC_PI, UPC_DAMAGES, UPC_DISCOVERY, UPC_COST_APPEAL, UPC_APP_ORDERS) has zero `condition_flag` rules — only one canonical timeline.
|
||||
|
||||
### 5.2 Chip set per proceeding
|
||||
|
||||
Chips are conditionally rendered based on which flags exist on the selected proceeding's `condition_flag` rule rows.
|
||||
|
||||
```
|
||||
UPC_INF: [Standard] [+ Widerklage Nichtigkeit (with_ccr)] [+ Patentänderung (with_amend)]
|
||||
UPC_REV: [Standard] [+ Verletzungs-Widerklage (with_cci)] [+ Patentänderung (with_amend)]
|
||||
DE_INF, DE_NULL, EPA_OPP, …: (no chips, single timeline)
|
||||
```
|
||||
|
||||
Chips are **toggleable** (multi-select), not radio. Each chip toggles its flag on/off; the timeline reflows. Composite combinations (`with_ccr + with_amend`) render the union of rules. Toggling all chips off renders the base proceeding (no `condition_flag` rules).
|
||||
|
||||
Future flags (court-specific, expedited) — chips are **disabled and dimmed** with a tooltip "wird noch nicht unterstützt" when the proceeding has nothing to offer. We do NOT pre-render dead chips for proceedings without variants.
|
||||
|
||||
### 5.3 Consolidated vs lane view — the toggle m asked for
|
||||
|
||||
m's example: an infringement action triggers a counterclaim for revocation. Two ways to render:
|
||||
|
||||
**Consolidated** — One timeline. CCR-related events (the `with_ccr` flag) interleave with base UPC_INF events along the same vertical timeline. Colour-coded by `primary_party` (claimant / defendant / court). This is the current behaviour when `?flags=with_ccr` is set.
|
||||
|
||||
**Lane** — Two parallel columns. Column 1 = UPC_INF base timeline. Column 2 = UPC_REV timeline (the counterclaim's own proceeding). Rules anchored on shared trigger dates align horizontally.
|
||||
|
||||
Toggle UI sits beside the variant chips:
|
||||
|
||||
```
|
||||
[Standard] [+ Widerklage] | View: ◉ Konsolidiert ○ Spalten
|
||||
```
|
||||
|
||||
In v1, the lane view is only available when the user has selected a variant that implies a *second proceeding* — i.e., `UPC_INF + with_ccr` shows UPC_INF || UPC_REV side-by-side, `UPC_REV + with_cci` shows UPC_REV || UPC_INF. Same backend data, different paint.
|
||||
|
||||
For variants that DON'T imply a second proceeding (`with_amend` alone), the lane toggle is hidden — there's only one timeline.
|
||||
|
||||
### 5.4 URL state
|
||||
|
||||
`/tools/verfahrensablauf?proceeding=UPC_INF&flags=with_ccr,with_amend&view=lane&trigger_date=2026-05-12`
|
||||
|
||||
Trigger date is optional — without it, the timeline renders with relative offsets ("+3 Monate", "+6 Wochen") instead of absolute dates. This is the "browse shape" mode. With a trigger date the timeline becomes concrete.
|
||||
|
||||
`view=consolidated` (default) or `view=lane` toggles paint.
|
||||
|
||||
---
|
||||
|
||||
## 6. Side-by-side compare
|
||||
|
||||
The second variant axis. m wants to compare *two different proceeding types* OR *two variants of the same proceeding* side-by-side.
|
||||
|
||||
### 6.1 Affordance
|
||||
|
||||
A "Vergleichen" button next to the variant chips. Click → second proceeding picker slides in, second variant-chip row appears, two timelines render side-by-side.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Verfahren A: [UPC_INF ▾] Flags: [✓ with_ccr] [ with_amend]│
|
||||
│ Verfahren B: [UPC_REV ▾] Flags: [✓ with_cci] [ with_amend]│
|
||||
│ Trigger A: [2026-05-12] Trigger B: [synced ✓] │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ │
|
||||
│ Timeline A ║ Timeline B │
|
||||
│ ┌─ Klageerhebung ║ ┌─ Nichtigkeitsklage │
|
||||
│ │ 2026-05-12 ║ │ 2026-05-12 │
|
||||
│ ├─ Klageerwiderung ║ ├─ Klageerwiderung │
|
||||
│ │ 2026-08-12 (3M) ║ │ 2026-08-12 (3M) │
|
||||
│ … │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 Decisions
|
||||
|
||||
- **Max 2 timelines for v1.** Three+ would push the layout below mobile readability and add picker friction. The `counterclaim_of` example always pairs two proceedings; that's the common case.
|
||||
- **Synchronised date axis** by default (Trigger A = Trigger B). Toggle "Unabhängige Trigger-Daten" reveals a second date input. Synced is the right default because the most common compare is "what happens in both proceedings starting from the same Klageerhebung date".
|
||||
- **Independent variant chips per timeline.** Variant A's flags don't affect Variant B. The chips render per-column.
|
||||
- **Wide-screen primary.** Lane and compare views require ≥720px to be readable. Below that, stack vertically (Timeline A above Timeline B, full-width each). The synced-trigger constraint stays; users on small screens still get the compare, just stacked.
|
||||
- **Permalink-shareable.** `?compare=1&a_proceeding=UPC_INF&a_flags=with_ccr&b_proceeding=UPC_REV&b_flags=with_cci&trigger=2026-05-12&synced=true` — every chip + variant + trigger captured in URL. Copy-paste produces an identical render.
|
||||
|
||||
### 6.3 Lane view vs Compare view — are they the same thing?
|
||||
|
||||
Conceptually similar (two columns), but UX-distinct:
|
||||
|
||||
- **Lane view** is "one variant that implies two proceedings rendered together". The two columns are *logically linked* (e.g., `UPC_INF + with_ccr` always shows the same UPC_REV alongside).
|
||||
- **Compare view** is "the user picked two arbitrary proceedings + variants to look at together". The two columns are *independently chosen*.
|
||||
|
||||
In renderer terms they share the same DOM layout (CSS grid with 2 columns). The state differs: lane view's second proceeding is computed from the variant flag; compare view's second proceeding is user-picked. We implement them as one renderer with two state-entry points.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sidebar nav labels + URL conventions
|
||||
|
||||
### 7.1 Labels (post-cleanup)
|
||||
|
||||
Today: **Fristenrechner** + **Verfahrensablauf**.
|
||||
|
||||
Recommendation: keep the labels as-is. m's brief suggested alternatives ("Frist berechnen" / "Verfahrensabläufe") — I think the current labels are tighter:
|
||||
|
||||
- "Fristenrechner" is a known brand-term in the firm vocabulary (per the German-tool-names-as-brands convention in CLAUDE.md).
|
||||
- "Verfahrensablauf" reads as a noun "the procedural flow", which matches the abstract-browse intent better than the plural "Verfahrensabläufe" (which reads as "the catalogue of all flows").
|
||||
|
||||
But I flag this for m in §13 — the call is brand-strategic, not technical.
|
||||
|
||||
### 7.2 URL conventions
|
||||
|
||||
| Route | Key params | Purpose |
|
||||
|---|---|---|
|
||||
| `/tools/fristenrechner` | `mode=akte\|abstract` | Pick branch |
|
||||
| `/tools/fristenrechner?mode=akte&project=<uuid>` | + `path=outgoing\|happened` | Akte deadline determination |
|
||||
| `/tools/fristenrechner?mode=abstract&forum=upc&proceeding=UPC_INF&trigger_date=…` | + `flags=…` | Abstract deadline determination |
|
||||
| `/tools/verfahrensablauf` | `proceeding=…&flags=…&view=…&trigger_date=…` | Browse one proceeding-shape |
|
||||
| `/tools/verfahrensablauf?compare=1&a_proceeding=…&b_proceeding=…&…` | (per §6.2) | Compare two |
|
||||
|
||||
The `?path=a` query param dies entirely. The `fixVerfahrensablaufActive` function deletes. The localStorage key `paliad.fristen.pathway` is preserved (still used by Akte-mode Pathway A/B inside `/tools/fristenrechner`); it gets a sibling `paliad.fristen.mode`.
|
||||
|
||||
### 7.3 Bookmarkability + share
|
||||
|
||||
Both pages produce permalinks. Copy URL → paste in another browser → identical view (with same auth gate). The compare-view URL is particularly load-bearing for the "send your colleague a precomputed timeline" use case — it's how a PA quickly shows a counterpart "this is the shape we're looking at".
|
||||
|
||||
---
|
||||
|
||||
## 8. Mobile + responsive
|
||||
|
||||
Existing breakpoints in the codebase: 640px / 720px / 768px / 1023px (`frontend/src/styles/global.css`).
|
||||
|
||||
### 8.1 `/tools/fristenrechner`
|
||||
|
||||
- **≥720px:** Step 0 toggle horizontal. Akte search results in a list.
|
||||
- **<720px:** Step 0 toggle stacks (radio rows top-to-bottom). Akte list full-width.
|
||||
- **<480px:** Proceeding-tile picker (UPC / DE / EPA / DPMA tabs + tiles) wraps tiles to one column.
|
||||
|
||||
### 8.2 `/tools/verfahrensablauf`
|
||||
|
||||
- **≥1023px:** Lane view + compare view render side-by-side (CSS grid 2-col).
|
||||
- **720–1022px:** Lane view side-by-side; compare view stacks (Timeline A above Timeline B, full-width).
|
||||
- **<720px:** Both lane and compare stack vertically. Variant chips wrap to 2-3 rows.
|
||||
- **<480px:** Single-column always. Compare-view "Vergleichen" button still works but stacks the result rows.
|
||||
|
||||
### 8.3 Variant chips on mobile
|
||||
|
||||
Chips wrap with `flex-wrap`. Maximum 3 chips per row on a 360px viewport (each chip ≤ 110px wide); composite proceedings (UPC_INF, UPC_REV) fit 3 chips so this works.
|
||||
|
||||
### 8.4 What does NOT collapse on mobile
|
||||
|
||||
- The trigger-date input. Stays a single date picker (browser-native; iOS / Android already render their own UI).
|
||||
- The proceeding picker. Stays tiled (large tap targets).
|
||||
- The result rows (column + timeline views). Render unchanged from today; mobile already handles them.
|
||||
|
||||
---
|
||||
|
||||
## 9. What gets dropped
|
||||
|
||||
| Today | Post-cleanup |
|
||||
|---|---|
|
||||
| **Step 2 "Verfahrensablauf einsehen" card** | Deleted. The abstract-browse case has its own route. |
|
||||
| **Sidebar `?path=a` deep-link** | Deleted. `/tools/verfahrensablauf` replaces it. |
|
||||
| **`fixVerfahrensablaufActive()` function** | Deleted. Both sidebar entries map 1:1 to URLs; native SSR active-class works. |
|
||||
| **`localStorage["paliad.fristen.pathway"]`** | Preserved as-is. Still used inside Akte-mode Pathway A/B. |
|
||||
| **The Step 1/Step 2 fork on `/tools/fristenrechner`** | Replaced by Step 0 (Akte vs Abstract). Step 2's "file vs happened vs browse" becomes a wizard-internal branch, not a top-level page state. |
|
||||
| **Step 3a "outgoing-intent chooser" (File / Draft / Enter)** | Kept inside Akte-mode. The Draft option (`fristen-step3a-draft`) stays disabled as today (placeholder). |
|
||||
|
||||
The deletions sum to maybe 200–300 LoC out of `client/fristenrechner.ts`. The lift of `verfahrensablauf-core.ts` is the bigger reshape; net LoC churn around +500 / -300.
|
||||
|
||||
---
|
||||
|
||||
## 10. Slicing for the coder pass
|
||||
|
||||
Four slices, each independently mergeable. Slice 1 ships the structural split; Slices 2–4 layer features.
|
||||
|
||||
### Slice 1 — Route + shell split (foundation)
|
||||
|
||||
- New route `/tools/verfahrensablauf` registered in `internal/handlers/handlers.go`.
|
||||
- New handler `handleVerfahrensablaufPage` serves `dist/verfahrensablauf.html`.
|
||||
- New TSX `frontend/src/verfahrensablauf.tsx` — renders the proceeding-tile picker + result panel. No variant chips yet; no compare yet. Just the abstract-browse case factored out.
|
||||
- New client `frontend/src/client/verfahrensablauf.ts` — minimal: picker → calc → render. Imports from a new shared module `client/views/verfahrensablauf-core.ts`.
|
||||
- Sidebar `Sidebar.tsx:163-164` updated: second nav entry's href flips from `/tools/fristenrechner?path=a` to `/tools/verfahrensablauf`.
|
||||
- `client/sidebar.ts:447 fixVerfahrensablaufActive` deleted (and its call site at the bottom of `initSidebar`).
|
||||
- Step 2 "Verfahrensablauf einsehen" card markup in `frontend/src/fristenrechner.tsx` + its handler in `client/fristenrechner.ts` deleted.
|
||||
- Step 2's "browse" event handler at `fristen-step2-browse` removed; the path="a" branch in `showPathway` still exists for Akte-mode wizard re-use.
|
||||
- DE/EN i18n keys: `tools.verfahrensablauf.title`, `tools.verfahrensablauf.subtitle`, plus all the proceeding-tile labels (already exist — reused).
|
||||
- Build: add `renderVerfahrensablauf` import and `bun:write` step in `frontend/build.ts`.
|
||||
- Tests: Playwright smoke — `/tools/verfahrensablauf` renders, sidebar nav links work, no 404s, the old `?path=a` URL 302s to `/tools/verfahrensablauf` (back-compat for any bookmarked links).
|
||||
|
||||
**What does NOT change in Slice 1:** the existing `/tools/fristenrechner` page works exactly as today (Step 1 / Step 2 / Step 3a / Pathway A / Pathway B). Step 0 is Slice 2.
|
||||
|
||||
### Slice 2 — Step 0 on `/tools/fristenrechner`
|
||||
|
||||
- New Step 0 toggle component in `fristenrechner.tsx` (above today's Step 1).
|
||||
- `?mode=akte|abstract` URL param + `paliad.fristen.mode` localStorage hook.
|
||||
- "Abstract" branch reveals a new compact proceeding-tile picker inside the Step 0 frame (or scrolls to today's wizard-step-1).
|
||||
- "Akte" branch renders today's Step 1 (Akte search + ad-hoc chips).
|
||||
- Akte-driven auto-derivation (§4): a new service `ResolveFristenrechnerCodeForProject(projectID)` and frontend hook that preselects the proceeding tile + `our_side` chip + Court hint (highlight only, not pre-select).
|
||||
- Tests: Playwright smoke for the four state transitions (akte → abstract, abstract → akte, akte+project → akte-no-project, deep-link `?mode=abstract&forum=upc`).
|
||||
|
||||
### Slice 3 — Variant chips + consolidated/lane view
|
||||
|
||||
- Variant-chip strip on `/tools/verfahrensablauf` (`with_ccr`, `with_cci`, `with_amend` conditional on proceeding).
|
||||
- `?flags=` URL param.
|
||||
- Lane-vs-consolidated toggle. Lane view auto-enables when the variant implies a second proceeding (UPC_INF+with_ccr → UPC_REV; UPC_REV+with_cci → UPC_INF).
|
||||
- Lane renderer in `views/verfahrensablauf-core.ts` (CSS grid 2-col, shared trigger-date axis).
|
||||
- Tests: Playwright smoke for variant toggles + lane render + lane on mobile (stack).
|
||||
|
||||
### Slice 4 — Side-by-side compare
|
||||
|
||||
- "Vergleichen" button + second-proceeding picker.
|
||||
- `?compare=1&a_proceeding=…&b_proceeding=…&…` URL state.
|
||||
- Synced-trigger toggle; independent-trigger fallback.
|
||||
- Permalink test (copy URL → fresh tab → same render).
|
||||
- Mobile fallback (stacked).
|
||||
- Tests: Playwright smoke for compare entry, both timelines render, permalink roundtrip.
|
||||
|
||||
Each slice merges to main independently. Slice 1 is the bottleneck; once it's in, Slices 2–4 can ship in any order (Slice 2 only touches `/tools/fristenrechner`, Slices 3+4 only touch `/tools/verfahrensablauf`).
|
||||
|
||||
---
|
||||
|
||||
## 11. Tradeoffs flagged
|
||||
|
||||
### 11.1 Code duplication vs route clarity
|
||||
|
||||
The split forces ~700–900 LoC of client code into a shared module (`views/verfahrensablauf-core.ts`). That's lift work without user-visible benefit. The alternative (one big page with `?mode=`) saves the lift but keeps the muddled mental model that triggered this redesign in the first place. **Decision: pay the lift cost.** It's a one-time refactor; the navigation clarity is durable.
|
||||
|
||||
### 11.2 Step 0 vs Step 1 — perceived "extra step"
|
||||
|
||||
Today's flow: Akte picker (Step 1) → choose-intent cards (Step 2) → wizard. Tomorrow's flow: mode toggle (Step 0) → Akte picker OR abstract picker → wizard. Same number of clicks for the Akte case. One *fewer* click for the abstract case (you go straight to proceeding tiles instead of clicking "Verfahrensablauf einsehen" first). Net win.
|
||||
|
||||
### 11.3 Court free-text means imperfect auto-derivation
|
||||
|
||||
We can't reliably auto-pick `court_id` from `projects.court` until that column becomes an FK. The design leans on "highlight matching options" rather than silent preselect. The cost is one extra click. **File a follow-up ticket** to migrate `projects.court` → `court_id` FK; until then, no silent FK promotion.
|
||||
|
||||
### 11.4 Pathway B (Determinator cascade) stays inside Akte-mode
|
||||
|
||||
t-paliad-166 will redesign Pathway B as a row-by-row cascade. We don't pre-empt that. Pathway B remains reachable from Akte-mode's "Etwas ist passiert" card. In Abstract mode it's reachable through a "Frist aufgrund Ereignis" link in the result panel. Both paths stay; only the entry surface changes.
|
||||
|
||||
### 11.5 Variant chips disabled for non-UPC proceedings
|
||||
|
||||
Only UPC_INF and UPC_REV have `condition_flag` rules today. DE_INF, DE_NULL, EPA_OPP, etc. show no chips. This is honest — the data isn't there. If users ask for German "with/without counterclaim" variants, that's a `condition_flag` seed-data ticket, not a UX redesign.
|
||||
|
||||
### 11.6 Lane view assumes the second proceeding exists
|
||||
|
||||
`UPC_INF + with_ccr` lanes to `UPC_REV`. But `UPC_REV` itself is a full proceeding with its own deadlines anchored on a *separate* trigger date (the CCR filing date, not the SoC date). For v1 we render the second lane with the *same trigger date* as the primary — which is wrong-but-useful: the user sees the *shape* of the counterclaim's flow but the dates are nominal. A future iteration adds a "second trigger date" input for the lane. **Document this in the UI** with a small caveat: "Annahme: Widerklage zur gleichen Zeit eingelegt".
|
||||
|
||||
### 11.7 No state preserved across the route boundary
|
||||
|
||||
If a user is mid-calc on `/tools/fristenrechner` and clicks the sidebar's `/tools/verfahrensablauf`, their wizard state is lost. We don't try to bridge the two — they're different intents. The URL captures everything important; the user can pop back via the browser back button.
|
||||
|
||||
### 11.8 Print mode is the only export
|
||||
|
||||
No PDF, no SVG, no CSV export in this design. The existing `#fristen-print-btn` + `@media print` stylesheet handles it. m's broader chart-export design (`docs/design-project-chart-2026-05-09.md`) covers the export ambition for the project-level chart; this Tool-level surface keeps it simple.
|
||||
|
||||
---
|
||||
|
||||
## 12. Files implementer will touch (Slice 1 only)
|
||||
|
||||
This is the bottleneck slice. Slices 2–4 each add their own scope but Slice 1 defines the structural change.
|
||||
|
||||
**Backend (Go):**
|
||||
|
||||
- `internal/handlers/handlers.go:162` — add `protected.HandleFunc("GET /tools/verfahrensablauf", handleVerfahrensablaufPage)`.
|
||||
- `internal/handlers/fristenrechner.go` — add `handleVerfahrensablaufPage` (1-liner, serves `dist/verfahrensablauf.html`). Or split into its own file `internal/handlers/verfahrensablauf.go` for tidiness.
|
||||
- `internal/handlers/handlers.go` — add back-compat 302: `/tools/fristenrechner?path=a` → `/tools/verfahrensablauf` (preserves bookmarked links). A small middleware or an `init` redirect handler suffices.
|
||||
|
||||
**Frontend (TSX + TS):**
|
||||
|
||||
- `frontend/src/verfahrensablauf.tsx` — new file. ~250 LoC. Renders header + jurisdiction-tab picker + proceeding-tile picker + result panel container. No variant chips, no compare yet (those are Slices 3+4). Reuses `<PWAHead>`, `<Sidebar>`, `<Footer>`.
|
||||
- `frontend/src/client/verfahrensablauf.ts` — new file. ~150 LoC for Slice 1. Wires the picker → POST `/api/tools/fristenrechner` → render via shared module.
|
||||
- `frontend/src/client/views/verfahrensablauf-core.ts` — new file. The lifted code: `renderTimelineBody`, `renderColumnsBody`, the `calculateDeadlines` fetch wrapper, court picker, view-toggle. Imported by both `client/fristenrechner.ts` and `client/verfahrensablauf.ts`.
|
||||
- `frontend/src/client/fristenrechner.ts` — delete the Step 2 "browse" card handler (lines 2715-2717 today). Remove the `?path=a` interpretation as a top-level entry (still keep `path="a"` as an Akte-mode wizard pathway). Import calc + render from `views/verfahrensablauf-core.ts`.
|
||||
- `frontend/src/fristenrechner.tsx` — delete the `fristen-step2-browse` card markup (lines 215-223 today).
|
||||
- `frontend/src/components/Sidebar.tsx:163-164` — change href from `/tools/fristenrechner?path=a` to `/tools/verfahrensablauf`. Adjust the `currentPath` comparison to match the new pathname.
|
||||
- `frontend/src/client/sidebar.ts:447 fixVerfahrensablaufActive` — delete the function + its call site.
|
||||
|
||||
**Build:**
|
||||
|
||||
- `frontend/build.ts` — add `renderVerfahrensablauf` import (line 5-6 area), add `client/verfahrensablauf.ts` to `entrypoints` array (line 228 area), add the `Bun.write(join(DIST, "verfahrensablauf.html"), renderVerfahrensablauf())` step (line 355 area).
|
||||
|
||||
**i18n:**
|
||||
|
||||
- `frontend/src/client/i18n.ts` + `i18n-keys.ts` — add `tools.verfahrensablauf.title`, `tools.verfahrensablauf.subtitle`, `nav.verfahrensablauf` (already exists; re-verify the key still points at the right label).
|
||||
|
||||
**Tests:**
|
||||
|
||||
- Playwright smoke covering: `/tools/verfahrensablauf` renders, sidebar nav link active class lights up correctly without `fixVerfahrensablaufActive`, `/tools/fristenrechner?path=a` 302s, the calc roundtrip works on both routes, build artefacts emit both `fristenrechner.html` and `verfahrensablauf.html`.
|
||||
|
||||
**Out of Slice 1 (deferred to Slices 2-4):**
|
||||
|
||||
- Step 0 toggle on `/tools/fristenrechner` (Slice 2).
|
||||
- Akte-driven auto-derivation helper service (Slice 2).
|
||||
- Variant chips, lane view (Slice 3).
|
||||
- Compare view (Slice 4).
|
||||
|
||||
---
|
||||
|
||||
## 13. Open questions for m
|
||||
|
||||
1. **Sidebar label.** Keep "Verfahrensablauf" (current) or switch to "Verfahrensabläufe" (plural — reads as catalogue) or something else? Current label is unambiguous; plural risks reading as a list page.
|
||||
|
||||
2. **Akte-mode mapping with no `proceeding_type_id`.** 11/11 live projects have NULL proceeding_type_id. Akte-mode silently degrades to "pick proceeding manually". OK? Or should Akte-mode require a proceeding_type_id and force the user to set it on the project first?
|
||||
|
||||
3. **Court free-text → FK migration.** I'm flagging this as a follow-up but not designing it here. Want me to file a separate ticket so it's tracked, or fold it into Slice 2's scope?
|
||||
|
||||
4. **Lane view caveat for v1.** The second lane uses the same trigger date as the primary (so dates are nominal-but-wrong for a real-world CCR filed weeks later). UI caveat "Annahme: Widerklage zur gleichen Zeit eingelegt" is honest but adds clutter. Acceptable or do we hold lane view back until trigger-2 input lands?
|
||||
|
||||
5. **Compare view max columns.** v1 caps at 2. Three+ would be a richer compare ("UPC_INF vs DE_INF vs EPA_OPP for the same patent") but layout-hostile on anything <1280px. Confirm 2 for v1?
|
||||
|
||||
6. **Back-compat for `?path=a`.** I propose a 302 redirect so old bookmarked URLs work. Alternative: 410 Gone (harsh) or 200-with-deprecation-banner (chatty). 302 is the conventional move; confirm?
|
||||
|
||||
7. **Drop the "Verfahrensablauf einsehen" card from Step 2 entirely** vs keep it as a deep-link shortcut to `/tools/verfahrensablauf` from inside the Fristenrechner flow? I'm proposing drop; m signals?
|
||||
|
||||
8. **DE_INF / EPA_OPP / DPMA variants.** Today no `condition_flag` rules. Future seed-data tickets (out of scope here): with/without expedited, with/without amendment for EPA opposition, etc. Want a follow-up ticket filed for the seed-data work or wait for user feedback?
|
||||
|
||||
9. **Pathway B (Determinator) entry point in Abstract mode.** I propose a small "Frist aufgrund Ereignis" link in the result panel. Or hide it entirely from abstract mode? Today Pathway B is reachable from anywhere via `?path=b`.
|
||||
|
||||
10. **Implementer choice.** I'd recommend a coder familiar with `frontend/src/client/fristenrechner.ts` for Slice 1 since the bundle split is the load-bearing risk. Curie (t-paliad-086), cronus (t-paliad-088, t-paliad-110), noether (t-paliad-165) have all touched the file. Head decides.
|
||||
|
||||
---
|
||||
|
||||
**DESIGN READY FOR REVIEW**
|
||||
|
||||
Slice 1 is the structural foundation (route split, sidebar cleanup, code lift). Slices 2-4 layer Step 0 / variant chips / compare on top. Awaiting m's go/no-go before coder shift.
|
||||
@@ -4,6 +4,7 @@ import { renderIndex } from "./src/index";
|
||||
import { renderLogin } from "./src/login";
|
||||
import { renderKostenrechner } from "./src/kostenrechner";
|
||||
import { renderFristenrechner } from "./src/fristenrechner";
|
||||
import { renderVerfahrensablauf } from "./src/verfahrensablauf";
|
||||
import { renderDownloads } from "./src/downloads";
|
||||
import { renderLinks } from "./src/links";
|
||||
import { renderGlossary } from "./src/glossary";
|
||||
@@ -15,6 +16,7 @@ import { renderCourts } from "./src/courts";
|
||||
import { renderProjects } from "./src/projects";
|
||||
import { renderProjectsNew } from "./src/projects-new";
|
||||
import { renderProjectsDetail } from "./src/projects-detail";
|
||||
import { renderProjectsChart } from "./src/projects-chart";
|
||||
import { renderEvents } from "./src/events";
|
||||
import { renderDeadlinesNew } from "./src/deadlines-new";
|
||||
import { renderDeadlinesDetail } from "./src/deadlines-detail";
|
||||
@@ -234,6 +236,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/login.ts"),
|
||||
join(import.meta.dir, "src/client/kostenrechner.ts"),
|
||||
join(import.meta.dir, "src/client/fristenrechner.ts"),
|
||||
join(import.meta.dir, "src/client/verfahrensablauf.ts"),
|
||||
join(import.meta.dir, "src/client/downloads.ts"),
|
||||
join(import.meta.dir, "src/client/links.ts"),
|
||||
join(import.meta.dir, "src/client/glossary.ts"),
|
||||
@@ -245,6 +248,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/projects.ts"),
|
||||
join(import.meta.dir, "src/client/projects-new.ts"),
|
||||
join(import.meta.dir, "src/client/projects-detail.ts"),
|
||||
join(import.meta.dir, "src/client/projects-chart.ts"),
|
||||
join(import.meta.dir, "src/client/events.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-new.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-detail.ts"),
|
||||
@@ -354,6 +358,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
|
||||
await Bun.write(join(DIST, "kostenrechner.html"), renderKostenrechner());
|
||||
await Bun.write(join(DIST, "fristenrechner.html"), renderFristenrechner());
|
||||
await Bun.write(join(DIST, "verfahrensablauf.html"), renderVerfahrensablauf());
|
||||
await Bun.write(join(DIST, "downloads.html"), renderDownloads());
|
||||
await Bun.write(join(DIST, "links.html"), renderLinks());
|
||||
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
|
||||
@@ -365,6 +370,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "projects.html"), renderProjects());
|
||||
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
|
||||
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
|
||||
await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart());
|
||||
// t-paliad-115 — shared EventsPage at the canonical /events URL.
|
||||
// One HTML output; defaultType="all" baked in. Sidebar Fristen /
|
||||
// Termine entries point at /events?type=… and events.ts re-highlights
|
||||
|
||||
@@ -1,67 +1,27 @@
|
||||
// Fristenrechner client-side logic
|
||||
// 3-step wizard: select proceeding -> enter date -> view timeline
|
||||
//
|
||||
// Rendering primitives (renderTimelineBody / renderColumnsBody /
|
||||
// deadlineCardHtml / formatDate / partyBadge / court picker) live in
|
||||
// `./views/verfahrensablauf-core` and are shared with the
|
||||
// /tools/verfahrensablauf page (t-paliad-179 Slice 1). This module owns
|
||||
// the Step1/2/3a wizard, Pathway A/B, Akte save flow, anchor-override
|
||||
// click-to-edit — none of which Verfahrensablauf wants.
|
||||
|
||||
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
interface AdjustmentHoliday {
|
||||
Date: string;
|
||||
Name: string;
|
||||
IsVacation: boolean;
|
||||
IsClosure: boolean;
|
||||
}
|
||||
|
||||
interface AdjustmentReason {
|
||||
kind: "weekend" | "public_holiday" | "vacation";
|
||||
holidays?: AdjustmentHoliday[];
|
||||
vacation_name?: string;
|
||||
vacation_start?: string;
|
||||
vacation_end?: string;
|
||||
original_weekday?: string;
|
||||
}
|
||||
|
||||
interface CalculatedDeadline {
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
party: string;
|
||||
isMandatory: boolean;
|
||||
ruleRef: string;
|
||||
legalSource?: string;
|
||||
notes?: string;
|
||||
notesEN?: string;
|
||||
dueDate: string;
|
||||
originalDate: string;
|
||||
wasAdjusted: boolean;
|
||||
adjustmentReason?: AdjustmentReason;
|
||||
isRootEvent: boolean;
|
||||
isCourtSet: boolean;
|
||||
// True when isCourtSet is "unbestimmt" — the rule chains off a
|
||||
// court-determined parent (e.g. RoP.151 = 1 Monat ab
|
||||
// Hauptentscheidung) rather than being itself court-set. The UI
|
||||
// renders "unbestimmt" instead of "wird vom Gericht bestimmt".
|
||||
isCourtSetIndirect?: boolean;
|
||||
// True when the deadline is conditional on a user act (filing a
|
||||
// cost-decision request, choosing to appeal, etc.). Pre-unchecked
|
||||
// in the save modal so the user must opt in.
|
||||
isOptional?: boolean;
|
||||
isOverridden?: boolean;
|
||||
}
|
||||
|
||||
interface DeadlineResponse {
|
||||
proceedingType: string;
|
||||
proceedingName: string;
|
||||
triggerDate: string;
|
||||
deadlines: CalculatedDeadline[];
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
claimant: "party-claimant",
|
||||
defendant: "party-defendant",
|
||||
court: "party-court",
|
||||
both: "party-both",
|
||||
};
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
type DeadlineResponse,
|
||||
calculateDeadlines,
|
||||
escAttr,
|
||||
escHtml,
|
||||
formatDate,
|
||||
populateCourtPicker as populateCourtPickerCore,
|
||||
renderColumnsBody,
|
||||
renderTimelineBody,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
@@ -106,92 +66,29 @@ onLangChange(() => {
|
||||
}
|
||||
});
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return "\u2014";
|
||||
const d = new Date(dateStr + "T00:00:00");
|
||||
if (getLang() === "en") {
|
||||
// ISO date (YYYY-MM-DD) \u2014 unambiguous for both US and intl readers, since
|
||||
// en-GB renders dd/mm/yyyy which US users misread as mm/dd/yyyy.
|
||||
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${weekday}, ${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
return d.toLocaleDateString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
// formatDate / partyBadge / formatDateSpan / localizeVacationName /
|
||||
// localizeWeekday / renderAdjustmentReason / formatAdjustedNote moved to
|
||||
// ./views/verfahrensablauf-core so /tools/verfahrensablauf can share them.
|
||||
// (t-paliad-179 Slice 1)
|
||||
|
||||
function partyBadge(party: string): string {
|
||||
const cls = PARTY_CLASS[party] || "party-both";
|
||||
return `<span class="party-badge ${cls}">${tDyn("deadlines.party." + party)}</span>`;
|
||||
}
|
||||
|
||||
// Short date span like "27.7.–28.8." (DE) or "27 Jul – 28 Aug" (EN). Used in
|
||||
// the vacation adjustment label, where the explicit weekday + year would
|
||||
// just be noise — the surrounding sentence carries the full year via the
|
||||
// dueDate / originalDate that the note brackets.
|
||||
function formatDateSpan(startISO: string, endISO: string): string {
|
||||
const start = new Date(startISO + "T00:00:00");
|
||||
const end = new Date(endISO + "T00:00:00");
|
||||
if (getLang() === "en") {
|
||||
const fmt = (d: Date) => d.toLocaleDateString("en-US", { day: "numeric", month: "short" });
|
||||
return `${fmt(start)} – ${fmt(end)}`;
|
||||
}
|
||||
const fmt = (d: Date) => `${d.getDate()}.${d.getMonth() + 1}.`;
|
||||
return `${fmt(start)}–${fmt(end)}`;
|
||||
}
|
||||
|
||||
// Vacation names come straight from paliad.holidays (e.g. "UPC judicial
|
||||
// vacation"). The Fristenrechner doesn't translate them: they're proper
|
||||
// names of court-set closures, not generic strings, and rotating them via
|
||||
// i18n.ts duplicates state that should live in the DB. Rename in the seed
|
||||
// if the wording needs to change.
|
||||
function localizeVacationName(name: string): string {
|
||||
return name;
|
||||
}
|
||||
|
||||
function localizeWeekday(en: string): string {
|
||||
if (en === "Saturday") return t("deadlines.adjusted.weekend.saturday");
|
||||
if (en === "Sunday") return t("deadlines.adjusted.weekend.sunday");
|
||||
return en;
|
||||
}
|
||||
|
||||
// Backend-shaped reason → human-readable phrase ("UPC-Gerichtsferien
|
||||
// (27.7.–28.8.)" / "Karfreitag holiday" / "Wochenende"). See t-paliad-119.
|
||||
function renderAdjustmentReason(r: AdjustmentReason): string {
|
||||
if (r.kind === "vacation" && r.vacation_name && r.vacation_start && r.vacation_end) {
|
||||
const span = formatDateSpan(r.vacation_start, r.vacation_end);
|
||||
return tDyn("deadlines.adjusted.vacation")
|
||||
.replace("{name}", localizeVacationName(r.vacation_name))
|
||||
.replace("{span}", span);
|
||||
}
|
||||
if (r.kind === "public_holiday" && r.holidays && r.holidays.length > 0) {
|
||||
return tDyn("deadlines.adjusted.holiday").replace("{name}", r.holidays[0].Name);
|
||||
}
|
||||
if (r.kind === "weekend" && r.original_weekday) {
|
||||
return localizeWeekday(r.original_weekday);
|
||||
}
|
||||
return t("deadlines.adjusted.weekend");
|
||||
}
|
||||
|
||||
// "Verschoben wegen X: A → B" (DE) / "Shifted (X): A → B" (EN). Falls back
|
||||
// to the legacy "Wochenende/Feiertag" string when the backend hasn't sent a
|
||||
// structured reason — keeps older API responses readable.
|
||||
function formatAdjustedNote(dl: CalculatedDeadline): string {
|
||||
const arrow = `${formatDate(dl.originalDate)} → ${formatDate(dl.dueDate)}`;
|
||||
const reason = dl.adjustmentReason
|
||||
? renderAdjustmentReason(dl.adjustmentReason)
|
||||
: t("deadlines.adjusted.reason");
|
||||
if (getLang() === "en") {
|
||||
return `${t("deadlines.adjusted")} (${reason}): ${arrow}`;
|
||||
}
|
||||
return `${t("deadlines.adjusted")} wegen ${reason}: ${arrow}`;
|
||||
}
|
||||
|
||||
let selectedType = "";
|
||||
|
||||
@@ -247,35 +144,19 @@ async function calculate() {
|
||||
? courtPicker.value
|
||||
: "";
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/tools/fristenrechner", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
priorityDate: priorityDate || undefined,
|
||||
flags: flags.length > 0 ? flags : undefined,
|
||||
anchorOverrides: Object.keys(overrides).length > 0 ? overrides : undefined,
|
||||
courtId: courtId || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (seq !== procCalcSeq) return;
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
console.error("API error:", err);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: DeadlineResponse = await resp.json();
|
||||
if (seq !== procCalcSeq) return;
|
||||
lastResponse = data;
|
||||
renderProcedureResults(data);
|
||||
showStep(3);
|
||||
} catch (e) {
|
||||
console.error("Fetch error:", e);
|
||||
}
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
priorityDate,
|
||||
flags,
|
||||
anchorOverrides: overrides,
|
||||
courtId,
|
||||
});
|
||||
if (seq !== procCalcSeq) return;
|
||||
if (!data) return;
|
||||
lastResponse = data;
|
||||
renderProcedureResults(data);
|
||||
showStep(3);
|
||||
}
|
||||
|
||||
interface ProjectOption {
|
||||
@@ -296,16 +177,6 @@ interface ProjectOption {
|
||||
our_side?: "claimant" | "defendant" | "court" | "both" | null;
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
async function fetchProjects(): Promise<ProjectOption[]> {
|
||||
try {
|
||||
const resp = await fetch("/api/projects");
|
||||
@@ -500,8 +371,8 @@ function renderProcedureResults(data: DeadlineResponse) {
|
||||
</div>`;
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data)
|
||||
: renderTimelineBody(data);
|
||||
? renderColumnsBody(data, { editable: true })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true });
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
printBtn.style.display = "block";
|
||||
@@ -572,186 +443,8 @@ function openInlineDateEditor(span: HTMLElement) {
|
||||
if (editor.value) editor.select();
|
||||
}
|
||||
|
||||
function deadlineCardHtml(dl: CalculatedDeadline, opts: { showParty: boolean }): string {
|
||||
// Click-to-edit on dated rows + court-set placeholders: lets the user
|
||||
// override the calculated date (e.g. court extended the deadline) or
|
||||
// fill in a court-set decision date once known. Downstream rules
|
||||
// re-anchor on the override via anchorOverrides → /api/tools/fristenrechner.
|
||||
// Root-event rows (the trigger anchor itself) are NOT editable — the
|
||||
// trigger date input is the canonical place to change that.
|
||||
const editable = !dl.isRootEvent && dl.code !== "";
|
||||
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
|
||||
const editAttrs = editable
|
||||
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
|
||||
: "";
|
||||
// "wird vom Gericht bestimmt" only fits direct court-set rules
|
||||
// (Urteil / Beschluss / Anordnung). Indirect rules (chained off a
|
||||
// court-set parent, e.g. RoP.151) render "unbestimmt" instead — the
|
||||
// date isn't directly determined by the court, it's derived from
|
||||
// the parent's date that the court will set. m's 2026-05-08 call.
|
||||
const courtLabelKey = dl.isCourtSetIndirect
|
||||
? "deadlines.court.indirect"
|
||||
: "deadlines.court.set";
|
||||
const dateStr = dl.isCourtSet
|
||||
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
|
||||
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
|
||||
|
||||
const mandatoryBadge = dl.isMandatory
|
||||
? ""
|
||||
: '<span class="optional-badge">optional</span>';
|
||||
|
||||
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
|
||||
|
||||
const adjustedNote = dl.wasAdjusted
|
||||
? `<div class="timeline-adjusted">\u26a0 ${formatAdjustedNote(dl)}</div>`
|
||||
: "";
|
||||
|
||||
const ruleRef = dl.ruleRef
|
||||
? `<span class="timeline-rule">${dl.ruleRef}</span>`
|
||||
: "";
|
||||
|
||||
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
const notes = noteText
|
||||
? `<div class="timeline-notes">${noteText}</div>`
|
||||
: "";
|
||||
|
||||
const meta = (opts.showParty || ruleRef)
|
||||
? `<div class="timeline-meta">
|
||||
${opts.showParty ? partyBadge(dl.party) : ""}
|
||||
${ruleRef}
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
return `<div class="timeline-item-header">
|
||||
<span class="timeline-name">
|
||||
${dlName}
|
||||
${mandatoryBadge}
|
||||
</span>
|
||||
${dateStr}
|
||||
</div>
|
||||
${meta}
|
||||
${adjustedNote}
|
||||
${notes}`;
|
||||
}
|
||||
|
||||
function renderTimelineBody(data: DeadlineResponse): string {
|
||||
let html = '<div class="timeline">';
|
||||
for (const dl of data.deadlines) {
|
||||
html += `
|
||||
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
|
||||
<div class="timeline-dot-col">
|
||||
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
|
||||
<div class="timeline-line"></div>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
${deadlineCardHtml(dl, { showParty: true })}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
}
|
||||
|
||||
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
|
||||
// (defendant). Each grid row corresponds to a distinct dueDate, so events on
|
||||
// the same day line up across columns. Deadlines with party=both render in
|
||||
// BOTH the Proactive and Reactive cells of their row with a "beide Seiten"
|
||||
// caption so the duplication is legible as intentional. Undated events
|
||||
// (Urteil, Beschluss, court-set placeholders) trail the dated rows; each
|
||||
// gets its own row in the backend's sequence_order so e.g. Urteil precedes
|
||||
// Berufungseinlegung visually instead of stacking in one bucket.
|
||||
function renderColumnsBody(data: DeadlineResponse): string {
|
||||
type Cell = CalculatedDeadline[];
|
||||
type Row = { proactive: Cell; court: Cell; reactive: Cell };
|
||||
|
||||
const UNSCHEDULED_PREFIX = "__unscheduled__";
|
||||
const rowsMap = new Map<string, Row>();
|
||||
const ensureRow = (key: string): Row => {
|
||||
let r = rowsMap.get(key);
|
||||
if (!r) {
|
||||
r = { proactive: [], court: [], reactive: [] };
|
||||
rowsMap.set(key, r);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
data.deadlines.forEach((dl, idx) => {
|
||||
// Dated rows share a row by date; undated rows each get their own row,
|
||||
// keyed by index so the backend's sequence_order is preserved in the
|
||||
// dateless tail.
|
||||
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
|
||||
const row = ensureRow(key);
|
||||
switch (dl.party) {
|
||||
case "claimant":
|
||||
row.proactive.push(dl);
|
||||
break;
|
||||
case "defendant":
|
||||
row.reactive.push(dl);
|
||||
break;
|
||||
case "court":
|
||||
row.court.push(dl);
|
||||
break;
|
||||
case "both":
|
||||
// Mirrored: same card lands in Proactive AND Reactive at this date.
|
||||
row.proactive.push(dl);
|
||||
row.reactive.push(dl);
|
||||
break;
|
||||
default:
|
||||
// Unknown party: keep visible by parking in the Court column.
|
||||
row.court.push(dl);
|
||||
}
|
||||
});
|
||||
|
||||
// Dated keys (YYYY-MM-DD) sort chronologically by lexicographic compare.
|
||||
// Unscheduled keys carry the sequence-order index in their padded suffix
|
||||
// so they likewise sort by source order. Concatenate so the dateless tail
|
||||
// sits below the dated rows.
|
||||
const datedKeys: string[] = [];
|
||||
const unscheduledKeys: string[] = [];
|
||||
for (const k of rowsMap.keys()) {
|
||||
if (k.startsWith(UNSCHEDULED_PREFIX)) unscheduledKeys.push(k);
|
||||
else datedKeys.push(k);
|
||||
}
|
||||
datedKeys.sort();
|
||||
unscheduledKeys.sort();
|
||||
const keys = [...datedKeys, ...unscheduledKeys];
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
|
||||
}
|
||||
const cards = items
|
||||
.map((dl) => {
|
||||
const mirrorTag = dl.party === "both"
|
||||
? `<div class="fr-col-mirror">\u2194 ${escHtml(t("deadlines.party.both.label"))}</div>`
|
||||
: "";
|
||||
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
|
||||
${deadlineCardHtml(dl, { showParty: false })}
|
||||
${mirrorTag}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="fr-col-cell">${cards}</div>`;
|
||||
};
|
||||
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
|
||||
|
||||
for (const key of keys) {
|
||||
const row = rowsMap.get(key)!;
|
||||
html += renderCell(row.proactive);
|
||||
html += renderCell(row.court);
|
||||
html += renderCell(row.reactive);
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
}
|
||||
// deadlineCardHtml / renderTimelineBody / renderColumnsBody moved to
|
||||
// ./views/verfahrensablauf-core (t-paliad-179 Slice 1).
|
||||
|
||||
function reset() {
|
||||
selectedType = "";
|
||||
@@ -812,7 +505,7 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
if (revCciRow) revCciRow.style.display = selectedType === "UPC_REV" ? "" : "none";
|
||||
|
||||
syncInfAmendEnabled();
|
||||
populateCourtPicker(selectedType);
|
||||
populateCourtPickerCore("court-picker-row", "court-picker", selectedType);
|
||||
|
||||
// Hide the four group blocks; show the compact summary in their place.
|
||||
setProceedingPickerCollapsed(true, name);
|
||||
@@ -821,99 +514,9 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
scheduleProcCalc(0);
|
||||
}
|
||||
|
||||
// Court picker — t-paliad-122. Visible only for proceeding types that can
|
||||
// land in multiple courts with different holiday calendars (today: every
|
||||
// UPC-flavoured proceeding type, since UPC LDs span DE/FR/IT/NL/BE/FI/PT/
|
||||
// AT/SI/DK + Stockholm RD + 3 CD seats). For DE-only proceedings (DE_NULL,
|
||||
// DE_NULL_BGH, DE_INF_BGH, DPMA_*, EPA_*, EP_GRANT) the court is fixed by
|
||||
// the proceeding type — no picker, server resolves the default.
|
||||
//
|
||||
// The picker calls /api/tools/courts?courtType=UPC-LD on first need and
|
||||
// caches the response per-type. Defaulting to upc-ld-muenchen matches HLC's
|
||||
// most common venue and keeps current behaviour for users who don't choose.
|
||||
interface CourtRow {
|
||||
id: string;
|
||||
code: string;
|
||||
nameDE: string;
|
||||
nameEN: string;
|
||||
country: string;
|
||||
regime?: string;
|
||||
courtType: string;
|
||||
}
|
||||
|
||||
const courtCache = new Map<string, CourtRow[]>();
|
||||
|
||||
function courtTypesFor(proceedingType: string): string[] {
|
||||
// Map proceeding code to compatible court types. UPC proceedings → UPC-LD
|
||||
// (most common); appeals → UPC-CoA; central-division revocations → UPC-CD.
|
||||
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
|
||||
return ["UPC-CoA"];
|
||||
}
|
||||
if (proceedingType === "UPC_REV") {
|
||||
return ["UPC-CD", "UPC-LD"]; // CD is the default revocation forum, LD when joined with infringement
|
||||
}
|
||||
if (proceedingType.startsWith("UPC_")) {
|
||||
return ["UPC-LD"];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function defaultCourtFor(proceedingType: string): string {
|
||||
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
|
||||
return "upc-coa-luxembourg";
|
||||
}
|
||||
if (proceedingType === "UPC_REV") {
|
||||
return "upc-cd-paris";
|
||||
}
|
||||
return "upc-ld-muenchen";
|
||||
}
|
||||
|
||||
async function fetchCourts(courtType: string): Promise<CourtRow[]> {
|
||||
if (courtCache.has(courtType)) return courtCache.get(courtType)!;
|
||||
try {
|
||||
const resp = await fetch(`/api/tools/courts?courtType=${encodeURIComponent(courtType)}`);
|
||||
if (!resp.ok) return [];
|
||||
const rows = (await resp.json()) as CourtRow[];
|
||||
courtCache.set(courtType, rows);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function populateCourtPicker(proceedingType: string): Promise<void> {
|
||||
const row = document.getElementById("court-picker-row");
|
||||
const select = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
if (!row || !select) return;
|
||||
|
||||
const types = courtTypesFor(proceedingType);
|
||||
if (types.length === 0) {
|
||||
row.style.display = "none";
|
||||
select.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Load all compatible court types and concatenate (CD before LD for REV).
|
||||
const lists = await Promise.all(types.map(t => fetchCourts(t)));
|
||||
const courts = lists.flat();
|
||||
if (courts.length <= 1) {
|
||||
// Single compatible court — no point asking the user. Server's
|
||||
// jurisdiction default lands the same place.
|
||||
row.style.display = "none";
|
||||
select.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const lang = getLang();
|
||||
const defaultID = defaultCourtFor(proceedingType);
|
||||
select.innerHTML = courts.map(c => {
|
||||
const name = lang === "en" ? c.nameEN : c.nameDE;
|
||||
return `<option value="${escAttr(c.id)}"${c.id === defaultID ? " selected" : ""}>${escHtml(name)}</option>`;
|
||||
}).join("");
|
||||
row.style.display = "";
|
||||
}
|
||||
|
||||
// inf-amend-flag is only meaningful when ccr-flag is on (R.30 application
|
||||
// Court-picker primitives (CourtRow / courtCache / courtTypesFor /
|
||||
// defaultCourtFor / fetchCourts / populateCourtPicker) moved to
|
||||
// ./views/verfahrensablauf-core (t-paliad-179 Slice 1).
|
||||
// is filed within the Defence to CCR). When ccr-flag flips off, also
|
||||
// untick inf-amend-flag so the calc payload stays coherent.
|
||||
function syncInfAmendEnabled() {
|
||||
@@ -2709,12 +2312,9 @@ function initPathwayFork() {
|
||||
document.getElementById("fristen-step2-happened")?.addEventListener("click", () => {
|
||||
navigateToPathway("b", "tree");
|
||||
});
|
||||
// t-paliad-168 — Verfahrensablauf einsehen (browse / learn). Drops
|
||||
// straight into Pathway A's proceeding-tile picker. The save CTA
|
||||
// disables itself in this mode (see isBrowseOrAdhocMode below).
|
||||
document.getElementById("fristen-step2-browse")?.addEventListener("click", () => {
|
||||
navigateToPathway("a");
|
||||
});
|
||||
// t-paliad-179 Slice 1: the "Verfahrensablauf einsehen" Step 2 card
|
||||
// has been retired — the abstract-browse intent lives on its own
|
||||
// route at /tools/verfahrensablauf now. No third-card handler here.
|
||||
|
||||
// Step 3a cards — File / Draft / Enter. File drops into the existing
|
||||
// Pathway A wizard; Enter routes to the manual-create form;
|
||||
|
||||
@@ -198,6 +198,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.title": "Fristenrechner \u2014 Paliad",
|
||||
"deadlines.heading": "Fristenrechner",
|
||||
"deadlines.subtitle": "Berechnung von Verfahrensfristen f\u00fcr UPC-, deutsche und EPA-Verfahren.",
|
||||
|
||||
// Verfahrensablauf (t-paliad-179 Slice 1)
|
||||
"tools.verfahrensablauf.title": "Verfahrensablauf \u2014 Paliad",
|
||||
"tools.verfahrensablauf.heading": "Verfahrensablauf",
|
||||
"tools.verfahrensablauf.subtitle": "Typischen Verfahrensablauf einsehen \u2014 Verfahrensart w\u00e4hlen, Datum optional setzen.",
|
||||
|
||||
"deadlines.step1": "Verfahrensart w\u00e4hlen",
|
||||
"deadlines.step2": "Ausgangsdatum eingeben",
|
||||
"deadlines.step3": "Ergebnis",
|
||||
@@ -1150,6 +1156,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.back": "\u2190 Zur\u00fcck zur \u00dcbersicht",
|
||||
"projects.detail.loading": "L\u00e4dt\u2026",
|
||||
"projects.detail.notfound": "Projekt nicht gefunden oder keine Berechtigung.",
|
||||
"projects.detail.smarttimeline.open_chart": "Als Chart anzeigen \u2197",
|
||||
"projects.chart.title": "Projekt-Chart \u2014 Paliad",
|
||||
"projects.chart.back": "\u2190 Zur\u00fcck zum Projekt",
|
||||
"projects.chart.loading": "L\u00e4dt\u2026",
|
||||
"projects.chart.notfound": "Projekt nicht gefunden oder keine Berechtigung.",
|
||||
"projects.chart.error.mount": "Chart konnte nicht initialisiert werden.",
|
||||
"projects.chart.control.layout.horizontal": "Layout: Horizontal",
|
||||
"projects.chart.control.columns.auto": "Spalten: Auto",
|
||||
"projects.chart.control.density.standard": "Dichte: Standard",
|
||||
"projects.chart.control.palette.default": "Palette: Standard",
|
||||
"projects.chart.control.export.soon": "Export \u2193 (Slice 2)",
|
||||
"projects.detail.edit": "Bearbeiten",
|
||||
"projects.detail.edit.modal.title": "Projekt bearbeiten",
|
||||
"projects.detail.save": "Speichern",
|
||||
@@ -2501,6 +2518,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.title": "Deadline Calculator \u2014 Paliad",
|
||||
"deadlines.heading": "Patent Deadline Calculator",
|
||||
"deadlines.subtitle": "Calculate procedural deadlines for UPC, German, and EPA proceedings.",
|
||||
|
||||
// Verfahrensablauf (t-paliad-179 Slice 1)
|
||||
"tools.verfahrensablauf.title": "Procedure Roadmap \u2014 Paliad",
|
||||
"tools.verfahrensablauf.heading": "Procedure Roadmap",
|
||||
"tools.verfahrensablauf.subtitle": "Browse the typical proceeding shape \u2014 pick a proceeding type, optionally set a trigger date.",
|
||||
|
||||
"deadlines.step1": "Select Proceeding Type",
|
||||
"deadlines.step2": "Enter Trigger Date",
|
||||
"deadlines.step3": "Result",
|
||||
@@ -3441,6 +3464,17 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.back": "\u2190 Back to overview",
|
||||
"projects.detail.loading": "Loading\u2026",
|
||||
"projects.detail.notfound": "Project not found or no access.",
|
||||
"projects.detail.smarttimeline.open_chart": "View as chart \u2197",
|
||||
"projects.chart.title": "Project Chart \u2014 Paliad",
|
||||
"projects.chart.back": "\u2190 Back to project",
|
||||
"projects.chart.loading": "Loading\u2026",
|
||||
"projects.chart.notfound": "Project not found or no access.",
|
||||
"projects.chart.error.mount": "Chart could not be initialised.",
|
||||
"projects.chart.control.layout.horizontal": "Layout: horizontal",
|
||||
"projects.chart.control.columns.auto": "Columns: auto",
|
||||
"projects.chart.control.density.standard": "Density: standard",
|
||||
"projects.chart.control.palette.default": "Palette: default",
|
||||
"projects.chart.control.export.soon": "Export \u2193 (Slice 2)",
|
||||
"projects.detail.edit": "Edit",
|
||||
"projects.detail.edit.modal.title": "Edit project",
|
||||
"projects.detail.save": "Save",
|
||||
|
||||
111
frontend/src/client/projects-chart.ts
Normal file
111
frontend/src/client/projects-chart.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { mount, type ChartHandle } from "./views/shape-timeline-chart";
|
||||
|
||||
// t-paliad-177 Slice 1 — boot client for the standalone Project Timeline
|
||||
// / Chart page. Reads the project id from the URL path, loads the
|
||||
// project metadata (for title + breadcrumb), mounts the SVG renderer
|
||||
// inside #projects-chart-host. Slice 1 keeps the controls inert; Slice 3
|
||||
// wires density / palette / zoom against this same surface.
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string;
|
||||
client_matter?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const PROJECT_ID_RE = /^\/projects\/([0-9a-fA-F-]{36})\/chart\/?$/;
|
||||
|
||||
function projectIdFromPath(): string | null {
|
||||
const match = PROJECT_ID_RE.exec(window.location.pathname);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function loadProject(id: string): Promise<Project | null> {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`);
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as Project;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatMeta(p: Project): string {
|
||||
const parts: string[] = [];
|
||||
if (p.reference) parts.push(p.reference);
|
||||
if (p.client_matter) parts.push(p.client_matter);
|
||||
return parts.join(" • ");
|
||||
}
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
const loadingEl = document.getElementById("projects-chart-loading");
|
||||
const notfoundEl = document.getElementById("projects-chart-notfound");
|
||||
const bodyEl = document.getElementById("projects-chart-body");
|
||||
const titleEl = document.getElementById("projects-chart-title");
|
||||
const metaEl = document.getElementById("projects-chart-meta");
|
||||
const backLink = document.getElementById("projects-chart-back-link") as HTMLAnchorElement | null;
|
||||
const host = document.getElementById("projects-chart-host");
|
||||
const undatedHint = document.getElementById("projects-chart-undated");
|
||||
|
||||
const id = projectIdFromPath();
|
||||
if (!id || !host || !bodyEl || !loadingEl || !notfoundEl) {
|
||||
if (loadingEl) loadingEl.style.display = "none";
|
||||
if (notfoundEl) notfoundEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
const project = await loadProject(id);
|
||||
if (!project) {
|
||||
loadingEl.style.display = "none";
|
||||
notfoundEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
// Wire back-link to the project's detail page.
|
||||
if (backLink) backLink.href = `/projects/${encodeURIComponent(id)}`;
|
||||
|
||||
if (titleEl) titleEl.textContent = project.title || t("projects.chart.title");
|
||||
if (metaEl) metaEl.textContent = formatMeta(project);
|
||||
|
||||
loadingEl.style.display = "none";
|
||||
bodyEl.style.display = "";
|
||||
|
||||
let handle: ChartHandle | null = null;
|
||||
try {
|
||||
handle = mount(host, { projectId: id });
|
||||
} catch (err) {
|
||||
console.error("chart mount failed", err);
|
||||
host.textContent = t("projects.chart.error.mount");
|
||||
return;
|
||||
}
|
||||
|
||||
// After the first paint, surface the undated hint when the renderer
|
||||
// reports clipped/undated rows. Re-checked on resize-debounced repaint.
|
||||
const checkUndated = () => {
|
||||
if (!undatedHint || !handle) return;
|
||||
const layout = handle.getLayout();
|
||||
if (!layout) return;
|
||||
if (layout.undatedCount > 0) {
|
||||
undatedHint.style.display = "";
|
||||
undatedHint.textContent = `${layout.undatedCount} Ereignis(se) ohne Datum (links angeheftet).`;
|
||||
} else {
|
||||
undatedHint.style.display = "none";
|
||||
}
|
||||
};
|
||||
// Poll once after the initial fetch settles. mount() kicks the fetch
|
||||
// synchronously; layout becomes available after the network round-trip.
|
||||
setTimeout(checkUndated, 400);
|
||||
setTimeout(checkUndated, 1500);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
void boot();
|
||||
});
|
||||
@@ -1086,6 +1086,14 @@ function renderHeader() {
|
||||
(document.getElementById("project-title-display") as HTMLElement).textContent = project.title;
|
||||
(document.getElementById("project-ref-display") as HTMLElement).textContent = project.reference || "";
|
||||
|
||||
// t-paliad-177 — link from Verlauf header to standalone chart page.
|
||||
// Wired here (not in the TSX shell) because we need the resolved
|
||||
// project id, which only exists after the detail fetch settles.
|
||||
const chartLink = document.getElementById("smart-timeline-open-chart") as HTMLAnchorElement | null;
|
||||
if (chartLink) {
|
||||
chartLink.href = `/projects/${encodeURIComponent(project.id)}/chart`;
|
||||
}
|
||||
|
||||
const descDisplay = document.getElementById("project-description-display") as HTMLElement;
|
||||
const description = project.description ?? "";
|
||||
descDisplay.textContent = description;
|
||||
|
||||
@@ -75,7 +75,6 @@ export function initSidebar() {
|
||||
initPaliadinLinks();
|
||||
initUserViewsGroup();
|
||||
initThemeToggle();
|
||||
fixVerfahrensablaufActive();
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
if (!sidebar) return;
|
||||
initSidebarResize(sidebar);
|
||||
@@ -444,29 +443,10 @@ function initUserViewsGroup(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// fixVerfahrensablaufActive disambiguates the two /tools/fristenrechner
|
||||
// sidebar entries (t-paliad-168). The SSR navItem helper compares
|
||||
// hrefs against pathname only, which can't tell ?path=a apart from
|
||||
// the no-query Fristenrechner — both would render as Fristenrechner=
|
||||
// active. At the client we know the search params; flip the active
|
||||
// class so the sidebar lights up the entry the user actually opened.
|
||||
function fixVerfahrensablaufActive(): void {
|
||||
if (window.location.pathname !== "/tools/fristenrechner") return;
|
||||
const path = new URLSearchParams(window.location.search).get("path");
|
||||
const fristenrechner = document.querySelector<HTMLAnchorElement>(
|
||||
'a.sidebar-item[href="/tools/fristenrechner"]',
|
||||
);
|
||||
const verfahrensablauf = document.querySelector<HTMLAnchorElement>(
|
||||
'a.sidebar-item[href="/tools/fristenrechner?path=a"]',
|
||||
);
|
||||
if (path === "a") {
|
||||
fristenrechner?.classList.remove("active");
|
||||
verfahrensablauf?.classList.add("active");
|
||||
} else {
|
||||
verfahrensablauf?.classList.remove("active");
|
||||
fristenrechner?.classList.add("active");
|
||||
}
|
||||
}
|
||||
// fixVerfahrensablaufActive removed (t-paliad-179 Slice 1). The two
|
||||
// sidebar entries now map 1:1 to distinct URLs (/tools/fristenrechner
|
||||
// vs /tools/verfahrensablauf), so the SSR navItem helper picks the
|
||||
// correct active class by pathname alone.
|
||||
|
||||
function renderUserViewItem(view: UserViewLite, currentPath: string): HTMLElement {
|
||||
const a = document.createElement("a");
|
||||
|
||||
190
frontend/src/client/verfahrensablauf.ts
Normal file
190
frontend/src/client/verfahrensablauf.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
// /tools/verfahrensablauf client (t-paliad-179 Slice 1)
|
||||
//
|
||||
// Abstract-browse surface: pick a proceeding, pick a trigger date,
|
||||
// see the typical timeline. No Akte, no save-to-project, no anchor
|
||||
// override editing, no Pathway B cascade. Variant chips + lane view
|
||||
// (Slice 3) and compare (Slice 4) layer on top of this in later
|
||||
// slices. Court picker + view toggle + calc fetch + renderers all
|
||||
// come from ./views/verfahrensablauf-core, which fristenrechner.ts
|
||||
// shares.
|
||||
|
||||
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
type DeadlineResponse,
|
||||
calculateDeadlines,
|
||||
formatDate,
|
||||
populateCourtPicker,
|
||||
renderColumnsBody,
|
||||
renderTimelineBody,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
// Auto-calc plumbing — sequence + debounce mirror /tools/fristenrechner
|
||||
// so rapid input changes never let a stale response overwrite a fresh
|
||||
// one.
|
||||
let calcSeq = 0;
|
||||
let calcTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function scheduleCalc(delayMs = 200) {
|
||||
if (calcTimer !== null) clearTimeout(calcTimer);
|
||||
calcTimer = setTimeout(() => {
|
||||
calcTimer = null;
|
||||
void doCalc();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function showStep(n: number) {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const el = document.getElementById(`step-${i}`);
|
||||
if (el) el.style.display = i <= n ? "block" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
async function doCalc() {
|
||||
const seq = ++calcSeq;
|
||||
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
|
||||
const triggerDate = dateInput?.value || "";
|
||||
if (!triggerDate || !selectedType) return;
|
||||
|
||||
const courtPickerRow = document.getElementById("court-picker-row");
|
||||
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
const courtId = courtPickerRow && courtPickerRow.style.display !== "none" && courtPicker?.value
|
||||
? courtPicker.value
|
||||
: "";
|
||||
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
courtId,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
if (!data) return;
|
||||
lastResponse = data;
|
||||
renderResults(data);
|
||||
showStep(3);
|
||||
}
|
||||
|
||||
function renderResults(data: DeadlineResponse) {
|
||||
const container = document.getElementById("timeline-container");
|
||||
if (!container) return;
|
||||
const printBtn = document.getElementById("fristen-print-btn");
|
||||
const toggle = document.getElementById("fristen-view-toggle");
|
||||
|
||||
const procName = tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
|
||||
const headerHtml = `<div class="timeline-header">
|
||||
<strong>${procName}</strong>
|
||||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||||
</div>`;
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data)
|
||||
: renderTimelineBody(data);
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
if (printBtn) printBtn.style.display = "block";
|
||||
if (toggle) toggle.style.display = "";
|
||||
}
|
||||
|
||||
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
|
||||
const groups = document.querySelectorAll<HTMLElement>(".proceeding-group");
|
||||
const summary = document.getElementById("proceeding-summary") as HTMLElement | null;
|
||||
const summaryName = document.getElementById("proceeding-summary-name");
|
||||
groups.forEach((g) => { g.style.display = collapsed ? "none" : ""; });
|
||||
if (summary) summary.style.display = collapsed ? "" : "none";
|
||||
if (summaryName && displayName) summaryName.textContent = displayName;
|
||||
}
|
||||
|
||||
function selectProceeding(btn: HTMLButtonElement) {
|
||||
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
selectedType = btn.dataset.code || "";
|
||||
|
||||
const name = btn.querySelector("strong")?.textContent || "";
|
||||
const triggerEventEl = document.getElementById("trigger-event");
|
||||
if (triggerEventEl) triggerEventEl.textContent = name;
|
||||
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
||||
|
||||
setProceedingPickerCollapsed(true, name);
|
||||
|
||||
showStep(2);
|
||||
scheduleCalc(0);
|
||||
}
|
||||
|
||||
function initViewToggle() {
|
||||
const toggle = document.getElementById("fristen-view-toggle");
|
||||
if (!toggle) return;
|
||||
|
||||
const initial = new URLSearchParams(window.location.search).get("view");
|
||||
if (initial === "timeline") procedureView = "timeline";
|
||||
|
||||
toggle.querySelectorAll<HTMLInputElement>("input[name=fristen-view]").forEach((input) => {
|
||||
input.checked = input.value === procedureView;
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
procedureView = input.value === "columns" ? "columns" : "timeline";
|
||||
const url = new URL(window.location.href);
|
||||
if (procedureView === "columns") {
|
||||
url.searchParams.delete("view");
|
||||
} else {
|
||||
url.searchParams.set("view", procedureView);
|
||||
}
|
||||
history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
|
||||
toggle.style.display = "none";
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>(".proceeding-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => selectProceeding(btn));
|
||||
});
|
||||
|
||||
document.getElementById("proceeding-summary-reselect")?.addEventListener("click", () => {
|
||||
setProceedingPickerCollapsed(false);
|
||||
});
|
||||
|
||||
document.getElementById("calculate-btn")?.addEventListener("click", () => scheduleCalc(0));
|
||||
|
||||
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
|
||||
if (dateInput) {
|
||||
dateInput.addEventListener("change", () => scheduleCalc());
|
||||
dateInput.addEventListener("input", () => scheduleCalc());
|
||||
dateInput.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0));
|
||||
|
||||
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
|
||||
|
||||
initViewToggle();
|
||||
|
||||
onLangChange(() => {
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
const activeBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
|
||||
if (activeBtn) {
|
||||
const name = activeBtn.querySelector("strong")?.textContent || "";
|
||||
const triggerEventEl = document.getElementById("trigger-event");
|
||||
if (triggerEventEl) triggerEventEl.textContent = name;
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-select the first proceeding tile so users see a timeline
|
||||
// immediately on landing — matches /tools/fristenrechner behaviour.
|
||||
const firstBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
|
||||
if (firstBtn) selectProceeding(firstBtn);
|
||||
});
|
||||
254
frontend/src/client/views/shape-timeline-chart.test.ts
Normal file
254
frontend/src/client/views/shape-timeline-chart.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { layout, type ChartViewport } from "./shape-timeline-chart";
|
||||
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
|
||||
|
||||
// t-paliad-177 Slice 1 — table-driven tests for the pure `layout()`
|
||||
// function. `layout` translates a TimelineEvent[] + LaneInfo[] + viewport
|
||||
// into deterministic SVG-ready geometry. Tests pin the math so subtle
|
||||
// drift (off-by-one days, axis tick density, lane stacking) surfaces fast.
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §15.
|
||||
|
||||
const vp = (overrides: Partial<ChartViewport> = {}): ChartViewport => ({
|
||||
width: 1000,
|
||||
height: 400,
|
||||
laneLabelWidth: 200,
|
||||
dateAxisHeight: 40,
|
||||
todayISO: "2026-06-15",
|
||||
rangeFrom: "2026-01-01",
|
||||
rangeTo: "2026-12-31",
|
||||
density: "standard",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const ev = (overrides: Partial<TimelineEvent> = {}): TimelineEvent => ({
|
||||
kind: "deadline",
|
||||
status: "open",
|
||||
track: "parent",
|
||||
date: "2026-06-15",
|
||||
title: "Test event",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("layout — base geometry", () => {
|
||||
test("chart canvas sits to the right of lane labels and below date axis", () => {
|
||||
const out = layout([], [], vp());
|
||||
expect(out.chartLeft).toBe(200);
|
||||
expect(out.chartTop).toBe(40);
|
||||
expect(out.chartWidth).toBe(800);
|
||||
expect(out.chartHeight).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("pxPerDay = chartWidth / total_days", () => {
|
||||
// 2026 is 365 days; range Jan 1..Dec 31 is 364 day-deltas + 1 = 365 days.
|
||||
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
|
||||
expect(out.pxPerDay).toBeCloseTo(800 / 364, 5);
|
||||
});
|
||||
|
||||
test("invalid range (to before from) falls back to a 1-day span", () => {
|
||||
const out = layout([], [], vp({ rangeFrom: "2026-06-01", rangeTo: "2026-05-01" }));
|
||||
// Sanity: pxPerDay finite, no division-by-zero.
|
||||
expect(Number.isFinite(out.pxPerDay)).toBe(true);
|
||||
expect(out.pxPerDay).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout — today rule", () => {
|
||||
test("today inside range produces a non-null todayX in the chart canvas", () => {
|
||||
const out = layout([], [], vp({ todayISO: "2026-06-15" }));
|
||||
expect(out.todayX).not.toBeNull();
|
||||
expect(out.todayX!).toBeGreaterThan(out.chartLeft);
|
||||
expect(out.todayX!).toBeLessThan(out.chartLeft + out.chartWidth);
|
||||
});
|
||||
|
||||
test("today before range.from → todayX is null", () => {
|
||||
const out = layout([], [], vp({ todayISO: "2025-12-15" }));
|
||||
expect(out.todayX).toBeNull();
|
||||
});
|
||||
|
||||
test("today after range.to → todayX is null", () => {
|
||||
const out = layout([], [], vp({ todayISO: "2027-01-15" }));
|
||||
expect(out.todayX).toBeNull();
|
||||
});
|
||||
|
||||
test("today equals range.from → todayX sits at chartLeft", () => {
|
||||
const out = layout([], [], vp({ todayISO: "2026-01-01" }));
|
||||
expect(out.todayX).toBeCloseTo(out.chartLeft, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout — lane stacking", () => {
|
||||
test("empty lanes synthesises a single 'self' lane", () => {
|
||||
const out = layout([], [], vp());
|
||||
expect(out.laneRows).toHaveLength(1);
|
||||
expect(out.laneRows[0].id).toBe("self");
|
||||
});
|
||||
|
||||
test("multiple lanes stack vertically in input order", () => {
|
||||
const lanes: LaneInfo[] = [
|
||||
{ id: "self", label: "Hauptverfahren" },
|
||||
{ id: "counterclaim:abc", label: "Widerklage" },
|
||||
{ id: "parent_context:xyz", label: "Parent" },
|
||||
];
|
||||
const out = layout([], lanes, vp());
|
||||
expect(out.laneRows).toHaveLength(3);
|
||||
expect(out.laneRows[0].y).toBe(out.chartTop);
|
||||
expect(out.laneRows[1].y).toBeGreaterThan(out.laneRows[0].y);
|
||||
expect(out.laneRows[2].y).toBeGreaterThan(out.laneRows[1].y);
|
||||
// All same height.
|
||||
expect(out.laneRows[0].height).toBe(out.laneRows[1].height);
|
||||
expect(out.laneRows[1].height).toBe(out.laneRows[2].height);
|
||||
});
|
||||
|
||||
test("density compact gives smaller lane height than spacious", () => {
|
||||
const compact = layout([], [], vp({ density: "compact" }));
|
||||
const spacious = layout([], [], vp({ density: "spacious" }));
|
||||
expect(compact.laneRows[0].height).toBeLessThan(spacious.laneRows[0].height);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout — marks", () => {
|
||||
test("single deadline maps to one mark in the self lane", () => {
|
||||
const events: TimelineEvent[] = [ev({ date: "2026-06-15" })];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks).toHaveLength(1);
|
||||
expect(out.marks[0].eventIndex).toBe(0);
|
||||
expect(out.marks[0].laneId).toBe("self");
|
||||
expect(out.marks[0].undated).toBe(false);
|
||||
});
|
||||
|
||||
test("event's x position matches its date offset from range.from", () => {
|
||||
// June 15 is day 165 of 2026 (0-indexed from Jan 1).
|
||||
const events: TimelineEvent[] = [ev({ date: "2026-06-15" })];
|
||||
const out = layout(events, [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
|
||||
const expectedX = out.chartLeft + 165 * out.pxPerDay;
|
||||
expect(out.marks[0].x).toBeCloseTo(expectedX, 1);
|
||||
});
|
||||
|
||||
test("event bucketed by lane_id matches the corresponding lane row", () => {
|
||||
const lanes: LaneInfo[] = [
|
||||
{ id: "self", label: "Self" },
|
||||
{ id: "ccr", label: "CCR" },
|
||||
];
|
||||
const events: TimelineEvent[] = [
|
||||
ev({ date: "2026-06-15", lane_id: "ccr" }),
|
||||
];
|
||||
const out = layout(events, lanes, vp());
|
||||
const ccrRow = out.laneRows.find((r) => r.id === "ccr")!;
|
||||
expect(out.marks[0].laneId).toBe("ccr");
|
||||
expect(out.marks[0].y).toBeCloseTo(ccrRow.y + ccrRow.height / 2, 1);
|
||||
});
|
||||
|
||||
test("unknown lane_id falls back to the first lane (defensive)", () => {
|
||||
const lanes: LaneInfo[] = [{ id: "self", label: "Self" }];
|
||||
const events: TimelineEvent[] = [
|
||||
ev({ date: "2026-06-15", lane_id: "deleted-lane-id" }),
|
||||
];
|
||||
const out = layout(events, lanes, vp());
|
||||
expect(out.marks[0].laneId).toBe("self");
|
||||
});
|
||||
|
||||
test("events outside range are clipped (not emitted)", () => {
|
||||
const events: TimelineEvent[] = [
|
||||
ev({ date: "2025-01-01", title: "before" }),
|
||||
ev({ date: "2026-06-15", title: "inside" }),
|
||||
ev({ date: "2027-12-31", title: "after" }),
|
||||
];
|
||||
const out = layout(events, [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
|
||||
expect(out.marks).toHaveLength(1);
|
||||
expect(out.marks[0].eventIndex).toBe(1);
|
||||
});
|
||||
|
||||
test("undated events go to the undated zone with undated=true", () => {
|
||||
const events: TimelineEvent[] = [ev({ date: null, title: "court-set" })];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks).toHaveLength(1);
|
||||
expect(out.marks[0].undated).toBe(true);
|
||||
// Undated marks sit in the lane label gutter (x < chartLeft).
|
||||
expect(out.marks[0].x).toBeLessThan(out.chartLeft);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout — mark shapes by kind+status", () => {
|
||||
test("deadline.done → dot, deadline.open → dot, deadline.overdue → dot", () => {
|
||||
const events: TimelineEvent[] = [
|
||||
ev({ kind: "deadline", status: "done" }),
|
||||
ev({ kind: "deadline", status: "open" }),
|
||||
ev({ kind: "deadline", status: "overdue" }),
|
||||
];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks.map((m) => m.shape)).toEqual(["dot", "dot", "dot"]);
|
||||
});
|
||||
|
||||
test("milestone → diamond", () => {
|
||||
const events: TimelineEvent[] = [ev({ kind: "milestone", status: "done" })];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks[0].shape).toBe("diamond");
|
||||
});
|
||||
|
||||
test("appointment → dot (Slice 1 keeps it simple; bar variant deferred)", () => {
|
||||
const events: TimelineEvent[] = [ev({ kind: "appointment", status: "open" })];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks[0].shape).toBe("dot");
|
||||
});
|
||||
|
||||
test("projected.predicted → hatched-dot", () => {
|
||||
const events: TimelineEvent[] = [ev({ kind: "projected", status: "predicted" })];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks[0].shape).toBe("hatched-dot");
|
||||
});
|
||||
|
||||
test("projected.court_set → dashed-dot", () => {
|
||||
const events: TimelineEvent[] = [ev({ kind: "projected", status: "court_set" })];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks[0].shape).toBe("dashed-dot");
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout — axis ticks", () => {
|
||||
test("short range (<90d) emits month ticks", () => {
|
||||
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-02-28" }));
|
||||
const kinds = new Set(out.axisTicks.map((t) => t.kind));
|
||||
expect(kinds.has("month")).toBe(true);
|
||||
});
|
||||
|
||||
test("medium range (90-730d) emits quarter ticks", () => {
|
||||
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
|
||||
const kinds = new Set(out.axisTicks.map((t) => t.kind));
|
||||
expect(kinds.has("quarter")).toBe(true);
|
||||
});
|
||||
|
||||
test("long range (>730d) emits year ticks", () => {
|
||||
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2029-12-31" }));
|
||||
const kinds = new Set(out.axisTicks.map((t) => t.kind));
|
||||
expect(kinds.has("year")).toBe(true);
|
||||
});
|
||||
|
||||
test("year-boundary ticks are flagged", () => {
|
||||
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2027-12-31" }));
|
||||
const yearBoundaries = out.axisTicks.filter((t) => t.isYearBoundary);
|
||||
expect(yearBoundaries.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("all ticks fall inside the chart canvas horizontally", () => {
|
||||
const out = layout([], [], vp());
|
||||
for (const tick of out.axisTicks) {
|
||||
expect(tick.x).toBeGreaterThanOrEqual(out.chartLeft - 0.5);
|
||||
expect(tick.x).toBeLessThanOrEqual(out.chartLeft + out.chartWidth + 0.5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout — undated counting", () => {
|
||||
test("undated marks tallied separately from inside-range count", () => {
|
||||
const events: TimelineEvent[] = [
|
||||
ev({ date: "2026-06-15" }),
|
||||
ev({ date: null }),
|
||||
ev({ date: null }),
|
||||
ev({ date: "2025-01-01" }), // out of range
|
||||
];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.undatedCount).toBe(2);
|
||||
expect(out.marks).toHaveLength(3); // 1 dated + 2 undated, the out-of-range one is clipped
|
||||
});
|
||||
});
|
||||
789
frontend/src/client/views/shape-timeline-chart.ts
Normal file
789
frontend/src/client/views/shape-timeline-chart.ts
Normal file
@@ -0,0 +1,789 @@
|
||||
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
|
||||
|
||||
// shape-timeline-chart (t-paliad-177 Slice 1) — horizontal SVG Gantt
|
||||
// renderer for the standalone Project Timeline / Chart page.
|
||||
//
|
||||
// Split into two concerns:
|
||||
//
|
||||
// layout(events, lanes, viewport): ChartLayout
|
||||
// pure function — translates the wire shape into deterministic
|
||||
// SVG-ready geometry (axis ticks, lane row y/height, mark x/y/shape,
|
||||
// today-rule x). No DOM access. Table-driven tests pin this in
|
||||
// shape-timeline-chart.test.ts.
|
||||
//
|
||||
// paint(layout, root): void (Slice 1, next commit)
|
||||
// DOM-mutates an SVGSVGElement. Reads layout, never recomputes
|
||||
// positions. Idempotent — calling twice with the same layout
|
||||
// produces the same DOM.
|
||||
//
|
||||
// mount(host, opts): ChartHandle (Slice 1, next commit)
|
||||
// End-to-end: fetches /api/projects/{id}/timeline, computes layout,
|
||||
// paints, returns a handle with .refresh() / .dispose().
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §3.2 + §12.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Density = "compact" | "standard" | "spacious";
|
||||
|
||||
export interface ChartViewport {
|
||||
width: number;
|
||||
height: number;
|
||||
/** Reserved on the left for lane labels (and the undated zone). */
|
||||
laneLabelWidth: number;
|
||||
/** Reserved on top for the date axis. */
|
||||
dateAxisHeight: number;
|
||||
/** Today's date as ISO YYYY-MM-DD. Used to position the today rule. */
|
||||
todayISO: string;
|
||||
/** Inclusive ISO YYYY-MM-DD start of the chart's date range. */
|
||||
rangeFrom: string;
|
||||
/** Inclusive ISO YYYY-MM-DD end of the chart's date range. */
|
||||
rangeTo: string;
|
||||
density: Density;
|
||||
}
|
||||
|
||||
export interface AxisTick {
|
||||
x: number;
|
||||
label: string;
|
||||
kind: "year" | "quarter" | "month";
|
||||
isYearBoundary: boolean;
|
||||
}
|
||||
|
||||
export interface LaneRow {
|
||||
id: string;
|
||||
label: string;
|
||||
y: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type MarkShape =
|
||||
| "dot"
|
||||
| "diamond"
|
||||
| "hatched-dot"
|
||||
| "dashed-dot";
|
||||
|
||||
export interface Mark {
|
||||
/** Index into the original events array — paint() reuses this for tooltips + deep-links. */
|
||||
eventIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Radius for dot / hatched-dot / dashed-dot, half-diagonal for diamond. */
|
||||
radius: number;
|
||||
shape: MarkShape;
|
||||
kind: TimelineEvent["kind"];
|
||||
status: TimelineEvent["status"];
|
||||
laneId: string;
|
||||
undated: boolean;
|
||||
}
|
||||
|
||||
export interface ChartLayout {
|
||||
viewport: ChartViewport;
|
||||
pxPerDay: number;
|
||||
chartLeft: number;
|
||||
chartTop: number;
|
||||
chartWidth: number;
|
||||
chartHeight: number;
|
||||
axisTicks: AxisTick[];
|
||||
laneRows: LaneRow[];
|
||||
marks: Mark[];
|
||||
/** Pixel x of the today rule, or null when today is outside the range. */
|
||||
todayX: number | null;
|
||||
undatedCount: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Density tokens — single source of truth, used by layout() and CSS swap.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LANE_HEIGHT: Record<Density, number> = {
|
||||
compact: 24,
|
||||
standard: 40,
|
||||
spacious: 64,
|
||||
};
|
||||
|
||||
const MARK_RADIUS: Record<Density, number> = {
|
||||
compact: 5,
|
||||
standard: 7,
|
||||
spacious: 10,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Date helpers — UTC throughout to avoid DST drift in day-math.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
function parseISODay(iso: string): number | null {
|
||||
// Accept "YYYY-MM-DD" and "YYYY-MM-DDTHH:MM:SSZ" (substrate marshals
|
||||
// deadline.due_date as the UTC-midnight form — see format.ts).
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
|
||||
if (!m) return null;
|
||||
const y = Number(m[1]);
|
||||
const mo = Number(m[2]);
|
||||
const d = Number(m[3]);
|
||||
if (
|
||||
!Number.isFinite(y) || !Number.isFinite(mo) || !Number.isFinite(d) ||
|
||||
mo < 1 || mo > 12 || d < 1 || d > 31
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Date.UTC(y, mo - 1, d);
|
||||
}
|
||||
|
||||
function dayDelta(fromMs: number, toMs: number): number {
|
||||
return Math.round((toMs - fromMs) / DAY_MS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mark shape resolution — single mapping table, mirrors §6.2 of the design.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function markShape(kind: TimelineEvent["kind"], status: TimelineEvent["status"]): MarkShape {
|
||||
if (kind === "milestone") return "diamond";
|
||||
if (kind === "projected") {
|
||||
if (status === "court_set") return "dashed-dot";
|
||||
return "hatched-dot"; // predicted, predicted_overdue, off_script
|
||||
}
|
||||
// deadline + appointment + everything else → plain dot. Status drives
|
||||
// colour saturation (see CSS palette tokens), not shape.
|
||||
return "dot";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Axis tick generation — granularity by total span.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateTicks(
|
||||
fromMs: number,
|
||||
toMs: number,
|
||||
chartLeft: number,
|
||||
pxPerDay: number,
|
||||
): AxisTick[] {
|
||||
const totalDays = dayDelta(fromMs, toMs);
|
||||
const ticks: AxisTick[] = [];
|
||||
|
||||
// Walk from the first day-of-month >= fromMs forward.
|
||||
const start = new Date(fromMs);
|
||||
const yStart = start.getUTCFullYear();
|
||||
const mStart = start.getUTCMonth();
|
||||
|
||||
// Density rules:
|
||||
// <90d → month ticks (every month-start)
|
||||
// 90-730 → quarter ticks (Jan, Apr, Jul, Oct)
|
||||
// >730 → year ticks (Jan only)
|
||||
let kind: AxisTick["kind"];
|
||||
let monthStep: number;
|
||||
if (totalDays < 90) {
|
||||
kind = "month";
|
||||
monthStep = 1;
|
||||
} else if (totalDays <= 730) {
|
||||
kind = "quarter";
|
||||
monthStep = 3;
|
||||
} else {
|
||||
kind = "year";
|
||||
monthStep = 12;
|
||||
}
|
||||
|
||||
// For quarter/year ticks, snap the starting month to the next aligned
|
||||
// boundary so the labels are calendar-aligned (Jan/Apr/Jul/Oct, not
|
||||
// Feb/May/Aug/Nov).
|
||||
let mCursor = mStart;
|
||||
let yCursor = yStart;
|
||||
if (kind === "quarter") {
|
||||
const offset = mCursor % 3;
|
||||
if (offset !== 0) mCursor += 3 - offset;
|
||||
} else if (kind === "year") {
|
||||
if (mCursor !== 0) {
|
||||
mCursor = 0;
|
||||
yCursor += 1;
|
||||
}
|
||||
}
|
||||
// If the first day of fromMs is not month-1, advance by one month so we
|
||||
// don't double-print the partial month at the very start.
|
||||
if (kind === "month" && start.getUTCDate() !== 1) {
|
||||
mCursor += 1;
|
||||
}
|
||||
while (mCursor >= 12) {
|
||||
mCursor -= 12;
|
||||
yCursor += 1;
|
||||
}
|
||||
|
||||
// Emit ticks until past toMs.
|
||||
while (true) {
|
||||
const tickMs = Date.UTC(yCursor, mCursor, 1);
|
||||
if (tickMs > toMs) break;
|
||||
const days = dayDelta(fromMs, tickMs);
|
||||
const x = chartLeft + days * pxPerDay;
|
||||
const label = formatTickLabel(yCursor, mCursor, kind);
|
||||
ticks.push({
|
||||
x,
|
||||
label,
|
||||
kind,
|
||||
isYearBoundary: mCursor === 0,
|
||||
});
|
||||
mCursor += monthStep;
|
||||
while (mCursor >= 12) {
|
||||
mCursor -= 12;
|
||||
yCursor += 1;
|
||||
}
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
const MONTH_DE = [
|
||||
"Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
|
||||
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez",
|
||||
];
|
||||
|
||||
function formatTickLabel(year: number, month: number, kind: AxisTick["kind"]): string {
|
||||
if (kind === "year") return String(year);
|
||||
if (kind === "quarter") {
|
||||
const q = Math.floor(month / 3) + 1;
|
||||
return `Q${q} ${year}`;
|
||||
}
|
||||
return MONTH_DE[month];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: layout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function layout(
|
||||
events: ReadonlyArray<TimelineEvent>,
|
||||
lanes: ReadonlyArray<LaneInfo>,
|
||||
viewport: ChartViewport,
|
||||
): ChartLayout {
|
||||
// -- Canvas geometry --------------------------------------------------
|
||||
const chartLeft = viewport.laneLabelWidth;
|
||||
const chartTop = viewport.dateAxisHeight;
|
||||
const chartWidth = Math.max(0, viewport.width - chartLeft);
|
||||
// chartHeight is derived from the number of lane rows so the SVG grows
|
||||
// / shrinks vertically with the data, not the supplied viewport.height
|
||||
// (which the caller uses as a hint — actual height comes back in
|
||||
// viewport.height after the paint pass).
|
||||
const laneCount = Math.max(1, lanes.length);
|
||||
const laneHeight = LANE_HEIGHT[viewport.density];
|
||||
const chartHeight = laneCount * laneHeight;
|
||||
|
||||
// -- Date math --------------------------------------------------------
|
||||
const fromMs = parseISODay(viewport.rangeFrom);
|
||||
const toMsRaw = parseISODay(viewport.rangeTo);
|
||||
if (fromMs === null || toMsRaw === null) {
|
||||
// Degenerate input — return an empty layout rather than NaN-paint.
|
||||
return {
|
||||
viewport,
|
||||
pxPerDay: 0,
|
||||
chartLeft,
|
||||
chartTop,
|
||||
chartWidth,
|
||||
chartHeight,
|
||||
axisTicks: [],
|
||||
laneRows: synthLaneRows(lanes, chartTop, laneHeight),
|
||||
marks: [],
|
||||
todayX: null,
|
||||
undatedCount: 0,
|
||||
};
|
||||
}
|
||||
// Guard against to < from. Clamp the inverted case to a 1-day span so
|
||||
// pxPerDay stays positive and finite.
|
||||
const toMs = toMsRaw <= fromMs ? fromMs + DAY_MS : toMsRaw;
|
||||
const totalDays = Math.max(1, dayDelta(fromMs, toMs));
|
||||
const pxPerDay = chartWidth / totalDays;
|
||||
|
||||
// -- Today rule -------------------------------------------------------
|
||||
const todayMs = parseISODay(viewport.todayISO);
|
||||
let todayX: number | null = null;
|
||||
if (todayMs !== null && todayMs >= fromMs && todayMs <= toMs) {
|
||||
todayX = chartLeft + dayDelta(fromMs, todayMs) * pxPerDay;
|
||||
}
|
||||
|
||||
// -- Lane rows --------------------------------------------------------
|
||||
const laneRows = synthLaneRows(lanes, chartTop, laneHeight);
|
||||
const laneIndex = new Map<string, LaneRow>();
|
||||
for (const row of laneRows) laneIndex.set(row.id, row);
|
||||
const fallbackLane = laneRows[0];
|
||||
|
||||
// -- Marks ------------------------------------------------------------
|
||||
const marks: Mark[] = [];
|
||||
let undatedCount = 0;
|
||||
const radius = MARK_RADIUS[viewport.density];
|
||||
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i];
|
||||
const laneRow = (event.lane_id && laneIndex.get(event.lane_id)) || fallbackLane;
|
||||
|
||||
if (!event.date) {
|
||||
// Undated rows live in a gutter to the left of the chart canvas.
|
||||
// We pile them up vertically inside the lane label area so they
|
||||
// remain hover-/click-targets, but they don't compete with the
|
||||
// date-axis-positioned marks for screen space.
|
||||
undatedCount++;
|
||||
const undatedX = chartLeft - viewport.laneLabelWidth * 0.25;
|
||||
marks.push({
|
||||
eventIndex: i,
|
||||
x: undatedX,
|
||||
y: laneRow.y + laneRow.height / 2,
|
||||
radius,
|
||||
shape: markShape(event.kind, event.status),
|
||||
kind: event.kind,
|
||||
status: event.status,
|
||||
laneId: laneRow.id,
|
||||
undated: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const ms = parseISODay(event.date);
|
||||
if (ms === null) continue; // unparseable date, drop defensively
|
||||
if (ms < fromMs || ms > toMs) continue; // outside range — clipped
|
||||
|
||||
const x = chartLeft + dayDelta(fromMs, ms) * pxPerDay;
|
||||
const y = laneRow.y + laneRow.height / 2;
|
||||
marks.push({
|
||||
eventIndex: i,
|
||||
x,
|
||||
y,
|
||||
radius,
|
||||
shape: markShape(event.kind, event.status),
|
||||
kind: event.kind,
|
||||
status: event.status,
|
||||
laneId: laneRow.id,
|
||||
undated: false,
|
||||
});
|
||||
}
|
||||
|
||||
// -- Axis ticks -------------------------------------------------------
|
||||
const axisTicks = generateTicks(fromMs, toMs, chartLeft, pxPerDay);
|
||||
|
||||
return {
|
||||
viewport,
|
||||
pxPerDay,
|
||||
chartLeft,
|
||||
chartTop,
|
||||
chartWidth,
|
||||
chartHeight,
|
||||
axisTicks,
|
||||
laneRows,
|
||||
marks,
|
||||
todayX,
|
||||
undatedCount,
|
||||
};
|
||||
}
|
||||
|
||||
function synthLaneRows(
|
||||
lanes: ReadonlyArray<LaneInfo>,
|
||||
chartTop: number,
|
||||
laneHeight: number,
|
||||
): LaneRow[] {
|
||||
if (lanes.length === 0) {
|
||||
return [{ id: "self", label: "", y: chartTop, height: laneHeight }];
|
||||
}
|
||||
return lanes.map((lane, idx) => ({
|
||||
id: lane.id,
|
||||
label: lane.label,
|
||||
y: chartTop + idx * laneHeight,
|
||||
height: laneHeight,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: paint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
function svg(name: string, attrs: Record<string, string | number> = {}): SVGElement {
|
||||
const el = document.createElementNS(SVG_NS, name);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
el.setAttribute(k, String(v));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* paint mutates an existing SVGSVGElement to reflect a ChartLayout.
|
||||
* Idempotent: clears prior children before painting, so calling twice
|
||||
* with the same layout produces the same DOM.
|
||||
*
|
||||
* Events are *not* wired here — mount() attaches the delegated listeners
|
||||
* after paint() returns. paint() stays pure-render so it stays cheap to
|
||||
* call from a resize / palette swap.
|
||||
*/
|
||||
export function paint(
|
||||
chart: ChartLayout,
|
||||
root: SVGSVGElement,
|
||||
events: ReadonlyArray<TimelineEvent>,
|
||||
): void {
|
||||
// Clear prior contents.
|
||||
while (root.firstChild) root.removeChild(root.firstChild);
|
||||
|
||||
const totalHeight = chart.chartTop + chart.chartHeight + 24; // 24px bottom pad for axis labels
|
||||
root.setAttribute("viewBox", `0 0 ${chart.viewport.width} ${totalHeight}`);
|
||||
root.setAttribute("preserveAspectRatio", "xMinYMin meet");
|
||||
root.setAttribute("role", "img");
|
||||
root.setAttribute("aria-label", "Project Timeline / Chart");
|
||||
|
||||
// <defs> — hatched pattern for projected marks.
|
||||
const defs = svg("defs");
|
||||
const pattern = svg("pattern", {
|
||||
id: "chart-hatch",
|
||||
patternUnits: "userSpaceOnUse",
|
||||
width: 4,
|
||||
height: 4,
|
||||
});
|
||||
pattern.appendChild(svg("path", {
|
||||
d: "M0,4 L4,0",
|
||||
stroke: "currentColor",
|
||||
"stroke-width": 1,
|
||||
fill: "none",
|
||||
}));
|
||||
defs.appendChild(pattern);
|
||||
root.appendChild(defs);
|
||||
|
||||
// Layer order: grid → lane separators → today rule → marks → labels.
|
||||
const gGrid = svg("g", { class: "chart-grid" });
|
||||
root.appendChild(gGrid);
|
||||
|
||||
// Date axis ticks — vertical guidelines + labels at top.
|
||||
for (const tick of chart.axisTicks) {
|
||||
gGrid.appendChild(svg("line", {
|
||||
class: tick.isYearBoundary
|
||||
? "chart-tick chart-tick--year"
|
||||
: "chart-tick",
|
||||
x1: tick.x,
|
||||
y1: chart.chartTop,
|
||||
x2: tick.x,
|
||||
y2: chart.chartTop + chart.chartHeight,
|
||||
}));
|
||||
const label = svg("text", {
|
||||
class: "chart-tick-label",
|
||||
x: tick.x + 4,
|
||||
y: chart.chartTop - 8,
|
||||
});
|
||||
label.textContent = tick.label;
|
||||
gGrid.appendChild(label);
|
||||
}
|
||||
|
||||
// Lane separators — horizontal lines between rows + labels in the gutter.
|
||||
for (let i = 0; i < chart.laneRows.length; i++) {
|
||||
const row = chart.laneRows[i];
|
||||
if (i > 0) {
|
||||
gGrid.appendChild(svg("line", {
|
||||
class: "chart-lane-separator",
|
||||
x1: 0,
|
||||
y1: row.y,
|
||||
x2: chart.viewport.width,
|
||||
y2: row.y,
|
||||
}));
|
||||
}
|
||||
if (row.label) {
|
||||
const labelEl = svg("text", {
|
||||
class: "chart-lane-label",
|
||||
x: 8,
|
||||
y: row.y + row.height / 2 + 4,
|
||||
});
|
||||
labelEl.textContent = row.label;
|
||||
gGrid.appendChild(labelEl);
|
||||
}
|
||||
}
|
||||
|
||||
// Today rule — vertical lime line + "Heute" label.
|
||||
if (chart.todayX !== null) {
|
||||
gGrid.appendChild(svg("line", {
|
||||
class: "chart-today-rule",
|
||||
x1: chart.todayX,
|
||||
y1: chart.chartTop - 4,
|
||||
x2: chart.todayX,
|
||||
y2: chart.chartTop + chart.chartHeight + 4,
|
||||
}));
|
||||
const todayLabel = svg("text", {
|
||||
class: "chart-today-label",
|
||||
x: chart.todayX + 4,
|
||||
y: chart.chartTop + chart.chartHeight + 18,
|
||||
});
|
||||
todayLabel.textContent = "Heute";
|
||||
gGrid.appendChild(todayLabel);
|
||||
}
|
||||
|
||||
// Marks.
|
||||
const gMarks = svg("g", { class: "chart-marks" });
|
||||
root.appendChild(gMarks);
|
||||
|
||||
for (const mark of chart.marks) {
|
||||
const event = events[mark.eventIndex];
|
||||
const markEl = paintMark(mark, event);
|
||||
gMarks.appendChild(markEl);
|
||||
}
|
||||
}
|
||||
|
||||
function paintMark(mark: Mark, event: TimelineEvent): SVGElement {
|
||||
// Wrap every mark in a <g> with data-* attributes so mount() can do
|
||||
// event-delegation off the top-level <svg> without per-mark listeners.
|
||||
const g = svg("g", {
|
||||
class: markClassName(mark),
|
||||
"data-event-index": mark.eventIndex,
|
||||
"data-kind": mark.kind,
|
||||
"data-status": mark.status,
|
||||
"data-lane": mark.laneId,
|
||||
"data-undated": mark.undated ? "1" : "0",
|
||||
"data-deadline-id": event.deadline_id || "",
|
||||
"data-appointment-id": event.appointment_id || "",
|
||||
"data-project-event-id": event.project_event_id || "",
|
||||
role: "img",
|
||||
tabindex: 0,
|
||||
});
|
||||
|
||||
// ARIA label so screen-readers can read each mark (§13).
|
||||
const title = svg("title");
|
||||
title.textContent = markAriaLabel(mark, event);
|
||||
g.appendChild(title);
|
||||
|
||||
// Generous invisible hit-target so dots are easy to click without
|
||||
// hunting (12px hit halo around a 7px standard radius).
|
||||
g.appendChild(svg("circle", {
|
||||
class: "chart-mark-hit",
|
||||
cx: mark.x,
|
||||
cy: mark.y,
|
||||
r: mark.radius + 6,
|
||||
fill: "transparent",
|
||||
}));
|
||||
|
||||
switch (mark.shape) {
|
||||
case "dot": {
|
||||
const c = svg("circle", {
|
||||
class: "chart-mark-dot",
|
||||
cx: mark.x,
|
||||
cy: mark.y,
|
||||
r: mark.radius,
|
||||
});
|
||||
g.appendChild(c);
|
||||
break;
|
||||
}
|
||||
case "diamond": {
|
||||
const r = mark.radius;
|
||||
g.appendChild(svg("polygon", {
|
||||
class: "chart-mark-diamond",
|
||||
points: `${mark.x},${mark.y - r} ${mark.x + r},${mark.y} ${mark.x},${mark.y + r} ${mark.x - r},${mark.y}`,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "hatched-dot": {
|
||||
g.appendChild(svg("circle", {
|
||||
class: "chart-mark-hatched",
|
||||
cx: mark.x,
|
||||
cy: mark.y,
|
||||
r: mark.radius,
|
||||
fill: "url(#chart-hatch)",
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "dashed-dot": {
|
||||
g.appendChild(svg("circle", {
|
||||
class: "chart-mark-dashed",
|
||||
cx: mark.x,
|
||||
cy: mark.y,
|
||||
r: mark.radius,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function markClassName(mark: Mark): string {
|
||||
const parts = ["chart-mark", `chart-mark--${mark.kind}`, `chart-mark--status-${mark.status}`];
|
||||
if (mark.undated) parts.push("chart-mark--undated");
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function markAriaLabel(mark: Mark, event: TimelineEvent): string {
|
||||
const dateStr = event.date ? event.date.slice(0, 10) : "Datum offen";
|
||||
return `${event.title} — ${event.kind} (${event.status}) — ${dateStr}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: mount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ChartMountOpts {
|
||||
projectId: string;
|
||||
todayISO?: string;
|
||||
density?: Density;
|
||||
/** Optional ISO YYYY-MM-DD overrides for the date range. When omitted,
|
||||
* mount picks `today-1y .. today+1y` per design Q8. */
|
||||
rangeFrom?: string;
|
||||
rangeTo?: string;
|
||||
/** Optional callback fired when the user clicks a mark with a known
|
||||
* deep-link target. Receives the underlying TimelineEvent. */
|
||||
onMarkClick?: (event: TimelineEvent) => void;
|
||||
}
|
||||
|
||||
export interface ChartHandle {
|
||||
/** Re-fetches the timeline and re-paints. */
|
||||
refresh: () => Promise<void>;
|
||||
/** Removes event listeners + tears down the SVG. */
|
||||
dispose: () => void;
|
||||
/** Returns the last computed layout (useful for tests / debugging). */
|
||||
getLayout: () => ChartLayout | null;
|
||||
}
|
||||
|
||||
interface TimelineEnvelope {
|
||||
events: TimelineEvent[];
|
||||
lanes: LaneInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* mount builds a chart inside the given host element. The host's
|
||||
* dimensions drive the SVG width; height grows from the lane row count.
|
||||
* Returns a handle for refresh / dispose.
|
||||
*/
|
||||
export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle {
|
||||
host.classList.add("smart-timeline-chart-host");
|
||||
|
||||
// Empty / error placeholders.
|
||||
const messageEl = document.createElement("div");
|
||||
messageEl.className = "smart-timeline-chart-message";
|
||||
messageEl.textContent = "";
|
||||
host.appendChild(messageEl);
|
||||
|
||||
// The SVG root we paint into.
|
||||
const svgEl = document.createElementNS(SVG_NS, "svg") as SVGSVGElement;
|
||||
svgEl.classList.add("smart-timeline-chart");
|
||||
svgEl.setAttribute("data-palette", "default");
|
||||
svgEl.setAttribute("data-density", opts.density ?? "standard");
|
||||
host.appendChild(svgEl);
|
||||
|
||||
let lastEvents: TimelineEvent[] = [];
|
||||
let lastLayout: ChartLayout | null = null;
|
||||
|
||||
const todayISO = opts.todayISO ?? today();
|
||||
const rangeFrom = opts.rangeFrom ?? shiftYears(todayISO, -1);
|
||||
const rangeTo = opts.rangeTo ?? shiftYears(todayISO, 1);
|
||||
|
||||
function repaint(): void {
|
||||
const rect = host.getBoundingClientRect();
|
||||
// Minimum width keeps the canvas usable when the host is hidden /
|
||||
// about to be sized; resize listener will repaint on real layout.
|
||||
const width = Math.max(640, rect.width || 1000);
|
||||
const density: Density = opts.density ?? "standard";
|
||||
const viewport: ChartViewport = {
|
||||
width,
|
||||
height: 400,
|
||||
laneLabelWidth: 200,
|
||||
dateAxisHeight: 40,
|
||||
todayISO,
|
||||
rangeFrom,
|
||||
rangeTo,
|
||||
density,
|
||||
};
|
||||
const chart = layout(lastEvents, [...currentLanes], viewport);
|
||||
lastLayout = chart;
|
||||
paint(chart, svgEl, lastEvents);
|
||||
svgEl.setAttribute("width", String(width));
|
||||
svgEl.setAttribute("height", String(chart.chartTop + chart.chartHeight + 32));
|
||||
}
|
||||
|
||||
let currentLanes: LaneInfo[] = [];
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
messageEl.textContent = "Lädt …";
|
||||
messageEl.classList.remove("smart-timeline-chart-message--error");
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${encodeURIComponent(opts.projectId)}/timeline`,
|
||||
);
|
||||
if (!resp.ok) {
|
||||
messageEl.textContent = "Timeline konnte nicht geladen werden.";
|
||||
messageEl.classList.add("smart-timeline-chart-message--error");
|
||||
return;
|
||||
}
|
||||
const body = await resp.json();
|
||||
// Defensive: tolerate the legacy []TimelineEvent shape (pre-Slice-4)
|
||||
// even though the Slice-4 envelope is the contract today.
|
||||
if (Array.isArray(body)) {
|
||||
lastEvents = body as TimelineEvent[];
|
||||
currentLanes = [];
|
||||
} else {
|
||||
const env = body as TimelineEnvelope;
|
||||
lastEvents = env.events ?? [];
|
||||
currentLanes = env.lanes ?? [];
|
||||
}
|
||||
if (lastEvents.length === 0) {
|
||||
messageEl.textContent = "Keine Ereignisse im gewählten Zeitraum.";
|
||||
} else {
|
||||
messageEl.textContent = "";
|
||||
}
|
||||
repaint();
|
||||
} catch (err) {
|
||||
messageEl.textContent = "Netzwerkfehler beim Laden der Timeline.";
|
||||
messageEl.classList.add("smart-timeline-chart-message--error");
|
||||
}
|
||||
}
|
||||
|
||||
// Click delegation — read data-* attrs to deep-link.
|
||||
function handleClick(e: Event) {
|
||||
const target = e.target as Element | null;
|
||||
if (!target) return;
|
||||
const g = target.closest("g.chart-mark") as Element | null;
|
||||
if (!g) return;
|
||||
const indexAttr = g.getAttribute("data-event-index");
|
||||
if (!indexAttr) return;
|
||||
const idx = Number(indexAttr);
|
||||
const event = lastEvents[idx];
|
||||
if (!event) return;
|
||||
if (opts.onMarkClick) {
|
||||
opts.onMarkClick(event);
|
||||
return;
|
||||
}
|
||||
if (event.deadline_id) {
|
||||
window.location.href = `/deadlines/${encodeURIComponent(event.deadline_id)}`;
|
||||
} else if (event.appointment_id) {
|
||||
window.location.href = `/appointments/${encodeURIComponent(event.appointment_id)}`;
|
||||
}
|
||||
// Milestones + projected rows have no detail page today — no-op.
|
||||
}
|
||||
|
||||
// Resize handler — debounced.
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function handleResize() {
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
repaint();
|
||||
}, 120);
|
||||
}
|
||||
|
||||
svgEl.addEventListener("click", handleClick);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// Kick off initial fetch.
|
||||
void refresh();
|
||||
|
||||
return {
|
||||
refresh,
|
||||
getLayout: () => lastLayout,
|
||||
dispose: () => {
|
||||
svgEl.removeEventListener("click", handleClick);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
if (svgEl.parentNode) svgEl.parentNode.removeChild(svgEl);
|
||||
if (messageEl.parentNode) messageEl.parentNode.removeChild(messageEl);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function today(): string {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${dd}`;
|
||||
}
|
||||
|
||||
function shiftYears(iso: string, delta: number): string {
|
||||
const ms = parseISODay(iso);
|
||||
if (ms === null) return iso;
|
||||
const d = new Date(ms);
|
||||
return `${d.getUTCFullYear() + delta}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
}
|
||||
447
frontend/src/client/views/verfahrensablauf-core.ts
Normal file
447
frontend/src/client/views/verfahrensablauf-core.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
// Shared core for Fristenrechner-style proceeding-timeline rendering.
|
||||
//
|
||||
// Both /tools/fristenrechner (deadline determination) and
|
||||
// /tools/verfahrensablauf (abstract browse — t-paliad-179 Slice 1) call
|
||||
// POST /api/tools/fristenrechner and paint the result with the same
|
||||
// renderers. The module is pure-functional: no shared mutable state, all
|
||||
// language / overrides / editability flow in through args so the two
|
||||
// pages can wire their own per-page concerns (Akte save, anchor edits,
|
||||
// Pathway B etc. on fristenrechner; variant chips, compare etc. coming
|
||||
// to verfahrensablauf in later slices) without leaking into each other.
|
||||
|
||||
import { t, tDyn, getLang } from "../i18n";
|
||||
|
||||
export interface AdjustmentHoliday {
|
||||
Date: string;
|
||||
Name: string;
|
||||
IsVacation: boolean;
|
||||
IsClosure: boolean;
|
||||
}
|
||||
|
||||
export interface AdjustmentReason {
|
||||
kind: "weekend" | "public_holiday" | "vacation";
|
||||
holidays?: AdjustmentHoliday[];
|
||||
vacation_name?: string;
|
||||
vacation_start?: string;
|
||||
vacation_end?: string;
|
||||
original_weekday?: string;
|
||||
}
|
||||
|
||||
export interface CalculatedDeadline {
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
party: string;
|
||||
isMandatory: boolean;
|
||||
ruleRef: string;
|
||||
legalSource?: string;
|
||||
notes?: string;
|
||||
notesEN?: string;
|
||||
dueDate: string;
|
||||
originalDate: string;
|
||||
wasAdjusted: boolean;
|
||||
adjustmentReason?: AdjustmentReason;
|
||||
isRootEvent: boolean;
|
||||
isCourtSet: boolean;
|
||||
isCourtSetIndirect?: boolean;
|
||||
isOptional?: boolean;
|
||||
isOverridden?: boolean;
|
||||
}
|
||||
|
||||
export interface DeadlineResponse {
|
||||
proceedingType: string;
|
||||
proceedingName: string;
|
||||
triggerDate: string;
|
||||
deadlines: CalculatedDeadline[];
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
id: string;
|
||||
code: string;
|
||||
nameDE: string;
|
||||
nameEN: string;
|
||||
country: string;
|
||||
regime?: string;
|
||||
courtType: string;
|
||||
}
|
||||
|
||||
export interface CalcParams {
|
||||
proceedingType: string;
|
||||
triggerDate: string;
|
||||
priorityDate?: string;
|
||||
flags?: string[];
|
||||
anchorOverrides?: Record<string, string>;
|
||||
courtId?: string;
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
claimant: "party-claimant",
|
||||
defendant: "party-defendant",
|
||||
court: "party-court",
|
||||
both: "party-both",
|
||||
};
|
||||
|
||||
// ─── small helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
export function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
export function escHtml(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return "—";
|
||||
const d = new Date(dateStr + "T00:00:00");
|
||||
if (getLang() === "en") {
|
||||
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${weekday}, ${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
return d.toLocaleDateString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateSpan(startISO: string, endISO: string): string {
|
||||
const start = new Date(startISO + "T00:00:00");
|
||||
const end = new Date(endISO + "T00:00:00");
|
||||
if (getLang() === "en") {
|
||||
const fmt = (d: Date) => d.toLocaleDateString("en-US", { day: "numeric", month: "short" });
|
||||
return `${fmt(start)} – ${fmt(end)}`;
|
||||
}
|
||||
const fmt = (d: Date) => `${d.getDate()}.${d.getMonth() + 1}.`;
|
||||
return `${fmt(start)}–${fmt(end)}`;
|
||||
}
|
||||
|
||||
function localizeWeekday(en: string): string {
|
||||
if (en === "Saturday") return t("deadlines.adjusted.weekend.saturday");
|
||||
if (en === "Sunday") return t("deadlines.adjusted.weekend.sunday");
|
||||
return en;
|
||||
}
|
||||
|
||||
// Vacation names come straight from paliad.holidays (e.g. "UPC judicial
|
||||
// vacation"). Not translated — they're proper names of court-set closures.
|
||||
function localizeVacationName(name: string): string {
|
||||
return name;
|
||||
}
|
||||
|
||||
function renderAdjustmentReason(r: AdjustmentReason): string {
|
||||
if (r.kind === "vacation" && r.vacation_name && r.vacation_start && r.vacation_end) {
|
||||
const span = formatDateSpan(r.vacation_start, r.vacation_end);
|
||||
return tDyn("deadlines.adjusted.vacation")
|
||||
.replace("{name}", localizeVacationName(r.vacation_name))
|
||||
.replace("{span}", span);
|
||||
}
|
||||
if (r.kind === "public_holiday" && r.holidays && r.holidays.length > 0) {
|
||||
return tDyn("deadlines.adjusted.holiday").replace("{name}", r.holidays[0].Name);
|
||||
}
|
||||
if (r.kind === "weekend" && r.original_weekday) {
|
||||
return localizeWeekday(r.original_weekday);
|
||||
}
|
||||
return t("deadlines.adjusted.weekend");
|
||||
}
|
||||
|
||||
function formatAdjustedNote(dl: CalculatedDeadline): string {
|
||||
const arrow = `${formatDate(dl.originalDate)} → ${formatDate(dl.dueDate)}`;
|
||||
const reason = dl.adjustmentReason
|
||||
? renderAdjustmentReason(dl.adjustmentReason)
|
||||
: t("deadlines.adjusted.reason");
|
||||
if (getLang() === "en") {
|
||||
return `${t("deadlines.adjusted")} (${reason}): ${arrow}`;
|
||||
}
|
||||
return `${t("deadlines.adjusted")} wegen ${reason}: ${arrow}`;
|
||||
}
|
||||
|
||||
export function partyBadge(party: string): string {
|
||||
const cls = PARTY_CLASS[party] || "party-both";
|
||||
return `<span class="party-badge ${cls}">${tDyn("deadlines.party." + party)}</span>`;
|
||||
}
|
||||
|
||||
// ─── card + body renderers ────────────────────────────────────────────────
|
||||
|
||||
export interface CardOpts {
|
||||
showParty: boolean;
|
||||
// editable=true wires the click-to-edit affordance: data-rule-code,
|
||||
// role=button, tabindex, hover hint. Fristenrechner enables it; the
|
||||
// verfahrensablauf abstract-browse surface keeps editable=false because
|
||||
// there's no anchor-override state on that page in Slice 1.
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
|
||||
const wantsEditable = !!opts.editable;
|
||||
const editable = wantsEditable && !dl.isRootEvent && dl.code !== "";
|
||||
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
|
||||
const editAttrs = editable
|
||||
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
|
||||
: "";
|
||||
const courtLabelKey = dl.isCourtSetIndirect
|
||||
? "deadlines.court.indirect"
|
||||
: "deadlines.court.set";
|
||||
const dateStr = dl.isCourtSet
|
||||
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
|
||||
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
|
||||
|
||||
const mandatoryBadge = dl.isMandatory
|
||||
? ""
|
||||
: '<span class="optional-badge">optional</span>';
|
||||
|
||||
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
|
||||
|
||||
const adjustedNote = dl.wasAdjusted
|
||||
? `<div class="timeline-adjusted">⚠ ${formatAdjustedNote(dl)}</div>`
|
||||
: "";
|
||||
|
||||
const ruleRef = dl.ruleRef
|
||||
? `<span class="timeline-rule">${dl.ruleRef}</span>`
|
||||
: "";
|
||||
|
||||
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
const notes = noteText
|
||||
? `<div class="timeline-notes">${noteText}</div>`
|
||||
: "";
|
||||
|
||||
const meta = (opts.showParty || ruleRef)
|
||||
? `<div class="timeline-meta">
|
||||
${opts.showParty ? partyBadge(dl.party) : ""}
|
||||
${ruleRef}
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
return `<div class="timeline-item-header">
|
||||
<span class="timeline-name">
|
||||
${dlName}
|
||||
${mandatoryBadge}
|
||||
</span>
|
||||
${dateStr}
|
||||
</div>
|
||||
${meta}
|
||||
${adjustedNote}
|
||||
${notes}`;
|
||||
}
|
||||
|
||||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||||
let html = '<div class="timeline">';
|
||||
for (const dl of data.deadlines) {
|
||||
html += `
|
||||
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
|
||||
<div class="timeline-dot-col">
|
||||
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
|
||||
<div class="timeline-line"></div>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
${deadlineCardHtml(dl, opts)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
}
|
||||
|
||||
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
|
||||
// (defendant). Each grid row shares a dueDate so same-day events line up
|
||||
// across columns; party=both renders in BOTH the Proactive and Reactive
|
||||
// cells of the row. Undated rows (Urteil etc.) trail the dated tail, each
|
||||
// keyed by sequence-order so e.g. Urteil precedes Berufungseinlegung.
|
||||
export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "showParty"> = {}): string {
|
||||
type Cell = CalculatedDeadline[];
|
||||
type Row = { proactive: Cell; court: Cell; reactive: Cell };
|
||||
|
||||
const UNSCHEDULED_PREFIX = "__unscheduled__";
|
||||
const rowsMap = new Map<string, Row>();
|
||||
const ensureRow = (key: string): Row => {
|
||||
let r = rowsMap.get(key);
|
||||
if (!r) {
|
||||
r = { proactive: [], court: [], reactive: [] };
|
||||
rowsMap.set(key, r);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
data.deadlines.forEach((dl, idx) => {
|
||||
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
|
||||
const row = ensureRow(key);
|
||||
switch (dl.party) {
|
||||
case "claimant":
|
||||
row.proactive.push(dl);
|
||||
break;
|
||||
case "defendant":
|
||||
row.reactive.push(dl);
|
||||
break;
|
||||
case "court":
|
||||
row.court.push(dl);
|
||||
break;
|
||||
case "both":
|
||||
row.proactive.push(dl);
|
||||
row.reactive.push(dl);
|
||||
break;
|
||||
default:
|
||||
row.court.push(dl);
|
||||
}
|
||||
});
|
||||
|
||||
const datedKeys: string[] = [];
|
||||
const unscheduledKeys: string[] = [];
|
||||
for (const k of rowsMap.keys()) {
|
||||
if (k.startsWith(UNSCHEDULED_PREFIX)) unscheduledKeys.push(k);
|
||||
else datedKeys.push(k);
|
||||
}
|
||||
datedKeys.sort();
|
||||
unscheduledKeys.sort();
|
||||
const keys = [...datedKeys, ...unscheduledKeys];
|
||||
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable };
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
|
||||
}
|
||||
const cards = items
|
||||
.map((dl) => {
|
||||
const mirrorTag = dl.party === "both"
|
||||
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
|
||||
: "";
|
||||
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
|
||||
${deadlineCardHtml(dl, cardOpts)}
|
||||
${mirrorTag}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="fr-col-cell">${cards}</div>`;
|
||||
};
|
||||
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
|
||||
|
||||
for (const key of keys) {
|
||||
const row = rowsMap.get(key)!;
|
||||
html += renderCell(row.proactive);
|
||||
html += renderCell(row.court);
|
||||
html += renderCell(row.reactive);
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
}
|
||||
|
||||
// ─── calculate fetch wrapper ──────────────────────────────────────────────
|
||||
|
||||
export async function calculateDeadlines(params: CalcParams): Promise<DeadlineResponse | null> {
|
||||
if (!params.proceedingType || !params.triggerDate) return null;
|
||||
try {
|
||||
const resp = await fetch("/api/tools/fristenrechner", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
proceedingType: params.proceedingType,
|
||||
triggerDate: params.triggerDate,
|
||||
priorityDate: params.priorityDate || undefined,
|
||||
flags: params.flags && params.flags.length > 0 ? params.flags : undefined,
|
||||
anchorOverrides: params.anchorOverrides && Object.keys(params.anchorOverrides).length > 0
|
||||
? params.anchorOverrides
|
||||
: undefined,
|
||||
courtId: params.courtId || undefined,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
console.error("API error:", err);
|
||||
return null;
|
||||
}
|
||||
return (await resp.json()) as DeadlineResponse;
|
||||
} catch (e) {
|
||||
console.error("Fetch error:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── court picker ─────────────────────────────────────────────────────────
|
||||
|
||||
const courtCache = new Map<string, CourtRow[]>();
|
||||
|
||||
export function courtTypesFor(proceedingType: string): string[] {
|
||||
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
|
||||
return ["UPC-CoA"];
|
||||
}
|
||||
if (proceedingType === "UPC_REV") {
|
||||
return ["UPC-CD", "UPC-LD"];
|
||||
}
|
||||
if (proceedingType.startsWith("UPC_")) {
|
||||
return ["UPC-LD"];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function defaultCourtFor(proceedingType: string): string {
|
||||
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
|
||||
return "upc-coa-luxembourg";
|
||||
}
|
||||
if (proceedingType === "UPC_REV") {
|
||||
return "upc-cd-paris";
|
||||
}
|
||||
return "upc-ld-muenchen";
|
||||
}
|
||||
|
||||
export async function fetchCourts(courtType: string): Promise<CourtRow[]> {
|
||||
if (courtCache.has(courtType)) return courtCache.get(courtType)!;
|
||||
try {
|
||||
const resp = await fetch(`/api/tools/courts?courtType=${encodeURIComponent(courtType)}`);
|
||||
if (!resp.ok) return [];
|
||||
const rows = (await resp.json()) as CourtRow[];
|
||||
courtCache.set(courtType, rows);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// populateCourtPicker fills the <select> for the proceeding's compatible
|
||||
// court types. The row + select IDs are passed in so each page can own
|
||||
// its own DOM scope. Visible only when the proceeding has ≥2 compatible
|
||||
// courts; otherwise hidden (server resolves the jurisdiction default).
|
||||
export async function populateCourtPicker(
|
||||
rowId: string,
|
||||
selectId: string,
|
||||
proceedingType: string,
|
||||
): Promise<void> {
|
||||
const row = document.getElementById(rowId);
|
||||
const select = document.getElementById(selectId) as HTMLSelectElement | null;
|
||||
if (!row || !select) return;
|
||||
|
||||
const types = courtTypesFor(proceedingType);
|
||||
if (types.length === 0) {
|
||||
row.style.display = "none";
|
||||
select.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const lists = await Promise.all(types.map((c) => fetchCourts(c)));
|
||||
const courts = lists.flat();
|
||||
if (courts.length <= 1) {
|
||||
row.style.display = "none";
|
||||
select.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const lang = getLang();
|
||||
const defaultID = defaultCourtFor(proceedingType);
|
||||
select.innerHTML = courts.map((c) => {
|
||||
const name = lang === "en" ? c.nameEN : c.nameDE;
|
||||
return `<option value="${escAttr(c.id)}"${c.id === defaultID ? " selected" : ""}>${escHtml(name)}</option>`;
|
||||
}).join("");
|
||||
row.style.display = "";
|
||||
}
|
||||
@@ -7,9 +7,10 @@ const ICON_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" s
|
||||
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||
const ICON_LINK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
|
||||
const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>';
|
||||
// Open-book icon for the /tools/fristenrechner?path=a "Verfahrensablauf"
|
||||
// nav entry (t-paliad-168). Distinct from ICON_BOOK (Glossar, closed)
|
||||
// so the two affordances read as different at a glance.
|
||||
// Open-book icon for the /tools/verfahrensablauf "Verfahrensablauf"
|
||||
// nav entry (t-paliad-168 → t-paliad-179 Slice 1 split). Distinct from
|
||||
// ICON_BOOK (Glossar, closed) so the two affordances read as different
|
||||
// at a glance.
|
||||
const ICON_BOOK_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H2z"/><path d="M22 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>';
|
||||
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
|
||||
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
|
||||
@@ -161,7 +162,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
Gerichte / Glossar), then content (Links / Downloads). */}
|
||||
{group("nav.group.werkzeuge", "Werkzeuge",
|
||||
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
|
||||
navItem("/tools/fristenrechner?path=a", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
|
||||
navItem("/tools/verfahrensablauf", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
|
||||
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
|
||||
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
|
||||
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +
|
||||
|
||||
@@ -207,20 +207,9 @@ export function renderFristenrechner(): string {
|
||||
Incoming — ein Ereignis hat eine Frist ausgelöst.
|
||||
</span>
|
||||
</button>
|
||||
{/* t-paliad-168 — third card: discoverable browse-/learn-mode
|
||||
entry. Drops directly into Pathway A (Verfahrensablauf
|
||||
wizard) with no save flow — mirrors the existing ad-hoc
|
||||
explore behaviour: timeline renders, save CTA stays
|
||||
disabled because there's no save intent. */}
|
||||
<button type="button" className="fristen-step2-card" data-action="browse" id="fristen-step2-browse">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">📖</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.browse.title">
|
||||
Verfahrensablauf einsehen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.browse.desc">
|
||||
Browse / Learn — sehen, was wann passiert. Keine Frist eintragen.
|
||||
</span>
|
||||
</button>
|
||||
{/* t-paliad-179 Slice 1: the third "Verfahrensablauf
|
||||
einsehen" card retired — abstract-browse intent now
|
||||
owns its own route at /tools/verfahrensablauf. */}
|
||||
</div>
|
||||
<div className="fristen-step2-shortcut">
|
||||
<div className="fristen-pathway-fork-shortcut-label" data-i18n="deadlines.pathway.shortcut.label">
|
||||
|
||||
@@ -1623,6 +1623,16 @@ export type I18nKey =
|
||||
| "projects.cards.show_all_levels"
|
||||
| "projects.cards.show_all_levels.hint"
|
||||
| "projects.cards.team"
|
||||
| "projects.chart.back"
|
||||
| "projects.chart.control.columns.auto"
|
||||
| "projects.chart.control.density.standard"
|
||||
| "projects.chart.control.export.soon"
|
||||
| "projects.chart.control.layout.horizontal"
|
||||
| "projects.chart.control.palette.default"
|
||||
| "projects.chart.error.mount"
|
||||
| "projects.chart.loading"
|
||||
| "projects.chart.notfound"
|
||||
| "projects.chart.title"
|
||||
| "projects.chip.all"
|
||||
| "projects.chip.has_open_deadlines"
|
||||
| "projects.chip.mine"
|
||||
@@ -1748,6 +1758,7 @@ export type I18nKey =
|
||||
| "projects.detail.smarttimeline.milestone.date"
|
||||
| "projects.detail.smarttimeline.milestone.description"
|
||||
| "projects.detail.smarttimeline.milestone.title"
|
||||
| "projects.detail.smarttimeline.open_chart"
|
||||
| "projects.detail.smarttimeline.section.future"
|
||||
| "projects.detail.smarttimeline.section.past"
|
||||
| "projects.detail.smarttimeline.section.undated"
|
||||
@@ -2013,6 +2024,9 @@ export type I18nKey =
|
||||
| "theme.toggle.cycle.light"
|
||||
| "theme.toggle.dark"
|
||||
| "theme.toggle.light"
|
||||
| "tools.verfahrensablauf.heading"
|
||||
| "tools.verfahrensablauf.subtitle"
|
||||
| "tools.verfahrensablauf.title"
|
||||
| "unit_role.attorney"
|
||||
| "unit_role.lead"
|
||||
| "unit_role.pa"
|
||||
|
||||
95
frontend/src/projects-chart.tsx
Normal file
95
frontend/src/projects-chart.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-177 Slice 1 — Project Timeline / Chart standalone page.
|
||||
//
|
||||
// Pure shell: header / controls scaffold (inert chips for the
|
||||
// vertical-toggle, density and palette pickers, which Slice 3 wires
|
||||
// live) + a chart host that client/projects-chart.ts mounts the SVG
|
||||
// renderer into. Project metadata is loaded client-side so the same
|
||||
// dist/projects-chart.html serves every {id}.
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
|
||||
export function renderProjectsChart(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="projects.chart.title">Projekt-Chart — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/projects" />
|
||||
<BottomNav currentPath="/projects" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page smart-timeline-chart-page">
|
||||
<div className="container">
|
||||
<a
|
||||
id="projects-chart-back-link"
|
||||
href="/projects"
|
||||
className="back-link"
|
||||
data-i18n="projects.chart.back"
|
||||
>
|
||||
← Zurück zum Projekt
|
||||
</a>
|
||||
|
||||
<div id="projects-chart-loading" className="entity-loading">
|
||||
<p data-i18n="projects.chart.loading">Lädt…</p>
|
||||
</div>
|
||||
|
||||
<div id="projects-chart-notfound" className="entity-empty" style="display:none">
|
||||
<p data-i18n="projects.chart.notfound">Projekt nicht gefunden oder keine Berechtigung.</p>
|
||||
</div>
|
||||
|
||||
<div id="projects-chart-body" style="display:none">
|
||||
<header className="smart-timeline-chart-header">
|
||||
<h1 id="projects-chart-title" />
|
||||
<span id="projects-chart-meta" className="smart-timeline-chart-meta" />
|
||||
</header>
|
||||
|
||||
<div className="smart-timeline-chart-controls" id="projects-chart-controls">
|
||||
{/* Slice 1: chips render inert. Slice 3 wires them to
|
||||
density / palette / zoom state. The presence keeps
|
||||
the surface visually stable when controls light up. */}
|
||||
<span className="chip-inert" data-i18n="projects.chart.control.layout.horizontal" title="Slice 3">
|
||||
Layout: Horizontal
|
||||
</span>
|
||||
<span className="chip-inert" data-i18n="projects.chart.control.columns.auto" title="Slice 3">
|
||||
Spalten: Auto
|
||||
</span>
|
||||
<span className="chip-inert" data-i18n="projects.chart.control.density.standard" title="Slice 3">
|
||||
Dichte: Standard
|
||||
</span>
|
||||
<span className="chip-inert" data-i18n="projects.chart.control.palette.default" title="Slice 3">
|
||||
Palette: Standard
|
||||
</span>
|
||||
<span className="chip-inert" data-i18n="projects.chart.control.export.soon" title="Slice 2">
|
||||
Export ↓ (Slice 2)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div id="projects-chart-host" className="smart-timeline-chart-host" />
|
||||
|
||||
<p id="projects-chart-undated" className="smart-timeline-chart-undated-hint" style="display:none" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/projects-chart.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -104,6 +104,19 @@ export function renderProjectsDetail(): string {
|
||||
<button type="button" className="btn-primary btn-cta-lime btn-small" id="smart-timeline-add-btn" data-i18n="projects.detail.smarttimeline.add.cta">
|
||||
+ Eintrag
|
||||
</button>
|
||||
{/* t-paliad-177 — link to the standalone /chart page.
|
||||
Opens in a new tab per design §8.1; the Verlauf
|
||||
embed itself stays vertical-DOM-only. */}
|
||||
<a
|
||||
id="smart-timeline-open-chart"
|
||||
className="btn-secondary btn-small"
|
||||
href="#"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-i18n="projects.detail.smarttimeline.open_chart"
|
||||
>
|
||||
Als Chart anzeigen ↗
|
||||
</a>
|
||||
</div>
|
||||
<div id="project-events-filter-bar" />
|
||||
<div id="project-smart-timeline" className="smart-timeline" />
|
||||
|
||||
@@ -14172,3 +14172,186 @@ dialog.quick-add-sheet::backdrop {
|
||||
display: block;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Smart Timeline Chart (t-paliad-177 Slice 1)
|
||||
Horizontal SVG Gantt renderer mounted on /projects/{id}/chart.
|
||||
Token surface lets future palette / density slices override
|
||||
colour and lane height purely via CSS-var swap — see
|
||||
docs/design-project-chart-2026-05-09.md §5 + §6.
|
||||
============================================================ */
|
||||
.smart-timeline-chart-page {
|
||||
padding: 1rem 0 3rem;
|
||||
}
|
||||
.smart-timeline-chart-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.smart-timeline-chart-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.smart-timeline-chart-meta {
|
||||
color: var(--color-text-muted, #777);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.smart-timeline-chart-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.smart-timeline-chart-controls .chip-inert {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-subtle, #f7f7f7);
|
||||
color: var(--color-text-muted, #777);
|
||||
font-size: 0.85rem;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.smart-timeline-chart-host {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 8px;
|
||||
background: var(--chart-bg, var(--color-bg, #fff));
|
||||
padding: 0;
|
||||
min-height: 200px;
|
||||
}
|
||||
.smart-timeline-chart-message {
|
||||
padding: 2rem 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted, #777);
|
||||
}
|
||||
.smart-timeline-chart-message--error {
|
||||
color: #c0392b;
|
||||
}
|
||||
.smart-timeline-chart {
|
||||
/* Default palette tokens — kept here so Slice 3 can swap them via
|
||||
[data-palette="..."] selectors without touching the renderer.
|
||||
Reference --color-* family so dark mode flips for free. */
|
||||
--chart-mark-deadline: var(--color-accent, #c6f41c);
|
||||
--chart-mark-appointment: #f5a623;
|
||||
--chart-mark-milestone: var(--hlc-midnight, #1a2233);
|
||||
--chart-mark-projected: var(--color-text-subtle, #999);
|
||||
--chart-mark-overdue: #d62828;
|
||||
--chart-mark-done: var(--color-accent, #c6f41c);
|
||||
--chart-today-rule: var(--color-accent, #c6f41c);
|
||||
--chart-grid-line: var(--color-border, #e0e0e0);
|
||||
--chart-lane-label: var(--color-text-muted, #777);
|
||||
--chart-tick-label: var(--color-text-muted, #777);
|
||||
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: var(--chart-mark-projected);
|
||||
font-family: inherit;
|
||||
}
|
||||
.smart-timeline-chart .chart-tick {
|
||||
stroke: var(--chart-grid-line);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 2 3;
|
||||
}
|
||||
.smart-timeline-chart .chart-tick--year {
|
||||
stroke: var(--chart-grid-line);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
.smart-timeline-chart .chart-tick-label {
|
||||
font-size: 0.75rem;
|
||||
fill: var(--chart-tick-label);
|
||||
}
|
||||
.smart-timeline-chart .chart-lane-separator {
|
||||
stroke: var(--chart-grid-line);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.smart-timeline-chart .chart-lane-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
fill: var(--chart-lane-label);
|
||||
}
|
||||
.smart-timeline-chart .chart-today-rule {
|
||||
stroke: var(--chart-today-rule);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.smart-timeline-chart .chart-today-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
fill: var(--chart-today-rule);
|
||||
}
|
||||
.smart-timeline-chart .chart-mark {
|
||||
cursor: pointer;
|
||||
}
|
||||
.smart-timeline-chart .chart-mark:focus-visible {
|
||||
outline: 2px solid var(--color-accent, #c6f41c);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.smart-timeline-chart .chart-mark-dot {
|
||||
fill: var(--chart-mark-deadline);
|
||||
stroke: var(--chart-mark-deadline);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.smart-timeline-chart .chart-mark--deadline.chart-mark--status-open .chart-mark-dot {
|
||||
fill: var(--chart-bg, #fff);
|
||||
stroke: var(--chart-mark-deadline);
|
||||
}
|
||||
.smart-timeline-chart .chart-mark--deadline.chart-mark--status-overdue .chart-mark-dot {
|
||||
fill: var(--chart-mark-overdue);
|
||||
stroke: var(--chart-mark-overdue);
|
||||
}
|
||||
.smart-timeline-chart .chart-mark--deadline.chart-mark--status-done .chart-mark-dot {
|
||||
fill: var(--chart-mark-done);
|
||||
stroke: var(--chart-mark-done);
|
||||
}
|
||||
.smart-timeline-chart .chart-mark--appointment .chart-mark-dot {
|
||||
fill: var(--chart-mark-appointment);
|
||||
stroke: var(--chart-mark-appointment);
|
||||
}
|
||||
.smart-timeline-chart .chart-mark-diamond {
|
||||
fill: var(--chart-mark-milestone);
|
||||
stroke: var(--chart-mark-milestone);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.smart-timeline-chart .chart-mark-hatched {
|
||||
color: var(--chart-mark-projected); /* drives the pattern stroke via currentColor */
|
||||
stroke: var(--chart-mark-projected);
|
||||
stroke-width: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.smart-timeline-chart .chart-mark--projected.chart-mark--status-predicted_overdue .chart-mark-hatched {
|
||||
color: var(--chart-mark-overdue);
|
||||
stroke: var(--chart-mark-overdue);
|
||||
}
|
||||
.smart-timeline-chart .chart-mark-dashed {
|
||||
fill: none;
|
||||
stroke: var(--chart-mark-projected);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 3 2;
|
||||
}
|
||||
.smart-timeline-chart .chart-mark--undated .chart-mark-dot,
|
||||
.smart-timeline-chart .chart-mark--undated .chart-mark-diamond,
|
||||
.smart-timeline-chart .chart-mark--undated .chart-mark-hatched,
|
||||
.smart-timeline-chart .chart-mark--undated .chart-mark-dashed {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.smart-timeline-chart-undated-hint {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #777);
|
||||
}
|
||||
|
||||
/* Mobile — design §9: force a vertical-only fallback notice below 640px
|
||||
instead of trying to render horizontal Gantt at phone width. Slice 3
|
||||
wires the actual layout flip; Slice 1 just nudges the user. */
|
||||
@media (max-width: 640px) {
|
||||
.smart-timeline-chart-host {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
207
frontend/src/verfahrensablauf.tsx
Normal file
207
frontend/src/verfahrensablauf.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Slice 1 (t-paliad-179) — the dedicated abstract-browse surface for
|
||||
// procedural shape. Same backend (POST /api/tools/fristenrechner) +
|
||||
// same renderer module (./client/views/verfahrensablauf-core) as
|
||||
// /tools/fristenrechner; this page strips the Step 1 Akte picker /
|
||||
// Step 2 cards / Pathway A wizard / Pathway B cascade / save modal,
|
||||
// leaving just: proceeding-type tile picker + trigger date + court
|
||||
// picker + result panel. Variant chips, lane view and compare arrive in
|
||||
// Slices 2-4.
|
||||
|
||||
interface ProceedingDef {
|
||||
code: string;
|
||||
i18nKey: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function proceedingBtn(p: ProceedingDef): string {
|
||||
return (
|
||||
<button type="button" className="proceeding-btn" data-code={p.code}>
|
||||
<strong data-i18n={p.i18nKey}>{p.name}</strong>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const UPC_TYPES: ProceedingDef[] = [
|
||||
{ code: "UPC_INF", i18nKey: "deadlines.upc_inf", name: "Verletzungsverfahren" },
|
||||
{ code: "UPC_REV", i18nKey: "deadlines.upc_rev", name: "Nichtigkeitsklage" },
|
||||
{ code: "UPC_PI", i18nKey: "deadlines.upc_pi", name: "Einstw. Maßnahmen" },
|
||||
{ code: "UPC_APP", i18nKey: "deadlines.upc_app", name: "Berufung" },
|
||||
{ code: "UPC_DAMAGES", i18nKey: "deadlines.upc_damages", name: "Schadensbemessung" },
|
||||
{ code: "UPC_DISCOVERY", i18nKey: "deadlines.upc_discovery", name: "Bucheinsicht" },
|
||||
{ code: "UPC_COST_APPEAL", i18nKey: "deadlines.upc_cost_appeal", name: "Berufung Kosten" },
|
||||
{ code: "UPC_APP_ORDERS", i18nKey: "deadlines.upc_app_orders", name: "Berufung Anordnungen" },
|
||||
];
|
||||
|
||||
const DE_TYPES: ProceedingDef[] = [
|
||||
{ code: "DE_INF", i18nKey: "deadlines.de_inf", name: "Verletzungsklage (LG)" },
|
||||
{ code: "DE_INF_OLG", i18nKey: "deadlines.de_inf_olg", name: "Berufung OLG" },
|
||||
{ code: "DE_INF_BGH", i18nKey: "deadlines.de_inf_bgh", name: "Revision/NZB BGH" },
|
||||
{ code: "DE_NULL", i18nKey: "deadlines.de_null", name: "Nichtigkeitsverfahren" },
|
||||
{ code: "DE_NULL_BGH", i18nKey: "deadlines.de_null_bgh", name: "Berufung BGH (Nichtigk.)" },
|
||||
];
|
||||
|
||||
const EPA_TYPES: ProceedingDef[] = [
|
||||
{ code: "EPA_OPP", i18nKey: "deadlines.epa_opp", name: "Einspruchsverfahren" },
|
||||
{ code: "EPA_APP", i18nKey: "deadlines.epa_app", name: "Beschwerdeverfahren" },
|
||||
{ code: "EP_GRANT", i18nKey: "deadlines.ep_grant", name: "EP-Erteilungsverfahren" },
|
||||
];
|
||||
|
||||
const DPMA_TYPES: ProceedingDef[] = [
|
||||
{ code: "DPMA_OPP", i18nKey: "deadlines.dpma_opp", name: "Einspruch DPMA" },
|
||||
{ code: "DPMA_BPATG_BESCHWERDE", i18nKey: "deadlines.dpma_bpatg_beschwerde", name: "Beschwerde BPatG (DPMA)" },
|
||||
{ code: "DPMA_BGH_RB", i18nKey: "deadlines.dpma_bgh_rb", name: "Rechtsbeschwerde BGH" },
|
||||
];
|
||||
|
||||
export function renderVerfahrensablauf(): string {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="tools.verfahrensablauf.title">Verfahrensablauf — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/tools/verfahrensablauf" />
|
||||
<BottomNav currentPath="/tools/verfahrensablauf" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="tools.verfahrensablauf.heading">Verfahrensablauf</h1>
|
||||
<p className="tool-subtitle" data-i18n="tools.verfahrensablauf.subtitle">
|
||||
Typischen Verfahrensablauf einsehen — Verfahrensart wählen, Datum optional setzen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Verfahrensart picker (single-tile mode — same DOM ids as
|
||||
/tools/fristenrechner so the shared renderer module and
|
||||
court-picker primitives bind without parameterisation). */}
|
||||
<div className="fristen-wizard" id="verfahrensablauf-wizard" data-mode="procedure">
|
||||
<div className="wizard-step" id="step-1">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">1</span>
|
||||
<span data-i18n="deadlines.step1">Verfahrensart wählen</span>
|
||||
</h3>
|
||||
|
||||
<div className="proceeding-group" data-forum="upc">
|
||||
<h4 data-i18n="deadlines.upc">UPC</h4>
|
||||
<div className="proceeding-btns">
|
||||
{UPC_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="de">
|
||||
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
|
||||
<div className="proceeding-btns">
|
||||
{DE_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="epa">
|
||||
<h4 data-i18n="deadlines.epa">EPA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{EPA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="dpma">
|
||||
<h4 data-i18n="deadlines.dpma">DPMA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{DPMA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
|
||||
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
|
||||
<strong className="proceeding-summary-name" id="proceeding-summary-name">—</strong>
|
||||
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
|
||||
data-i18n="deadlines.proceeding.reselect">
|
||||
Anderes Verfahren wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-2" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">2</span>
|
||||
<span data-i18n="deadlines.step2">Ausgangsdatum eingeben</span>
|
||||
</h3>
|
||||
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="trigger-event" className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</label>
|
||||
<span id="trigger-event" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
|
||||
<input type="date" id="trigger-date" className="date-input" value={today} />
|
||||
</div>
|
||||
<div className="date-field-row" id="court-picker-row" style="display:none">
|
||||
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
|
||||
<select id="court-picker" className="date-input"></select>
|
||||
</div>
|
||||
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
|
||||
Fristen berechnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-3" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">3</span>
|
||||
<span data-i18n="deadlines.step3">Ergebnis</span>
|
||||
</h3>
|
||||
|
||||
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
|
||||
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="columns" checked />
|
||||
<span data-i18n="deadlines.view.columns">Spalten</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
</div>
|
||||
|
||||
<div className="fristen-result-actions">
|
||||
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
|
||||
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 6 2 18 2 18 9"></polyline>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
|
||||
<rect x="6" y="14" width="12" height="8"></rect>
|
||||
</svg>
|
||||
<span data-i18n="deadlines.print">Drucken</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/verfahrensablauf.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
16
internal/handlers/chart_pages.go
Normal file
16
internal/handlers/chart_pages.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
|
||||
// t-paliad-177 Slice 1 — Project Timeline / Chart standalone page.
|
||||
//
|
||||
// Serves the statically-generated dist/projects-chart.html shell for
|
||||
// GET /projects/{id}/chart. The visibility check happens client-side
|
||||
// against the existing /api/projects/{id}/timeline endpoint, which
|
||||
// already gates on project visibility through ProjectionService.For.
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
|
||||
|
||||
func handleProjectsChartPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/projects-chart.html")
|
||||
}
|
||||
@@ -9,10 +9,29 @@ import (
|
||||
)
|
||||
|
||||
// Fristenrechner page handler: serves the static HTML. No DB dependency.
|
||||
//
|
||||
// Back-compat: the pre-split sidebar entry for "Verfahrensablauf" pointed at
|
||||
// /tools/fristenrechner?path=a. After the t-paliad-179 split, that landing is
|
||||
// owned by /tools/verfahrensablauf. A naked ?path=a (no Akte context — i.e.
|
||||
// no ?project=) is the bookmarked-legacy-entry case → 302 to the new route.
|
||||
// ?project=<uuid>&path=a is the Akte-mode internal wizard pathway and stays
|
||||
// on /tools/fristenrechner so the wizard state survives a refresh.
|
||||
func handleFristenrechnerPage(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
if q.Get("path") == "a" && q.Get("project") == "" {
|
||||
http.Redirect(w, r, "/tools/verfahrensablauf", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, "dist/fristenrechner.html")
|
||||
}
|
||||
|
||||
// Verfahrensablauf page handler (t-paliad-179 Slice 1): the dedicated
|
||||
// abstract-browse surface for procedural shape. No DB dependency — the page
|
||||
// shell is static HTML; the calculator API still drives the timeline render.
|
||||
func handleVerfahrensablaufPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/verfahrensablauf.html")
|
||||
}
|
||||
|
||||
// POST /api/tools/fristenrechner — calculate the UI timeline for a proceeding.
|
||||
//
|
||||
// Phase C: routes through FristenrechnerService which pulls rules from
|
||||
|
||||
@@ -160,6 +160,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /tools/kostenrechner", handleKostenrechnerPage)
|
||||
protected.HandleFunc("POST /api/tools/kostenrechner", handleKostenrechnerAPI)
|
||||
protected.HandleFunc("GET /tools/fristenrechner", handleFristenrechnerPage)
|
||||
protected.HandleFunc("GET /tools/verfahrensablauf", handleVerfahrensablaufPage)
|
||||
protected.HandleFunc("POST /api/tools/fristenrechner", handleFristenrechnerAPI)
|
||||
protected.HandleFunc("POST /api/tools/fristenrechner/calculate-rule", handleFristenrechnerCalculateRule)
|
||||
protected.HandleFunc("GET /api/tools/proceeding-types", handleProceedingTypes)
|
||||
@@ -359,6 +360,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /projects/{id}/notes", gateOnboarded(handleProjectsDetailPage))
|
||||
protected.HandleFunc("GET /projects/{id}/checklists", gateOnboarded(handleProjectsDetailPage))
|
||||
protected.HandleFunc("GET /projects/{id}/team", gateOnboarded(handleProjectsDetailPage))
|
||||
// t-paliad-177 — standalone Project Timeline / Chart page (Slice 1).
|
||||
// Horizontal SVG renderer mounted client-side; reuses the existing
|
||||
// /api/projects/{id}/timeline JSON endpoint for data.
|
||||
protected.HandleFunc("GET /projects/{id}/chart", gateOnboarded(handleProjectsChartPage))
|
||||
protected.HandleFunc("GET /projects/{id}/deadlines/new", gateOnboarded(handleDeadlinesNewPage))
|
||||
protected.HandleFunc("GET /projects/{id}/appointments/new", gateOnboarded(handleAppointmentsNewPage))
|
||||
|
||||
|
||||
83
internal/handlers/verfahrensablauf_test.go
Normal file
83
internal/handlers/verfahrensablauf_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// /tools/fristenrechner?path=a was the pre-split sidebar entry for the
|
||||
// "Verfahrensablauf" surface. After t-paliad-179 Slice 1 that intent
|
||||
// owns its own /tools/verfahrensablauf route — so a naked ?path=a hit
|
||||
// must 302 to the new URL to preserve bookmarked legacy links.
|
||||
//
|
||||
// The Akte-mode internal wizard pathway (?project=<uuid>&path=a) is
|
||||
// NOT a top-level entry — it's wizard state set by client-side
|
||||
// history.replaceState. That URL must keep serving the fristenrechner
|
||||
// shell so a mid-wizard refresh doesn't bounce away.
|
||||
func TestHandleFristenrechnerPage_LegacyPathARedirect(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
wantStatus int
|
||||
wantLoc string
|
||||
}{
|
||||
{
|
||||
name: "naked path=a → redirect",
|
||||
path: "/tools/fristenrechner?path=a",
|
||||
wantStatus: http.StatusFound,
|
||||
wantLoc: "/tools/verfahrensablauf",
|
||||
},
|
||||
{
|
||||
name: "path=a with project= → no redirect (Akte-mode wizard)",
|
||||
path: "/tools/fristenrechner?project=abc-123&path=a",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "no path param → no redirect",
|
||||
path: "/tools/fristenrechner",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "path=b → no redirect (Pathway B stays)",
|
||||
path: "/tools/fristenrechner?path=b",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleFristenrechnerPage(w, req)
|
||||
if w.Code != tc.wantStatus {
|
||||
// http.ServeFile may write 404 if dist/fristenrechner.html
|
||||
// is missing under `go test` (CI runs without a frontend
|
||||
// build). We only care that we did NOT redirect in those
|
||||
// cases — collapse 200 and 404 into "not a redirect".
|
||||
if tc.wantStatus == http.StatusOK && w.Code != http.StatusFound {
|
||||
return
|
||||
}
|
||||
t.Fatalf("status = %d, want %d", w.Code, tc.wantStatus)
|
||||
}
|
||||
if tc.wantLoc != "" {
|
||||
if got := w.Header().Get("Location"); got != tc.wantLoc {
|
||||
t.Fatalf("Location = %q, want %q", got, tc.wantLoc)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The new /tools/verfahrensablauf route registers as a 1-liner page
|
||||
// handler that ServeFiles dist/verfahrensablauf.html. We assert the
|
||||
// handler does NOT redirect — if the dist artefact is missing under
|
||||
// `go test`, ServeFile may return 404, but it must never return a 3xx.
|
||||
func TestHandleVerfahrensablaufPage_NoRedirect(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/tools/verfahrensablauf", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleVerfahrensablaufPage(w, req)
|
||||
if w.Code >= 300 && w.Code < 400 {
|
||||
t.Fatalf("verfahrensablauf must not redirect; got %d → %s",
|
||||
w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user