Compare commits

...

15 Commits

Author SHA1 Message Date
mAi
f5eb84718a chore(t-paliad-179): sidebar maps Verfahrensablauf 1:1 to its own URL
Sidebar.tsx href flips from /tools/fristenrechner?path=a to
/tools/verfahrensablauf. The two Werkzeuge entries now resolve to
distinct pathnames, so the SSR navItem helper picks the right active
class on its own — fixVerfahrensablaufActive (which compared search
params client-side to disambiguate) is deleted along with its call
in initSidebar.
2026-05-13 00:19:16 +02:00
mAi
1255ee049f feat(t-paliad-179): /tools/verfahrensablauf page (TSX + client + build)
The new abstract-browse surface. TSX shell hosts:

  - header (h1 + subtitle)
  - jurisdiction-tabbed proceeding-tile picker (UPC / DE / EPA / DPMA)
  - trigger date input
  - court picker (visible only for proceedings with multiple
    compatible courts — UPC_REV across CD + LD seats etc.)
  - view toggle (Spalten / Zeitstrahl)
  - result container

client/verfahrensablauf.ts wires picker click → calculateDeadlines →
renderColumnsBody/renderTimelineBody via the shared core. Pre-selects
the first proceeding tile on load so users see a timeline immediately,
matching /tools/fristenrechner's auto-render behaviour. No Akte
picker, no Pathway B cascade, no save modal, no anchor-override edit
— Slice 1 is the structural foundation; variant chips + lane view
(Slice 3) and compare (Slice 4) layer on top in later commits.

build.ts wires the new entrypoint + write step. i18n adds
tools.verfahrensablauf.title / .heading / .subtitle in DE + EN; the
existing nav.verfahrensablauf reused.
2026-05-13 00:19:10 +02:00
mAi
0105d35f0c refactor(t-paliad-179): fristenrechner consumes shared renderer module
client/fristenrechner.ts imports renderTimelineBody / renderColumnsBody
/ deadlineCardHtml / formatDate / partyBadge / escAttr / escHtml /
calculateDeadlines / populateCourtPicker from views/verfahrensablauf-
core, deleting the local copies (~480 lines out). The click-to-edit
anchor-override path stays wired by passing { editable: true } to the
shared renderers; the local anchor-override Map / openInlineDateEditor
/ render-on-override path are unchanged.

The "Verfahrensablauf einsehen" Step 2 card (t-paliad-168) is retired
— TSX markup gone, click handler gone. The abstract-browse intent
lives at /tools/verfahrensablauf now (Slice 1 design §9, §10).
2026-05-13 00:19:00 +02:00
mAi
0531e5dbf6 feat(t-paliad-179): lift Fristenrechner renderers into shared core module
frontend/src/client/views/verfahrensablauf-core.ts — pure-functional
module with the proceeding-timeline rendering surface:

  - DeadlineResponse / CalculatedDeadline / CourtRow types
  - escAttr / escHtml / formatDate / partyBadge helpers
  - deadlineCardHtml(dl, { showParty, editable })
  - renderTimelineBody(data, opts)
  - renderColumnsBody(data, opts)
  - calculateDeadlines(params) — POST /api/tools/fristenrechner wrapper
  - courtTypesFor / defaultCourtFor / fetchCourts (cache)
  - populateCourtPicker(rowId, selectId, proceedingType)

Both /tools/fristenrechner and /tools/verfahrensablauf import from
here. No module-level mutable state — the per-page concerns
(anchorOverrides, lastResponse, Akte save) stay in the consumers.

The deadlineCardHtml signature carries an editable flag so the click-
to-edit anchor-override affordance is opt-in per page: fristenrechner
enables it, verfahrensablauf (Slice 1 scope) doesn't.
2026-05-13 00:18:52 +02:00
mAi
0099e2f28c feat(t-paliad-179): register /tools/verfahrensablauf + 302 legacy ?path=a
Backend half of Slice 1: a new dedicated route owns the abstract-browse
intent that was previously emulated by /tools/fristenrechner?path=a +
client-side fix-up. The page handler is a 1-liner that serves
dist/verfahrensablauf.html (no DB dependency).

A naked ?path=a on /tools/fristenrechner now 302s to the new URL so
bookmarked legacy links survive. ?project=<uuid>&path=a still serves
the fristenrechner shell because that's wizard state set by client-
side history.replaceState during Akte-mode Pathway A — refreshing
mid-wizard must not bounce away.

Test covers all four query shapes: naked path=a → redirect, path=a
with project → no redirect, no params → no redirect, path=b → no
redirect.
2026-05-13 00:18:42 +02:00
mAi
cd1a70d08c Merge: t-paliad-178 — Tools surface cleanup design doc (DESIGN READY FOR REVIEW) 2026-05-13 00:00:41 +02:00
mAi
bdb3d8a425 Merge: t-paliad-177 Slice 1 — Project Timeline / Chart (SVG Gantt + standalone /projects/{id}/chart page) 2026-05-12 14:14:01 +02:00
mAi
30f7031e99 feat(t-paliad-177): chart page TSX + boot client + i18n + Verlauf link
Wires the chart surface end-to-end:

- frontend/src/projects-chart.tsx — standalone page shell with title
  row, inert control chips (Slice 3 wires them live), undated hint slot,
  and the mount target for the SVG renderer.
- frontend/src/client/projects-chart.ts — boot client that parses the
  project id from the URL, loads project metadata for the header,
  mounts the renderer, and reveals the undated hint when the layout
  reports clipped/undated rows.
- frontend/build.ts — registers the new bundle + HTML output.
- frontend/src/client/i18n.ts — 11 new DE+EN keys under projects.chart.*
  + projects.detail.smarttimeline.open_chart (the Verlauf link).
- frontend/src/projects-detail.tsx — "Als Chart anzeigen ↗" link in
  the SmartTimeline controls, opens /chart in a new tab.
- frontend/src/client/projects-detail.ts — resolves the chart href in
  renderHeader once project.id is known.

`bun run build` clean, `go build ./...` clean, 27/27 chart tests pass.

Design ref: docs/design-project-chart-2026-05-09.md §8.1 + §8.2 + §12.
2026-05-12 14:12:20 +02:00
mAi
8e9cde6d52 design(t-paliad-178): Tools surface cleanup — split Fristenrechner / Verfahrensablauf
Inventor pass for t-paliad-178. Two intents (deadline determination vs
abstract procedural shape browse) get two dedicated routes:

- /tools/fristenrechner — keeps deadline-determination, gains Step 0
  ("Abstrakt oder Akte?") above today's Step 1.
- /tools/verfahrensablauf — new dedicated abstract-browse surface with
  variant chips (with_ccr / with_cci / with_amend), consolidated-vs-lane
  view, and side-by-side compare.

§0 premise audit corrects three things the task brief got wrong:
  1. projects.court is free-text, not FK — no silent court_id auto-pick.
  2. projects.proceeding_type_id points at litigation-category rows, not
     fristenrechner-category — a mapping helper (litigation × jurisdiction
     → fristenrechner code) is required.
  3. condition_flag variants only exist on UPC_INF + UPC_REV; every other
     proceeding renders a single canonical timeline. Variant chips honour
     this — no dead chips on DE_INF / EPA_OPP / DPMA_*.

Sliced into 4 independent merges: Slice 1 (route + shell split) is the
structural foundation; Slices 2-4 layer Step 0 / variant chips / compare.

DESIGN ONLY — no implementation. Awaiting m's go/no-go before coder shift.
2026-05-12 14:10:20 +02:00
mAi
a3adb6b13b feat(t-paliad-177): chart SVG paint() + mount() + palette CSS tokens
Extends shape-timeline-chart.ts with the DOM-mutation half of the
renderer:

- paint(layout, root, events): hand-rolled SVG using namespaced
  document.createElementNS. Idempotent (clears prior children),
  layers <defs> → grid+axis+lanes → today rule → marks. Each mark
  wraps in <g> with data-* attrs for delegated event handling.
- mount(host, opts): fetches /api/projects/{id}/timeline (defensive
  for both legacy []TimelineEvent and Slice-4 envelope shapes),
  computes a today-1y..today+1y default range (design Q8), wires
  resize debouncing + click delegation. Returns a handle with
  refresh / dispose / getLayout.

CSS palette tokens swap purely via --chart-* custom properties on
.smart-timeline-chart, so future palette slices (Slice 3) toggle
attributes without touching the renderer. Deadlines colour-saturate
by status (open = ring, done = filled, overdue = red). Projected
rows use the hatched/dashed-dot variants from §6.2.

Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §5 + §6.
2026-05-12 14:09:43 +02:00
mAi
ed4e731333 feat(t-paliad-177): chart layout() pure-function + 27 table-driven tests
Slice 1 load-bearing math. Translates TimelineEvent[] + LaneInfo[] +
viewport into deterministic SVG-ready geometry: axis ticks (month /
quarter / year by total span), lane row y/height, mark x/y/shape per
kind+status, today rule. No DOM access — paint() will read this and
mutate the SVG separately.

Tests pin canvas geometry, pxPerDay math, today-rule clipping, lane
stacking, mark bucketing by lane_id, out-of-range clipping, undated
zone, mark-shape mapping, axis tick density. Date math is UTC
throughout so DST doesn't drift day-deltas.

Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §15.
2026-05-12 14:07:48 +02:00
mAi
b0a6b0998f feat(t-paliad-177): chart page handler + GET /projects/{id}/chart route
Slice 1 backend slice. Tiny static-file server for the new standalone
chart page; visibility piggybacks on the existing /api/projects/{id}/
timeline endpoint (gated through ProjectionService.For), so no new
auth surface.

Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
2026-05-12 14:05:52 +02:00
m
54b227ce7b Merge: t-paliad-176 — FilterBar regression bundle (m/paliad#32 + #33)
maxwell diagnosed and fixed two regressions m hit @ 18:32:

#32 — FilterBar timeline filters don't narrow Verlauf:
- The Verlauf bar mounted only 'time' + 'project_event_kind' axes; the
  Slice 2/3 timeline_status + timeline_track chips never rendered.
- The customRunner drained predicates into the legacy loadEvents()
  array, but the SmartTimeline render reads timelineRows. Filter pass
  was landing on a dead branch.

#33 — Nur direkt always includes sub-projects:
- Frontend correctly sent ?direct_only=true; handler parsed it; the
  loadProjectTrack SQL filter respected DirectOnly. But Slice 3's
  CCR-children loading (forCaseLevel) and Slice 4's lane-per-child
  loading (forAggregatedLevel) ran unconditionally regardless of the
  flag.

Fix:
- Backend: ProjectionService.For() short-circuits to new
  forDirectSelfOnly when opts.DirectOnly. Single 'self' lane, no
  CCR/parent_context/child-case aggregation. Level-policy kind+status
  filter still applies. Added ProjectEventType field to TimelineEvent
  so frontend can filter by project_event_kind end-to-end.
- Frontend: mountFilterBar's customRunner gains a 'state' arg for
  first-run hydration; BarHandle gains getState(). Verlauf bar now
  mounts all three axes (time, project_event_kind, timeline_status,
  timeline_track). customRunner drains state into verlaufFilters;
  renderTimeline calls applyTimelineRowFilters before passing rows
  to renderSmartTimeline.
- Tests: two new live-DB subtests in TestProjectionService_LevelAggregation_Live
  pin the DirectOnly contract at Patent and Case level (+89 LoC).

Verified: go build/vet/test clean, bun build clean. (Pre-existing
pq type-inference seed issue in unrelated projection_service_test
remains; verified independent of this change via stash.)

3-way merge with main preserves faraday's chart design doc unchanged.

Single commit c2f1c29 from mai/maxwell/bug-bundle-filterbar.
2026-05-09 18:53:39 +02:00
m
c2f1c29b10 fix(t-paliad-176): FilterBar timeline narrowing + Nur-direkt subtree skip
Two regressions from SmartTimeline Slices 2-4 dogfood @ 2026-05-09:

m/paliad#32 — clicking timeline_status / timeline_track / project_event_kind
chips changed URL params but the rendered list never narrowed. Two
causes: (1) the Verlauf bar mounted only "time" + "project_event_kind"
axes — the timeline_status / timeline_track chips never appeared. (2)
the customRunner drained predicates into `loadEvents` which writes the
legacy `events` array; the SmartTimeline render reads `timelineRows`,
so the filter pass was a dead branch.

Fix: mount all three axes on the bar; rewrite customRunner to drain
state into `verlaufFilters`; renderTimeline applies them client-side
via `applyTimelineRowFilters` before handing rows to renderSmartTimeline.
project_event_kind is forwarded through the substrate-shaped predicate
map (effective.filter.predicates.project_event.event_types);
timeline_status / timeline_track sit on raw BarState — the customRunner
signature now accepts the BarState snapshot as a second arg so the
bar's first run (before the handle is assigned) can read them.

Backend adds `ProjectEventType` to TimelineEvent + frontend
TimelineEvent — needed so the project_event_kind chip can match against
the underlying paliad.project_events.event_type for milestone rows.

m/paliad#33 — "Nur direkt" pill flipped subtreeMode and re-fetched the
timeline with ?direct_only=true, but ProjectionService.For honoured the
flag only at the deadline / appointment / project_events SQL level. CCR
sub-project lanes (Slice 3) and child-case lanes (Slice 4) loaded
unconditionally, so the "direct" view still showed everything.

Fix: `For` short-circuits to `forDirectSelfOnly` whenever DirectOnly is
set. Single "self" lane, no CCR / parent_context / child-case
aggregation. The level-policy kind/status filter still applies at
higher levels so a Patent-level direct view doesn't leak off_script
custom milestones the aggregated view filters out.

Tests: two new live-DB subtests in TestProjectionService_LevelAggregation_Live
pin the contract — Patent direct_only collapses to a single 'self' lane
and excludes child-case events; Case-A direct_only excludes the CCR
child's milestones (with subtree default still surfacing them).

Build: go build/vet/test clean. bun run build clean (2171 keys).
2026-05-09 18:52:01 +02:00
m
17e96b7a1c Merge: t-paliad-177 — Project Timeline / Chart design doc (DESIGN READY FOR REVIEW)
faraday's inventor pass on m's 18:32 ask. Visualisation layer above the
SmartTimeline substrate; ADDS surfaces (does not replace Verlauf).

607-line doc covers:
- Renderer choice: SVG hand-rolled for horizontal Gantt; DOM (existing
  shape-timeline.ts) for vertical. No D3 / Chart.js dep.
- Layouts: vertical (existing) + horizontal Gantt-strip (new).
- 5 palette presets, 6 export formats (client SVG/PNG/PDF + server
  CSV/JSON/iCal). Chromium-on-Dokploy ruled out for v1.
- Three surfaces: Verlauf embed, standalone /projects/{id}/chart (new),
  Custom Views shape='timeline' (Slice 4).
- 4-slice phasing, ~700 LoC Slice 1 (standalone page + horizontal SVG).
- 12 open questions parked for m's review.

Slice 1 NOT auto-spawned — inventor → coder gate stands.

Single commit 8402002 from mai/faraday/project-timeline-chart.
2026-05-09 18:47:06 +02:00
27 changed files with 3397 additions and 517 deletions

View 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: ~700900 LoC. New code on `verfahrensablauf.ts` (variant chips + lane mode + compare): ~400600 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).
- **7201022px:** 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 200300 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 24 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 24 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 ~700900 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 24 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.

View File

@@ -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

View File

@@ -71,7 +71,10 @@ export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
try {
let result: ViewRunResult;
if (opts.customRunner) {
result = await opts.customRunner(effective);
// Hand the runner a frozen snapshot of the bar state so it can
// read axes the EffectiveSpec doesn't round-trip (SmartTimeline
// timeline_status / timeline_track on the Verlauf surface).
result = await opts.customRunner(effective, Object.freeze({ ...state }));
} else {
const slug = opts.systemViewSlug as string; // ctor guard guarantees this
const r = await fetch(`/api/views/${encodeURIComponent(slug)}/run`, {
@@ -202,6 +205,11 @@ export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
if (lastEffective) return lastEffective;
return computeEffective(opts.baseFilter, opts.baseRender, state);
},
getState() {
// Hand back a frozen snapshot so callers can't smuggle mutations
// back into the bar's owned state — the bar is the single writer.
return Object.freeze({ ...state });
},
destroy() {
destroyed = true;
toolbar.remove();

View File

@@ -112,12 +112,14 @@ export interface MountOpts {
systemViewSlug?: string;
// Custom runner. When set, the bar bypasses the substrate POST and
// hands the effective spec to this function instead. Used by surfaces
// that haven't migrated to the substrate yet (Verlauf tab still hits
// /api/projects/{id}/events to keep subtree expansion + cursor
// pagination, t-paliad-170). Must be either this OR systemViewSlug —
// the bar throws if both / neither are provided.
customRunner?: (effective: EffectiveSpec) => Promise<ViewRunResult>;
// hands the effective spec + raw BarState to this function instead.
// Used by surfaces that need axes the EffectiveSpec doesn't round-trip
// (e.g. SmartTimeline's timeline_status / timeline_track, t-paliad-176).
// The state argument is a frozen snapshot — same shape getState()
// returns on the handle, but available on the very first run before
// the handle has been assigned. Must be either this OR systemViewSlug
// — the bar throws if both / neither are provided.
customRunner?: (effective: EffectiveSpec, state: Readonly<BarState>) => Promise<ViewRunResult>;
// Per-surface override of the time-axis chip presets. Order is
// preserved. Default presets are forward-looking (next_*+past_30d+any)
@@ -150,4 +152,10 @@ export interface BarHandle {
// Read-only effective spec at this moment (post URL + localStorage
// overlay). Pages use this to construct deep-link URLs etc.
getEffective(): EffectiveSpec;
// Read-only raw BarState. Surfaces with axes the EffectiveSpec doesn't
// round-trip (timeline_status / timeline_track on the SmartTimeline
// surface — the substrate FilterSpec has no per-source predicate for
// those) read state directly to drive client-side filtering. Returns
// a frozen snapshot; callers must not mutate.
getState(): Readonly<BarState>;
}

View File

@@ -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, "&amp;").replace(/"/g, "&quot;");
}
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;

View File

@@ -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",

View 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();
});

View File

@@ -255,16 +255,30 @@ let timelineSelectedLanes: string[] | null = null;
// and back keeps the user's choice.
let timelineClientShowLanes = false;
// t-paliad-170 — Verlauf FilterBar state.
// t-paliad-170 / t-paliad-176 — Verlauf FilterBar state.
//
// The bar mounts once, owns the URL params (?time=, ?pe_kind=, …), and
// drives loadEvents through its customRunner. Filtering is client-side
// against the legacy /api/projects/{id}/events response so subtree mode
// + cursor pagination stay intact (substrate-side scope expansion lands
// with t-paliad-169 SmartTimeline). Empty filter → identity passthrough.
// The bar mounts once, owns the URL params (?time=, ?pe_kind=, ?tl_status=,
// ?tl_track=, …), and drives a client-side filter pass over `timelineRows`
// before render. The SmartTimeline endpoint has no built-in predicate for
// timeline_status / timeline_track / project_event_kind axes — they sit on
// BarState only — so we filter rendered rows in `applyTimelineRowFilters`
// rather than re-fetching on every chip click. The customRunner drains the
// bar's state into `verlaufFilters` and triggers a re-render via onResult.
let verlaufBar: BarHandle | null = null;
interface VerlaufFilters {
// project_event_kind chip — values from KnownProjectEventKinds (see
// internal/services/filter_spec.go). Only filters rows whose underlying
// project_events.event_type is non-empty (deadline / appointment /
// projected rows pass through unaffected — they have no event_type).
eventKinds?: Set<string>;
// timeline_status chip — matches TimelineEvent.status verbatim
// (done | open | overdue | predicted | predicted_overdue | court_set | off_script).
timelineStatuses?: Set<string>;
// timeline_track chip — chip values are "parent" / "counterclaim" /
// "off_script" but row.track may carry suffixed forms like
// "counterclaim:<id>" or "parent_context:<id>". Filtering normalises
// by matching the chip's prefix against the row's track tag.
timelineTracks?: Set<string>;
// Bounds are inclusive lower / exclusive upper, matching
// computeViewSpecBounds in internal/services/view_service.go so the
// semantics align when this surface eventually moves to the substrate.
@@ -273,6 +287,65 @@ interface VerlaufFilters {
}
let verlaufFilters: VerlaufFilters = {};
// applyTimelineRowFilters narrows the SmartTimeline rows to whatever
// the FilterBar's BarState declares. Empty filter → identity passthrough.
// Called from renderTimeline immediately before handing rows to
// renderSmartTimeline (single-column or lane-strip alike).
function applyTimelineRowFilters(rows: SmartTimelineEvent[]): SmartTimelineEvent[] {
const f = verlaufFilters;
if (
!f.eventKinds &&
!f.timelineStatuses &&
!f.timelineTracks &&
!f.fromDate &&
!f.toDate
) {
return rows;
}
return rows.filter((r) => {
// project_event_kind narrows project_events specifically: deadline /
// appointment / projected rows pass through unaffected (they carry no
// project_event_type). A milestone whose project_event_type isn't in
// the picked subset drops out.
if (f.eventKinds && r.project_event_type) {
if (!f.eventKinds.has(r.project_event_type)) return false;
}
if (f.timelineStatuses && !f.timelineStatuses.has(r.status)) return false;
if (f.timelineTracks && !timelineTrackChipMatches(r.track, f.timelineTracks)) return false;
if (f.fromDate || f.toDate) {
// Undated rows (court-set decisions, counterclaim-pending) escape
// the time horizon — same convention as the renderer's "Datum offen"
// bucket. Otherwise compare the row's date against the bounds.
if (r.date) {
const d = new Date(r.date);
if (f.fromDate && d < f.fromDate) return false;
if (f.toDate && d >= f.toDate) return false;
}
}
return true;
});
}
// timelineTrackChipMatches normalises the chip vocabulary against the
// row's track tag — chip "counterclaim" matches both "counterclaim" and
// "counterclaim:<id>"; chip "parent" matches "parent" only (NOT
// "parent_context:<id>", which is a CCR-child-viewing-parent overlay).
function timelineTrackChipMatches(rowTrack: string, chips: Set<string>): boolean {
const tag = rowTrack || "parent";
if (chips.has(tag)) return true;
for (const chip of chips) {
if (chip === "counterclaim" && tag.startsWith("counterclaim:")) return true;
}
return false;
}
// applyVerlaufFilters narrows the legacy /api/projects/{id}/events
// response to the bar's filter state. The render path no longer reads
// this `events` array (the SmartTimeline took over), but loadEvents +
// loadMoreEvents still call it so the cursor pagination state stays
// consistent for any future re-introduction. Keeps the project_event_kind
// + time-horizon filter intact; the SmartTimeline-only axes don't apply
// to the legacy ProjectEvent shape.
function applyVerlaufFilters(rows: ProjectEvent[]): ProjectEvent[] {
const f = verlaufFilters;
if (!f.eventKinds && !f.fromDate && !f.toDate) return rows;
@@ -505,7 +578,13 @@ function renderTimeline() {
return;
}
renderSmartTimeline(host, timelineRows, {
// t-paliad-176 — apply FilterBar predicates client-side. The
// SmartTimeline endpoint returns the unfiltered superset; the bar's
// BarState (timeline_status / timeline_track / project_event_kind /
// time horizon) narrows what we render. Empty filter → identity.
const filteredRows = applyTimelineRowFilters(timelineRows);
renderSmartTimeline(host, filteredRows, {
projectId,
lang: getLang() === "en" ? "en" : "de",
lookahead: timelineLookahead,
@@ -1007,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;
@@ -1968,19 +2055,18 @@ async function main() {
}
// mountVerlaufFilterBar mounts the universal FilterBar inside the
// Verlauf tab (t-paliad-170). The bar owns URL params (?time=, ?pe_kind=)
// and the displayed filter chrome; on every state change it invokes the
// customRunner below, which calls loadEvents (the legacy
// /api/projects/{id}/events endpoint) and applies client-side filtering.
// Verlauf tab (t-paliad-170 → t-paliad-176). The bar owns URL params
// (?time=, ?pe_kind=, ?tl_status=, ?tl_track=) and the displayed filter
// chrome; on every state change it invokes the customRunner below, which
// drains the bar state into `verlaufFilters` and lets the bar's onResult
// callback trigger renderTimeline — narrowing happens client-side over
// `timelineRows` in `applyTimelineRowFilters`.
//
// Why customRunner instead of the substrate POST: the legacy endpoint
// expands the project's descendant subtree server-side and returns
// cursor-paginated rows, both of which the substrate's project_event
// runner doesn't yet support (substrate only does ScopeExplicit on a
// flat ID list, no "include descendants", no cursor). Migrating to the
// substrate is the SmartTimeline redesign (t-paliad-169) — this slice
// avoids the regression by keeping the data path and wiring the bar as
// a UI primitive on top.
// Why customRunner instead of the substrate POST: the SmartTimeline
// endpoint isn't a substrate-managed system view, and timeline_status /
// timeline_track / project_event_kind don't all map cleanly onto the
// substrate's per-source predicates. The customRunner stays as the bar's
// integration point with the SmartTimeline read pipeline.
function mountVerlaufFilterBar(id: string): void {
const host = document.getElementById("project-events-filter-bar");
if (!host) return;
@@ -2000,17 +2086,29 @@ function mountVerlaufFilterBar(id: string): void {
verlaufBar = mountFilterBar(host, {
baseFilter,
baseRender,
axes: ["time", "project_event_kind"],
// t-paliad-176 — exposing timeline_status + timeline_track on the
// Verlauf tab. They were declared in the bar's axis catalogue from
// Slice 2 onward but never mounted on this surface; chips were
// therefore invisible and the wire-up was a no-op.
axes: ["time", "timeline_status", "timeline_track", "project_event_kind"],
surfaceKey: "project-history",
showSaveAsView: false,
timePresets: ["past_7d", "past_30d", "past_90d", "any"],
customRunner: async (effective) => {
customRunner: async (effective, state) => {
// project_event_kind rides through effective.filter.predicates
// (substrate-shaped); timeline_status / timeline_track live on raw
// BarState. The bar passes both to keep first-run hydration honest
// (the bar handle hasn't been assigned to verlaufBar yet on first
// run, so we can't reach getState() — the state argument fixes that).
const kinds = effective.filter.predicates?.project_event?.event_types;
const tlStatus = state.timeline_status;
const tlTrack = state.timeline_track;
verlaufFilters = {
eventKinds: kinds && kinds.length ? new Set(kinds) : undefined,
timelineStatuses: tlStatus && tlStatus.length ? new Set(tlStatus) : undefined,
timelineTracks: tlTrack && tlTrack.length ? new Set(tlTrack) : undefined,
...horizonBounds(effective.filter.time?.horizon ?? "any"),
};
await loadEvents(id);
return { rows: [], inaccessible_project_ids: [] };
},
onResult: () => renderTimeline(),

View File

@@ -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");

View 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);
});

View 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
});
});

View 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")}`;
}

View File

@@ -72,6 +72,12 @@ export interface TimelineEvent {
// Empty / missing is treated as "self" (the legacy single-lane case).
lane_id?: string;
bubble_up?: boolean;
// t-paliad-176 — underlying paliad.project_events.event_type for
// milestone rows. Empty for deadline / appointment / projected rows.
// Powers the FilterBar's project_event_kind chip on the Verlauf tab
// (matched against KnownProjectEventKinds in filter_spec.go).
project_event_type?: string;
}
export interface LaneInfo {

View 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, "&amp;").replace(/"/g, "&quot;");
}
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 = "";
}

View File

@@ -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) +

View File

@@ -207,20 +207,9 @@ export function renderFristenrechner(): string {
Incoming &mdash; ein Ereignis hat eine Frist ausgel&ouml;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">&#128214;</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 &mdash; 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">

View File

@@ -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"

View 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 &mdash; 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"
>
&larr; Zur&uuml;ck zum Projekt
</a>
<div id="projects-chart-loading" className="entity-loading">
<p data-i18n="projects.chart.loading">L&auml;dt&hellip;</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 &darr; (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>
);
}

View File

@@ -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 &nearr;
</a>
</div>
<div id="project-events-filter-bar" />
<div id="project-smart-timeline" className="smart-timeline" />

View File

@@ -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;
}
}

View 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 &mdash; 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 &mdash; Verfahrensart w&auml;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&auml;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">&mdash;</strong>
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
data-i18n="deadlines.proceeding.reselect">
Anderes Verfahren w&auml;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&ouml;sendes Ereignis:</label>
<span id="trigger-event" className="trigger-event-name">&mdash;</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>
);
}

View 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")
}

View File

@@ -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

View File

@@ -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))

View 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"))
}
}

View File

@@ -238,6 +238,95 @@ func TestProjectionService_LevelAggregation_Live(t *testing.T) {
}
})
t.Run("Patent-level: direct_only collapses to single 'self' lane (m/paliad#33)", func(t *testing.T) {
rows, meta, err := projection.For(ctx, userID, patentID, ProjectionOpts{DirectOnly: true})
if err != nil {
t.Fatalf("For patent direct_only: %v", err)
}
// Lanes should NOT include child cases — just one "self" entry
// pointing at the patent itself.
if len(meta.Lanes) != 1 || meta.Lanes[0].ID != "self" {
t.Errorf("DirectOnly Lanes = %v, want a single 'self' lane", meta.Lanes)
}
if len(meta.Lanes) > 0 && meta.Lanes[0].ProjectID != patentID.String() {
t.Errorf("self lane ProjectID = %q, want patent id", meta.Lanes[0].ProjectID)
}
// Case-A's deadline / milestones must NOT surface — they belong to
// the case subtree and direct_only excludes them.
for _, r := range rows {
if r.DeadlineID != nil && *r.DeadlineID == deadlineA {
t.Errorf("Case-A deadline should NOT surface at Patent level with direct_only=true (got %v)", r)
}
if r.ProjectEventID != nil && *r.ProjectEventID == bubbledMilestoneA {
t.Errorf("Case-A bubbled milestone should NOT surface at Patent level with direct_only=true")
}
}
})
t.Run("Case-level: direct_only drops CCR sub-project lane", func(t *testing.T) {
// Seed a CCR child of Case-A so the default (subtree) path
// includes a "counterclaim:<id>" lane and direct_only excludes it.
ccrID := uuid.New()
ccrMilestoneID := uuid.New()
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.projects (id, type, parent_id, counterclaim_of, path, title, status, created_by)
VALUES ($1, 'case', $2, $2, $2::text || '.' || $1::text, 'Case A — CCR', 'active', $3)`,
ccrID, caseAID, userID); err != nil {
t.Fatalf("seed CCR: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
ccrID, userID); err != nil {
t.Fatalf("seed CCR team: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.project_events
(id, project_id, event_type, title, event_date, created_by, metadata,
created_at, updated_at, timeline_kind)
VALUES ($1, $2, 'custom_milestone', 'CCR-side note', $3, $4,
'{}'::jsonb, $5, $5, 'custom_milestone')`,
ccrMilestoneID, ccrID, now.AddDate(0, 0, -1), userID, now); err != nil {
t.Fatalf("seed CCR milestone: %v", err)
}
defer func() {
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE id = $1`, ccrMilestoneID)
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, ccrID)
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, ccrID)
}()
// Default (subtree) path: Case-A timeline carries both "self" +
// "counterclaim:<ccrID>" lanes.
_, defaultMeta, err := projection.For(ctx, userID, caseAID, ProjectionOpts{})
if err != nil {
t.Fatalf("For caseA default: %v", err)
}
var sawCCRLane bool
for _, l := range defaultMeta.Lanes {
if l.ID == "counterclaim:"+ccrID.String() {
sawCCRLane = true
}
}
if !sawCCRLane {
t.Fatalf("default Case-A meta.Lanes should include the CCR child: %v", defaultMeta.Lanes)
}
// Direct-only path: only the "self" lane survives, CCR milestones
// are excluded.
rows, directMeta, err := projection.For(ctx, userID, caseAID, ProjectionOpts{DirectOnly: true})
if err != nil {
t.Fatalf("For caseA direct_only: %v", err)
}
if len(directMeta.Lanes) != 1 || directMeta.Lanes[0].ID != "self" {
t.Errorf("direct_only Lanes = %v, want only 'self'", directMeta.Lanes)
}
for _, r := range rows {
if r.ProjectEventID != nil && *r.ProjectEventID == ccrMilestoneID {
t.Errorf("CCR milestone should NOT surface at Case-A with direct_only=true")
}
}
})
t.Run("Patent-level: bubble_up false → row dropped", func(t *testing.T) {
// Re-write the regular milestone with bubble_up=true and confirm
// it surfaces. Then revert.

View File

@@ -116,6 +116,13 @@ type TimelineEvent struct {
// one column per lane and groups rows by LaneID.
LaneID string `json:"lane_id,omitempty"`
// ProjectEventType carries the underlying paliad.project_events.event_type
// for milestone rows (t-paliad-176). Empty for deadline / appointment /
// projected rows. The FilterBar's project_event_kind chip narrows the
// rendered list against this field; KnownProjectEventKinds in
// internal/services/filter_spec.go is the canonical vocabulary.
ProjectEventType string `json:"project_event_type,omitempty"`
// BubbleUp signals that a project_event milestone is marked to
// bubble up to higher-level SmartTimelines (t-paliad-175 §5.3 + §7.2).
// Read from metadata.bubble_up on the underlying paliad.project_events
@@ -298,6 +305,17 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
policy := levelPolicy(proj.Type)
// DirectOnly collapses every level to a single-lane "self" view —
// no CCR sub-project lanes (Case level), no parent_context lane (CCR
// child viewpoint), no child-case / child-patent / child-litigation
// lanes (Patent / Litigation / Client levels). The level-policy
// kind/status filter still applies at higher levels so that, e.g., a
// Patent-level direct view doesn't suddenly leak off_script custom
// milestones that the aggregated view filters out (t-paliad-176).
if opts.DirectOnly {
return s.forDirectSelfOnly(ctx, userID, proj, policy, opts, meta)
}
// Patent / Litigation / Client levels — lane-aggregated rendering.
if policy.LaneAxis != "self_plus_ccr" {
return s.forAggregatedLevel(ctx, userID, proj, policy, opts, meta)
@@ -309,6 +327,51 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
return s.forCaseLevel(ctx, userID, proj, opts, meta)
}
// forDirectSelfOnly handles every level when DirectOnly is requested
// (m/paliad#33). Renders this project's own actuals + (at Case level)
// projection only — no CCR / parent_context / child-case lanes. The
// policy's kind/status filter still applies at higher levels so the
// "Nur direkt" Patent view honours the same milestone-only contract as
// the aggregated default. Produces a single "self" lane.
func (s *ProjectionService) forDirectSelfOnly(
ctx context.Context,
userID uuid.UUID,
proj *models.Project,
policy LevelPolicy,
opts ProjectionOpts,
meta ProjectionMeta,
) ([]TimelineEvent, ProjectionMeta, error) {
includeProjection := policy.LaneAxis == "self_plus_ccr"
rows, mainMeta, err := s.loadProjectTrack(ctx, userID, proj, opts, "parent", nil, includeProjection)
if err != nil {
return nil, meta, err
}
meta.HasProjection = mainMeta.HasProjection
meta.ProjectedTotal = mainMeta.ProjectedTotal
meta.ProjectedShown = mainMeta.ProjectedShown
meta.PredictedOverdue = mainMeta.PredictedOverdue
allowKind := stringSet(policy.Kinds)
allowStatus := stringSet(policy.Statuses)
out := make([]TimelineEvent, 0, len(rows))
for i := range rows {
row := rows[i]
row.LaneID = "self"
if !rowSurvivesPolicy(row, allowKind, allowStatus) {
continue
}
out = append(out, row)
}
meta.Lanes = append(meta.Lanes, LaneInfo{
ID: "self",
Label: proj.Title,
ProjectID: proj.ID.String(),
})
sortTimeline(out)
return out, meta, nil
}
// forCaseLevel runs the original Slice-1-through-3 flow: parent track +
// CCR sub-projects (when this project is the parent) or parent_context
// (when this project is a CCR child). Lanes mirror tracks one-for-one
@@ -1100,6 +1163,9 @@ func (s *ProjectionService) listProjectEvents(ctx context.Context, userID, proje
ProjectEventID: &r.ID,
BubbleUp: extractBubbleUp(r.Metadata, r.EventType, r.TimelineKind),
}
if r.EventType != nil {
ev.ProjectEventType = *r.EventType
}
if r.Description != nil {
ev.Description = *r.Description
}