Compare commits

..

10 Commits

Author SHA1 Message Date
mAi
0f3c30a647 feat(scenario-builder): B0 schema foundation + minimal API (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
t-paliad-340 — B0 of edison's 7-slice train (PRD §7.1). DB-only: schema
+ RLS land, dev-only test route exercises the surface, no user-facing
change. B1 wires the actual builder UI on top.

Migration 157 (additive on the legacy mig-145 scenarios table — 0 rows
in prod, safe to relax):
- paliad.scenarios gets owner_id / status / origin_project_id /
  promoted_project_id / stichtag / notes. spec drops NOT NULL and the
  scenarios_unique_per_scope constraint drops (the builder allows
  multiple scratch + Unbenanntes Szenario rows per user).
- New tables: scenario_proceedings, scenario_events, scenario_shares.
- paliad.projects.origin_scenario_id for the promote-to-project audit
  trail (the FK lands now; the wizard ships in B5).
- paliad.can_see_scenario(uuid) STABLE SECURITY DEFINER helper covering
  owner / share / global_admin / two legacy paths.
- Replacement RLS on scenarios + RLS on the three new tables; legacy
  service + handlers stay live and unchanged.

PRD §5.1 deviations called out in the migration header:
- proceeding_type_id is integer (live schema), not uuid (PRD draft).
- FK target is paliad.users, matching the rest of paliad's schema.

Go surface:
- ScenarioBuilderService — list/create/get-deep/patch scenarios,
  add/patch/delete proceedings, add/patch/delete events,
  add/delete shares. Writes wrap in transactions with set_config(
  paliad.audit_reason, ..., true) per event_choice_service.go pattern.
- /api/builder/scenarios/* — handlers register under a builder/
  prefix so the legacy /api/scenarios surface still works.
- /dev/scenario-builder — single-page HTML form gated to
  PaliadinOwnerEmail, exercises the B0 surface without Postman.
- Live-DB integration test (TEST_DATABASE_URL gated) covers
  create + list + deep-get + share + visibility negatives + patch.

Audit-first: every DDL block ran clean via BEGIN/ROLLBACK against
the live DB before commit; end-to-end sanity (insert chain + CHECK
constraints + CASCADE-on-delete) verified via the Supabase MCP.

bun build clean. go vet + go test -short ./... green.
2026-05-27 23:50:14 +02:00
mAi
2c2b93bc7c Merge: t-paliad-339 — PRD for Procedures Litigation Builder (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-27 23:04:07 +02:00
mAi
661d87273c docs(plans): PRD — Procedures Litigation Builder (m/paliad#153)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
PRD for the columnar litigation planner replacing today's 4-tab catalog
at /tools/procedures with a Litigation Builder backed by a new Scenario
DB. Captures 20 chip-picker decisions (5 batches via AskUserQuestion)
covering: unified-builder shape with 3 entry modes (cold-open /
event-triggered / Akte), separate paliad.scenarios table with
multi-proceeding constellations, auto-save + named-list, per-proceeding
flags + perspective + Detailgrad, 3-state event cards
(planned/filed/skipped), per-event-card optional horizon, vertical
stacked column-triplets with inline spawn children, universal search
(events + scenarios + Akten), 3-step promote-to-project wizard,
read-only team sharing, desktop v1 + mobile basic-read.

Includes data model deltas (4 new tables + 1 column on
paliad.projects), 6-slice migration plan from the current live U0-U4
catalog, and coder hand-off notes. Cross-proceeding peer triggers and
DE/EPA/DPMA full expansion deferred to v1.1.
2026-05-27 23:00:24 +02:00
mAi
ed3c5d1f32 Revert "Merge: t-paliad-338 T1 — workflow-tracker shell replaces catalog (m/paliad#152)"
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
This reverts commit 2e6427dca6, reversing
changes made to 9fe06094a8.
2026-05-27 22:09:06 +02:00
mAi
be570c2fd0 Revert "Merge: t-paliad-338 T3-T5 — workflow-tracker Akte/polish/cleanup (m/paliad#152)"
This reverts commit 6506d7d862, reversing
changes made to 2e6427dca6.
2026-05-27 22:09:06 +02:00
mAi
58692513a8 Revert "Merge: tracker filter pill dark-mode contrast (m/paliad#152)"
This reverts commit 702f786771, reversing
changes made to 6506d7d862.
2026-05-27 22:09:06 +02:00
mAi
702f786771 Merge: tracker filter pill dark-mode contrast (m/paliad#152)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-27 22:03:00 +02:00
mAi
93c664c865 fix(procedures): tracker filter pill — dark-mode contrast (m/paliad#152)
Selected .tracker-pill.is-active used --color-accent-fg, which in dark
mode resolves to lime → lime text on lime background, unreadable.
Switch to --color-accent-dark (midnight in both modes) so the selected
pill has midnight text on lime in both light + dark. Same pattern as
the older .filter-pill.active rule.
2026-05-27 22:03:00 +02:00
mAi
6506d7d862 Merge: t-paliad-338 T3-T5 — workflow-tracker Akte/polish/cleanup (m/paliad#152)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-27 22:00:03 +02:00
mAi
2e6427dca6 Merge: t-paliad-338 T1 — workflow-tracker shell replaces catalog (m/paliad#152)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-27 21:46:57 +02:00
25 changed files with 7781 additions and 2190 deletions

View File

@@ -246,6 +246,10 @@ func main() {
// SSoT. Drives Verfahrensablauf + Mode B result-view conditional
// rendering and per-rule selection state (`rule:<uuid>` keys).
ScenarioFlags: services.NewScenarioFlagsService(pool, projectSvc),
// t-paliad-340 / m/paliad#153 B0 (mig 157) — Litigation Builder.
// CRUD over the new normalised scenarios + scenario_proceedings
// + scenario_events + scenario_shares tables.
ScenarioBuilder: services.NewScenarioBuilderService(pool),
}
// t-paliad-246 Slice A — Backup Mode runner. Wired only when

View File

@@ -0,0 +1,685 @@
# PRD — Procedures: Litigation Builder (m/paliad#153)
**Task:** t-paliad-339
**Gitea:** m/paliad#153
**Inventor:** edison (shift-1, Opus)
**Date:** 2026-05-27
**Branch:** `mai/edison/inventor-prd-columnar`
**Status:** Draft — DESIGN READY FOR REVIEW. Coder gate held.
**Builds on (read before extending this PRD):**
- `docs/design-procedures-workflow-tracker-2026-05-27.md` — atlas's reverted tracker design (m/paliad#152). The anchor+scope idea did not land; understand *why* before re-proposing.
- `docs/design-unified-procedural-events-tool-2026-05-27.md` — cronus's U0-U4 catalog, currently live on main @ `ed3c5d1` post-revert. Visual baseline for filter strip + tab control.
- `docs/design-deadline-system-revision-2026-05-27.md` — atlas Phase 2 model layer (scenario_flags SSoT, view-mode toggle, per-rule selection chips). Model layer is locked; this PRD is purely surface + new persistence tables.
- `docs/design-fristenrechner-overhaul-2026-05-26.md` — cronus 2026-05-26 inventor-pass (Mode A + B + result, shipped via t-paliad-322).
**Predecessor takeaway (atlas's debrief on #152):**
> "When the architecture is novel, default to grilling m in prose FIRST. The doc rewrites cost a commit; the bigger cost would have been wasting m's question-batch on the wrong architecture."
Followed here. This PRD captures the architecture m chose through **20 chip-picker decisions across 5 batches**, not an inventor-first strawman.
---
## §0 Premises
### §0.1 What is `/tools/procedures` today (live, post-revert)
The current page is cronus's 4-tab catalog (U0-U4, shipped via m/paliad#151):
- Sticky filter strip (search box + 4 chip rows: Forum / Verfahren / Ereignisart / Partei).
- 4 solid tabs: `Verfahren wählen` / `Direkt suchen` / `Geführt` / `Aus Akte`.
- Default-active tab = "Verfahren wählen" renders `VerfahrensablaufBody` (the legacy Verfahrensablauf wizard: proceeding picker → perspective + date → 3-step wizard → result in 3-column "Spalten" or single-column "Zeitstrahl").
- Other 3 tab panels are stubs (search/wizard/akte never wired in U0-U3).
m's blocking feedback (verbatim, 2026-05-27 22:18):
> I like to keep our current columnar layout with proactive / court / reactive. And it is good if we can select which side we want to simulate. […] There are basically three main approaches I see to this: Get an overview over proceedings, play around with options, build Scenarios. Another one where something specific happened and we just want to know what deadlines we need to note […]. A third one from a specific proceeding / case file where things take place / have taken place.
And the architecture-shifting follow-ups (2026-05-27 22:35-22:36, mid-grilling):
> I would prefer to have an interface where not every constellation is in the URL by the way. That seems limiting.
> We could just have a litigation builder. Sometimes we build a full scenario with multiple instances etc, sometimes we just want the next step.
> we should have ways to save these "litigation constellations" where we save which proceedings we have and which state they are in, which submissions were or were not filed. A small Scenario DB could work, dont you think?
These three statements upgraded the brief from "redesign a catalog" to "build a Litigation Builder backed by a Scenario DB". The PRD below is shaped by them.
### §0.2 Locked constraints (m's words, brief in #153)
- Columnar layout: `proaktiv | court | reaktiv` (perspective-flippable).
- Three approaches as entry modes: overview/scenarios, event-triggered, case-file driven.
- Filtering across all dimensions + text search.
- Optional follow-ups: toggleable, highlightable, with display-count setting.
- Modular *where it actually helps* (m: "I don't know — generally does not super apply here." — drop modular as a load-bearing goal).
- UPC v1, expand later.
### §0.3 Live data the builder works against
Verified 2026-05-27 against `paliad.sequencing_rules` (231 published / 242 total):
- 110 chained (`parent_id` not null).
- 78 trigger-rooted, 4 spawns (cross-PT), 47 court-set, 18 conditional.
- ~46 proceeding types total (UPC 35 / DE 5 / EPA 3 / DPMA 3). v1 focuses on UPC.
- `paliad.proceeding_types.kind` discriminator (atlas's t-paliad-324) filters non-proceeding rows (phases/side_actions/meta) from the picker.
- `paliad.deadlines` carries both `procedural_event_id` and `sequencing_rule_id` → Akte actuals overlay is a direct join.
- `paliad.projects.scenario_flags` jsonb (atlas P0) is the SSoT for project-level scenario state; the new `paliad.scenario_proceedings.scenario_flags` mirrors this shape per-proceeding-per-scenario.
### §0.4 Scope (in / out)
**In:**
- Replace `/tools/procedures` with the Litigation Builder.
- New `paliad.scenarios` + `paliad.scenario_proceedings` + `paliad.scenario_events` + `paliad.scenario_shares` tables.
- Promote-to-project flow (scenario → `paliad.projects` row).
- Bidirectional link from `/projects/{id}` (button: "Im Builder öffnen" — exports project state to a builder session).
**Out (deferred or owned elsewhere):**
- Calculator (`pkg/litigationplanner.CalculateRule`) — working.
- Editorial backfill (curie's t-paliad-333 owns the 7 compound rules + R.109).
- `/admin/procedural-events` (editor surface; different audience).
- `/projects/{id}` Verlauf / SmartTimeline (per-Akte actuals; sister tool).
- youpc.org / Outlook / PDF export.
- Multi-jurisdiction expansion (DE/EPA/DPMA) — UPC v1 first.
- Cross-proceeding peer triggers (UPC-inf judgment → EPA opp choice deadline) — v1.1.
- Multi-user concurrent editing on the same scenario (out of scope; sharing is read-only).
---
## §1 Goals
1. **One canvas, three entry modes.** Unify the 3 approaches into a single Litigation Builder surface. The entry modes (`Übersicht / Ereignis / Aus Akte`) shape the *initial* state of the canvas; once the user is working, the canvas itself is what they interact with.
2. **Persisted constellations.** A user can save a "litigation constellation" — multiple parallel proceedings with their flags, filed/skipped/planned event states, dates, and notes — as a named scenario. Scenarios live in the DB (not the URL).
3. **Auto-save by default.** No "unsaved changes" modals. The active scenario auto-persists. Anonymous scratch scenarios convert to named ones when the user clicks "Benennen".
4. **Promote-to-project.** A scenario can be turned into a real `paliad.projects` row via a 3-step wizard. Procedural shape, placeholder parties, notes, and filed-state all carry over; the user fleshes out client-bound metadata during the wizard.
5. **Share read-only with the team.** Each scenario is private by default; explicit "An Team teilen" grants named HLC users read-only access. Original owner stays sole editor.
6. **Columnar geometry restored.** The current "Spalten" view (claimant | court | defendant) returns as the canonical render — but now per-proceeding-triplet within a scenario, with perspective ("our side") flippable per proceeding so `proaktiv | court | reaktiv` reads correctly across multi-proceeding constellations.
7. **Per-event-card optional horizon.** Each event card on the canvas can dial in how many optional follow-ups to surface. Cards are the unit of optional-display control.
---
## §2 User journeys
### §2.1 Journey A — Cold-open builder ("Übersicht / Scenarios")
**Persona:** Dr. Becker, senior partner. Friday afternoon. New UPC matter not yet committed; she's briefing a client on Monday on the full procedural shape.
1. Opens `/tools/procedures`. No `?scenario` param. Cold-open canvas: empty workbench with a "Neues Szenario starten" CTA and a short list of her 5 most-recent scenarios.
2. Clicks the CTA → inline picker (Forum chip row → Verfahren chip row → `Hinzufügen`). Picks UPC + `upc.inf.cfi`.
3. Canvas now renders one proceeding triplet (`proaktiv | court | reaktiv`). Default perspective is empty (no party selected) — both sides render equally; the perspective radio in the page header sits unset.
4. She picks defendant perspective at the page header → triplet flips. The defendant column becomes `proaktiv` (her side); claimant becomes `reaktiv`.
5. She adds a second proceeding via `+ Verfahren hinzufügen` at the bottom: EPA `epa.opp.opd`. New triplet stacks below the first. New triplet's perspective defaults to "patentee" inheriting from her client's role across the two; she flips per-proceeding via the triplet header.
6. She turns on `with_ccr` on the UPC inf triplet's per-proceeding flag strip. The CCR child triplet auto-expands inline below the parent at the spawn node.
7. Auto-save kicks in (debounced 500ms). The page header shows "Gespeichert in Scratch · Benennen".
8. She clicks "Benennen", enters "Becker — UPC + EPA defensive". Side panel "Meine Szenarien" updates.
9. On Monday she opens the scenario from her recent list, walks the client through it, hits "Als Projekt anlegen" (when the client commits). 3-step wizard fires (§5.4).
### §2.2 Journey B — Event-triggered lookup ("Ereignis")
**Persona:** Sandra, paralegal. Today: a Hinweisbeschluss arrived on a CMS queue. She doesn't know yet which Akte it belongs to.
1. Opens `/tools/procedures`. Picks "Ereignis" entry mode at the top.
2. Page-header search box auto-focuses. She types "Hinweis" → universal search drops down: `5 Ereignisse · 1 Szenario · 0 Akten`. Picks the event `upc.inf.cfi.cmo_review` (Antrag CMO-Überprüfung).
3. Canvas renders one triplet of `upc.inf.cfi` with the Hinweisbeschluss event card auto-anchored (lime band + `━━ DU BIST HIER ━━` divider above the next-coming events).
4. She reads the follow-ups: "Antrag auf CMO-Überprüfung (claimant, R.333.2 · 1 Monat)" and 2 optional follow-ups. The Stichtag input in the page header defaults to today; she leaves it.
5. She doesn't save anything — this was a quick lookup. Scratch scenario auto-persists but she doesn't name it; it'll fall off her recent list after a while.
6. Later she identifies the matter (HL-2024-001), switches to "Aus Akte" mode, and continues there.
### §2.3 Journey C — Case-file driven ("Aus Akte")
**Persona:** Anna, senior associate. Working on HL-2024-001 (UPC infringement). The client just confirmed they want to file a CCR.
1. Opens `/tools/procedures`. Page-header Akte picker shows recent projects; she picks HL-2024-001.
2. Page header auto-fills: proceeding = `upc.inf.cfi`, perspective = defendant (from `projects.our_side`), scenario_flags = `{with_ccr: false}` (current state).
3. Builder loads: one `upc.inf.cfi` triplet, perspective-flipped. Event cards overlay actuals from `paliad.deadlines``Klageerhebung` is filed (2026-01-15), `Klageerwiderung` is planned (2026-04-01, computed), others are planned.
4. She turns on `with_ccr` on the triplet's flag strip. The CCR child triplet expands inline. **Crucially:** the scenario is *project-backed* — the flag write also patches `projects.scenario_flags` (via existing `PATCH /api/projects/{id}/scenario-flags` from atlas P0). When she walks away, the project's deadlines + flags reflect the builder's state.
5. She marks the `Widerklage auf Nichtigkeit` event card as "filed" with today's date. Builder writes a `paliad.deadlines` row with `status='done'` + `completed_at=today`, audit_reason "via Litigation Builder". Project's Verlauf reflects this.
6. The CCR child triplet's `Antrag Patentänderung (R.30)` event card surfaces. She marks it "planned" and ticks the per-card optional horizon to "+2" → 2 more optional R.30-adjacent rules surface.
7. Exit: she closes the tab. Project state persists in `paliad.projects` + `paliad.deadlines` as before; the scenario row tracks the builder-session view (so when she returns, the canvas state is restored — including her per-card optional-horizon picks).
### §2.4 Journey D — Promote scratch to a real project
**Persona:** Dr. Becker, follow-up from Journey A. The client committed; she wants to convert the scenario into a real matter.
1. With "Becker — UPC + EPA defensive" loaded, she clicks "Als Projekt anlegen" in the page header.
2. **Wizard step 1: Bestätigen.** Read-only summary of what's about to be promoted: 2 proceedings (UPC inf + EPA opp), CCR child, 3 scenario flags set, 0 events filed, 5 events planned, 2 notes. "Weiter".
3. **Wizard step 2: Parteien ergänzen.** Each proceeding's parties section shows whatever placeholder names she sketched in the scenario ("Klg X" / "Bekl Y"). She edits each into the real names. (Per m's Q11 pick — full carry — placeholder strings come in; the wizard's job is to clean them.)
4. **Wizard step 3: Akte-Metadaten.** Case number, client, litigation parent project (optional), our_side (auto-set from the scenario's primary triplet), team selection. "Anlegen".
5. New `paliad.projects` row written with `origin_scenario_id = <scenario.id>`. Scenario row's `status` flips to `promoted`, `promoted_project_id` points back. Builder navigates to `/projects/<new-id>`.
6. The scenario stays read-only in her "Meine Szenarien" list under "Promoted", reachable for historical reference (cf. "this is what we planned at briefing time").
### §2.5 Journey E — Share a scenario with a colleague
**Persona:** Anna shares the HL-2024-001 builder session with Dr. Becker (her supervising partner) for review before committing to the CCR strategy.
1. Anna opens the scenario, clicks "Teilen" in the page header.
2. Side panel slides in with a user-picker (HLC user search). She picks "Dr. Becker", clicks "Schreibgeschützt teilen".
3. `paliad.scenario_shares` row written. Anna remains sole editor.
4. Dr. Becker opens the tool. Her side panel "Meine Szenarien" has a new bucket "Geteilt mit mir"; Anna's scenario is listed. She opens it: canvas renders the same view but every mutating affordance (add proceeding, flag toggle, file/skip, promote, share) is disabled. Watermark: "Geteilt von Anna · schreibgeschützt".
5. Becker reads, drops Anna a note via existing comment infrastructure (out of scope — separate ticket). Decision made out-of-band. Anna proceeds.
---
## §3 The canvas shape
### §3.1 ASCII sketch
```
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ Paliad · Verfahren & Fristen — Litigation Builder [Mein Konto ▾] │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ Szenario: [Becker — UPC + EPA def. ▼] Gespeichert ✓ · [Benennen] [Teilen] [Als Projekt] │
│ Akte: [— ohne — ▼] Stichtag: [2026-04-01] │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ Filter: [🔍 Klageerwiderung, Hinweis, HL-2024… ] │
│ Forum [● UPC] [DE] [EPA] [DPMA] Verfahren [● upc.inf.cfi …] │
│ Partei [Klg] [● Bekl] Ereignisart [filing] [hearing] [decision] │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ Einstieg: [ Übersicht ● ][ Ereignis ○ ][ Aus Akte ○ ] │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─ upc.inf.cfi · Verletzungsverfahren UPC Bekl-Sicht [▾] [Detailgrad: Gewählt ▾]│
│ │ Optionen: ☑ with_ccr ☐ with_amend ☐ with_cci [─][×] │
│ ├─────────────────┬────────────────────┬─────────────────────────────────────────┤
│ │ Proaktiv (Bekl) │ Gericht │ Reaktiv (Klg) │
│ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ │ Klageerw. │ │ │ │ Klageerh. │ │
│ │ │ R.23 │ │ │ │ R.13 │ │
│ │ │ planned │ │ │ │ filed │ │
│ │ │ 2026-04-01 │ │ │ │ 2026-01-15 │ │
│ │ │ +3 Optionen ▾│ │ │ │ │ │
│ │ └─────────────┘ │ │ └─────────────┘ │
│ │ │ ┌──────────────┐ │ │
│ │ │ │ Mündl. Verh. │ │ │
│ │ │ │ planned │ │ │
│ │ │ │ [Gericht] │ │ │
│ │ │ └──────────────┘ │ │
│ │ ━━━━━━━━━ DU BIST HIER (Klageerwiderung) ━━━━━━━━━ │
│ └─────────────────┴────────────────────┴─────────────────────────────────────────┘ │
│ │
│ ┌── (spawn child) upc.ccr.cfi · Widerklage auf Nichtigkeit Klg-Sicht [▾] ───────┐│
│ │ Optionen: ☐ with_amend [─][×]││
│ ├────────────────────┬─────────────────┬───────────────────────────────────────┐ ││
│ │ Proaktiv (Klg) │ Gericht │ Reaktiv (Bekl) │ ││
│ │ ┌─────────────┐ │ │ │ ││
│ │ │ CCR-Antrag │ │ │ │ ││
│ │ │ R.49 │ │ │ │ ││
│ │ │ planned │ │ │ │ ││
│ │ └─────────────┘ │ │ │ ││
│ └────────────────────┴─────────────────┴───────────────────────────────────────┘ ││
│ │
│ ┌─ epa.opp.opd · Einspruchsverfahren EPA PatInh-Sicht [▾] [Detailgrad: Gewählt ▾]│
│ │ Optionen: (keine flags für EPA Opp) [─][×] │
│ ├─────────────────┬────────────────────┬─────────────────────────────────────────┤
│ │ Proaktiv │ EPA │ Reaktiv (Einsprechende) │
│ │ ┌─────────────┐ │ │ │
│ │ │ Erwiderung │ │ │ │
│ │ │ R.79(1) EPÜ │ │ │ │
│ │ │ planned │ │ │ │
│ │ └─────────────┘ │ │ │
│ └─────────────────┴────────────────────┴─────────────────────────────────────────┘ │
│ │
│ [ + Verfahren hinzufügen ] │
│ │
└──────────────────────────────────────────────────────────────────────────────────────┘
Side panel (collapsible, right-edge):
┌──── Meine Szenarien ────┐
│ ● Aktiv │
│ ▸ Becker — UPC+EPA def │ ← current
│ ▸ Test-CCR-Patent-X │
│ ○ Geteilt mit mir │
│ ▸ Becker UPC ply │
│ ○ Promoted │
│ ▸ HL-2023-118 │
│ ○ Archiviert (3) │
│ [+ Neues Szenario] │
└──────────────────────────┘
```
### §3.2 What each element does
| Element | Read | Write | Persists in |
|---|---|---|---|
| Page-header scenario picker | Current `scenarios.id` + `name` | Switch scenarios | URL `?scenario=<id>` + DB |
| `[Benennen]` button | Anonymous → named | `scenarios.name`, `status='active'` | DB |
| `[Teilen]` button | — | `scenario_shares` row(s) | DB |
| `[Als Projekt]` button | — | Opens promote wizard | (wizard → DB on commit) |
| Akte picker | User's projects | Loads project state into builder | URL `?project=<id>` + DB |
| Stichtag input | Scenario-level default | `scenarios.stichtag` | DB |
| Filter strip (search + chips) | Free-text + dimension filters | UI state | URL `?q`, `?forum`, … per-mode |
| Einstieg mode radio | Current entry mode | Resets filter strip on change | URL `?mode=` |
| Triplet header (jurisdiction badge + name + perspective + Detailgrad) | `scenario_proceedings.{primary_party, detailgrad}` | Edit | DB |
| Triplet flag strip | `scenario_proceedings.scenario_flags` | Toggle flags | DB |
| Event card (state, date, notes, optional-horizon) | `scenario_events.*` | Edit per-card | DB |
| `+ Verfahren hinzufügen` | — | New `scenario_proceedings` row | DB |
| Side panel | User's scenarios + shared scenarios | Switch + create + archive | DB |
### §3.3 Columns: `proaktiv | court | reaktiv`
The 3-column layout returns as the canonical desktop shape. Per m's locked constraint (and brief #153), it is a **stance grouping**, not a sequence anchor — time flows top-to-bottom (chronological), columns express *who is acting*.
- **Proaktiv**: the column for events the active perspective's party initiates (their `primary_party` matches the event's `primary_party`).
- **Court**: court-set events (`is_court_set=true`), neutral column.
- **Reaktiv**: the column for events the opposing party initiates.
The perspective is per-proceeding (per-triplet, via `scenario_proceedings.primary_party`). When no perspective is set (`null`), both party columns render equally with their natural party labels (Klg / Bekl), not Proaktiv / Reaktiv. This means kontextfrei browsing reads as "claimant column | court | defendant column" until the user picks a side.
This addresses m's reverted-design bug #3 verbatim: "Proaktiv/Gericht/Reaktiv columns are a stance grouping, not a sequence anchor." Time = vertical. Stance = horizontal. The triplet is the unit; multiple proceedings stack vertically.
### §3.4 Event card anatomy
```
┌─────────────────────┐
│ Klageerwiderung │ ← event name (procedural_event.name)
│ R.23 │ ← rule code
│ planned │ ← state: planned / filed / skipped
│ 2026-04-01 │ ← date (computed for planned, actual for filed)
│ +3 Optionen ▾ │ ← per-card optional horizon (only when card has optionals)
└─────────────────────┘
```
State machine (m's Q10 pick — 3-state):
- `planned` (default): future event, date is computed from anchor + duration_value + duration_unit. Click → choose `filed` or `skipped`.
- `filed`: past event, `actual_date` is set (defaults to computed, user can override). Visual: ✓ checkmark, slightly muted "past" tone.
- `skipped`: user chose not to file. Visual: strikethrough text + optional `skip_reason` (textarea). Optional rules are commonly skipped without rationale; mandatory rules with `skipped` state flag the scenario as "non-standard" but don't block.
No `overdue` state — user does the date arithmetic by eye against today. (Mandatory cards rendered in red when `actual_date < today AND state=planned` is a **render hint**, not a stored state.)
**Per-card optional horizon (m's Q4 pick).** Each card with children at `priority IN ('optional','recommended-skip-by-default')` carries a chip `+N Optionen ▾`. Default N=0 (hidden). Clicking opens an inline list of the optional children with `+`/`-` controls to surface/hide them on the canvas. Per-card horizon persists as `scenario_events.horizon_optional int`.
Filed-state cards persist the date in `scenario_events.actual_date date`. The card's notes field (textarea, lazy-loaded) lives in `scenario_events.notes text`.
### §3.5 Court-set events
`is_court_set=true` rules don't compute a date until the court picks one. Card renders with `[Gericht]` badge in place of the date and a small "Datum eintragen" affordance. Clicking `filed` opens a date picker (date is required for `filed` state when `is_court_set=true` — the user is asserting "the court set this date").
Downstream events that anchor on a court-set event render their dates as `[abhängig von <event>]` until the court date is filed, then auto-recompute.
### §3.6 Spawn (child) proceedings
When a triplet has a `with_<flag>` enabled and the flag's gating rule has `is_spawn=true`, the child proceeding (e.g. `upc.ccr.cfi` for `with_ccr` on `upc.inf.cfi`) renders inline as a child triplet **immediately below the parent triplet** in the canvas stack — visually nested via the spawn note in the parent triplet's header band.
`scenario_proceedings.parent_scenario_proceeding_id` FK self-references for the nesting; `scenario_proceedings.spawn_anchor_event_id` points at the gating sequencing_rule so the UI knows where in the parent the spawn happened.
The child triplet has its own perspective, scenario flags, Stichtag override, Detailgrad. It can itself spawn (depth N supported; today's data is 2-deep at most).
Cross-proceeding peer triggers (`upc.inf judgment → epa.opp choice deadline`) are **out of scope for v1** (m's Q14 pick). v1 ships independent triplets stacked vertically; the user mentally tracks cross-dependencies. A future `scenario_event_links` table is the path to peer triggers in v1.1.
---
## §4 Hard decisions table — m's 20 picks
| # | Topic | Pick | Locks |
|---|---|---|---|
| Q1 | Modular meaning | "doesn't super apply" — drop modular as a load-bearing goal | §0.2 |
| Q2 | Tab state semantics | Shared anchor + Akte across modes; filters reset per mode | §3.1, §3.2, §6 |
| Q3 | Case-file integration | Page-header Akte picker, persistent across modes | §3.1, §3.2, §2.3 |
| Q4 | Optional-display horizon | Per-event-card | §3.4 |
| Q5 | Builder shape | Unified builder, 3 entry modes (cold-open / event-triggered / Akte) | §0, §1, §2, §3 |
| Q6 | Scenario↔project relationship | Separate `paliad.scenarios` table + promote-to-project action | §5, §2.4 |
| Q7 | Scenario contents | Multi-proceeding constellation per scenario | §3, §5 |
| Q8 | Save model | Auto-save active scenario + "Meine Szenarien" list | §1, §3, §6.4 |
| Q9 | Multi-proceeding render | Vertical stacked column-triplets | §3 |
| Q10 | Per-event state | 3-state: planned / filed / skipped (no `overdue` state) | §3.4 |
| Q11 | Promote-to-project carry | Everything (incl. placeholder parties + free-form notes) | §2.4, §5.4 |
| Q12 | Sharing model | Private by default + explicit team-share (read-only) | §1, §5, §2.5 |
| Q13 | Scenario flags placement | Per-proceeding (each triplet owns its `scenario_flags`) | §5.1 |
| Q14 | Cross-proceeding peer triggers | Out of scope for v1 (defer to v1.1) | §3.6, §7 |
| Q15 | Perspective scope | Per-proceeding (each triplet has its own `primary_party`) | §3.3, §5.1 |
| Q16 | Add-proceeding flow | `+ Verfahren hinzufügen` button below the last triplet, inline picker | §3, §3.1 |
| Q17 | Cold-open canvas | Empty canvas + "Neues Szenario" CTA + recent-list | §2.1, §3 |
| Q18 | Search scope | Universal: events + scenarios + Akten, scoped by result type | §3.1, §6 |
| Q19 | Promote-to-project flow | 3-step wizard (Bestätigen → Parteien ergänzen → Akte-Metadaten) | §2.4, §5.4 |
| Q20 | Mobile treatment | Desktop v1, mobile basic-read (mutating actions prompt "Auf größerem Bildschirm öffnen") | §3, §7 |
### §4.1 Divergences from inventor recommendations
Three picks diverged from my recommendation. Captured here so future readers (m, the coder) see the *current* design, not the strawman.
- **Q1 — Modular.** Inventor recommended "plug-in widgets". m: "I don't know — generally does not super apply here." Modular is dropped as a goal; the natural decomposition (BuilderCanvas → ProceedingTriplet → EventCard → ScenarioListPanel → PromoteWizard) is documented in §6.2 as build hygiene, not as a load-bearing constraint.
- **Q10 — Event state.** Inventor recommended 4-state (planned / filed / skipped / overdue). m picked 3-state — no `overdue` enum. Rationale (interpreted): `overdue` is derived from `date < today AND state=planned`, not stored; this avoids stale state when the date is edited.
- **Q11 — Promote carry.** Inventor recommended carrying procedural shape + flags + filed-state + notes but **not** placeholder parties/case_number/billing. m picked "everything carries" — placeholder parties come in. Mitigation: Q19's 3-step wizard's step 2 (Parteien ergänzen) gives the user a chance to clean placeholders before commit, so the safety net m wanted on Q11 is folded into Q19.
### §4.2 Inventor picks not formally asked
A few decisions are inventor-set because they're either: (a) implementation details that don't change the architecture, or (b) clean defaults that match existing patterns. Listed here so they're visible; m can flag any.
- **Detailgrad ("Gewählt" / "Alle Optionen") scope**: per-proceeding (matches today's Verfahrensablauf pattern). State in `scenario_proceedings.detailgrad`.
- **Akte picker shape**: flat dropdown sorted by recently-viewed first, with a typeahead filter for case numbers/names. Same shape as today's project picker on /agenda.
- **Notes**: per-event-card (textarea on each card, lazy-loaded). Scenario-level notes also exist (`scenarios.notes text`) for cross-cutting commentary.
- **Read-only shared state UI**: every mutating affordance is disabled (greyed, no click handlers). Watermark "Geteilt von <X> · schreibgeschützt" at the top of the canvas. No "Fork to my workspace" affordance in v1.
- **URL contract**: minimal, view-state only — `?scenario=<id>&mode=<entry>&event=<sequencing_rule_id>` (deep-link to a specific anchor). Filter pills + chip state get URL params *per active entry mode* but explicitly NOT the constellation data (per m's "not every constellation in URL" guidance). The constellation lives in `paliad.scenario_*` tables.
- **Auto-save granularity**: debounced 500ms on every change. Indicator near scenario name: `Gespeichert ✓` (last successful save < 5s ago), `Speichert…` (in flight), `Letzte Speicherung fehlgeschlagen — erneut versuchen` (on error).
- **Soft delete**: archived scenarios stay in DB with `status='archived'`. No hard delete in v1.
- **Audit**: no audit log on scenario edits (they're exploratory). Audit on promote-to-project goes via the existing `projects.audit_log`.
- **Concurrent editing**: single-editor model. Owner is sole editor; shares are read-only. No locking / merge conflict UI needed in v1.
- **Bilingual**: German primary, English via existing `i18n.ts`. Scenario names: user-chosen, any language. Skip reasons + notes: free-text, any language.
---
## §5 Data model deltas
All new tables live in `paliad.*` schema, alongside existing `paliad.projects` / `paliad.deadlines` / `paliad.sequencing_rules`.
### §5.1 New tables
```sql
-- Scenario header. One row per saved scenario (named or scratch).
CREATE TABLE paliad.scenarios (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
name text NOT NULL DEFAULT 'Unbenanntes Szenario',
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','archived','promoted')),
origin_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
-- set when scenario was exported from a project
promoted_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
-- set when scenario was promoted to a project
stichtag date NULL,
-- scenario-level default Stichtag; per-triplet overrides take precedence
notes text NULL,
-- free-form scenario-level commentary
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX scenarios_owner_status_idx ON paliad.scenarios(owner_id, status);
CREATE INDEX scenarios_updated_idx ON paliad.scenarios(owner_id, updated_at DESC);
-- One row per proceeding inside a scenario. Multiple per scenario for
-- multi-proceeding constellations. parent_scenario_proceeding_id self-refs
-- for spawned children (CCR child of UPC inf etc.).
CREATE TABLE paliad.scenario_proceedings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_id uuid NOT NULL REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
proceeding_type_id uuid NOT NULL REFERENCES paliad.proceeding_types(id),
primary_party text NULL
CHECK (primary_party IN ('claimant','defendant')),
-- per-proceeding perspective; null = no perspective picked yet
scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb,
-- per-proceeding flags: {with_ccr: true, with_amend: false, …}
parent_scenario_proceeding_id uuid NULL REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
-- self-ref for spawned children (CCR child of UPC inf etc.)
spawn_anchor_event_id uuid NULL REFERENCES paliad.sequencing_rules(id),
-- which rule of the parent caused this spawn (for UI placement)
ordinal int NOT NULL DEFAULT 0,
-- stack order on canvas (top to bottom)
stichtag date NULL,
-- per-proceeding Stichtag override; falls back to scenarios.stichtag
detailgrad text NOT NULL DEFAULT 'selected'
CHECK (detailgrad IN ('selected','all_options')),
appeal_target text NULL,
-- applies_to_target for appeal proceedings; null for non-appeal triplets
collapsed boolean NOT NULL DEFAULT false,
-- user-collapsed triplet header (UI state)
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX scenario_proceedings_scenario_idx ON paliad.scenario_proceedings(scenario_id, ordinal);
CREATE INDEX scenario_proceedings_parent_idx ON paliad.scenario_proceedings(parent_scenario_proceeding_id);
-- One row per event card on the canvas. Captures the card's state +
-- per-card attributes (filed date, skip reason, notes, optional horizon).
-- Most cards are sequencing-rule-backed; free-form events have a null
-- sequencing_rule_id and a non-null procedural_event_id (or text label).
CREATE TABLE paliad.scenario_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_proceeding_id uuid NOT NULL REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
sequencing_rule_id uuid NULL REFERENCES paliad.sequencing_rules(id),
procedural_event_id uuid NULL REFERENCES paliad.procedural_events(id),
-- one of {sequencing_rule_id, procedural_event_id, custom_label} must be set
custom_label text NULL,
-- free-form event name when neither sequencing_rule nor procedural_event apply
state text NOT NULL DEFAULT 'planned'
CHECK (state IN ('planned','filed','skipped')),
actual_date date NULL,
-- set when state='filed'; can also be set for state='planned' (court-set override)
skip_reason text NULL,
-- optional rationale when state='skipped'
notes text NULL,
-- per-card free-form
horizon_optional int NOT NULL DEFAULT 0,
-- per-card "show N more optionals" affordance
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (scenario_proceeding_id, sequencing_rule_id) WHERE sequencing_rule_id IS NOT NULL
);
CREATE INDEX scenario_events_proceeding_idx ON paliad.scenario_events(scenario_proceeding_id);
-- Read-only team shares. Owner is sole editor; shares grant view-only.
CREATE TABLE paliad.scenario_shares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_id uuid NOT NULL REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
shared_with_user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid NOT NULL REFERENCES auth.users(id),
UNIQUE (scenario_id, shared_with_user_id)
);
CREATE INDEX scenario_shares_user_idx ON paliad.scenario_shares(shared_with_user_id);
```
### §5.2 Additions to existing tables
```sql
-- One nullable FK on paliad.projects to track which scenario spawned this
-- project (set on promote-to-project). Auditable origin trail.
ALTER TABLE paliad.projects
ADD COLUMN origin_scenario_id uuid NULL
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
CREATE INDEX projects_origin_scenario_idx ON paliad.projects(origin_scenario_id)
WHERE origin_scenario_id IS NOT NULL;
```
No other changes to existing schema. `paliad.deadlines` continues to be the authoritative source for project-bound actuals; the builder writes to `paliad.deadlines` (not `scenario_events`) when working in Akte mode against a project-backed scenario.
### §5.3 RLS
Same pattern as existing `paliad.projects`:
- `scenarios` readable by `owner_id` OR by users with a matching `scenario_shares.shared_with_user_id` row.
- `scenarios` writable only by `owner_id` (and only when `status != 'promoted'`).
- `scenario_proceedings` + `scenario_events` cascade from scenario visibility.
- `scenario_shares` readable by `shared_with_user_id` or `created_by`; writable only by the scenario owner.
Helper function `paliad.can_see_scenario(scenario_id)` mirrors the existing `paliad.can_see_project(project_id)` shape.
### §5.4 Promote-to-project: data flow
```
[Wizard step 1: Bestätigen]
Read: scenarios + scenario_proceedings + scenario_events
Action: none (read-only summary)
[Wizard step 2: Parteien ergänzen]
Read: scenario_proceedings.scenario_flags (for hints about placeholder party names)
Action: builds an in-memory parties payload (per proceeding, per role)
[Wizard step 3: Akte-Metadaten]
Read: user's clients + litigations + project tree (existing /projects API)
Action: builds an in-memory project metadata payload
[Commit]
Transaction:
1. INSERT into paliad.projects (carrying step-2 + step-3 payloads, + scenario notes)
SET origin_scenario_id = <scenario.id>
2. INSERT into paliad.project_parties from step-2 payload
3. For each scenario_proceeding (depth-first, parent before child):
a. INSERT scenario_flags as projects.scenario_flags (parent-level only;
children become sub-projects via parent_project_id)
b. For each filed scenario_event: INSERT paliad.deadlines row with
status='done', completed_at=actual_date, audit_reason='via Litigation Builder promotion'
c. For each planned scenario_event: INSERT paliad.deadlines row with
status='open', due_date=computed (or actual_date override)
d. Skipped events: not inserted (no deadline row)
4. UPDATE paliad.scenarios SET status='promoted', promoted_project_id=<new>
5. Navigate to /projects/<new>
```
The deadlines write uses existing `POST /api/projects/{id}/deadlines/bulk` semantics under the hood no new bulk-deadline-from-scenario endpoint needed.
---
## §6 Modular boundaries (light)
m said modular "doesn't super apply" dropped as a load-bearing goal. The natural decomposition below is build-hygiene documentation, not a constraint the coder must enforce.
### §6.1 Front-end components
| Component | File | Responsibility |
|---|---|---|
| `BuilderCanvas` | `frontend/src/components/BuilderCanvas.tsx` | Root render of the builder. Receives the active scenario, renders triplet stack + cold-open empty state |
| `ProceedingTriplet` | `frontend/src/components/ProceedingTriplet.tsx` | One proceeding's render: header strip (jurisdiction + name + perspective + Detailgrad + collapse + remove) + flag strip + 3 columns + spawn child triplets recursively |
| `EventCard` | `frontend/src/components/EventCard.tsx` | One card in a column lane. State / date / optional-horizon / notes affordances |
| `ScenarioFlagsStrip` | `frontend/src/components/ScenarioFlagsStrip.tsx` | Per-triplet flag toggles. Reads scenario_flag_catalog, applies to scenario_proceedings.scenario_flags |
| `AddProceedingPicker` | `frontend/src/components/AddProceedingPicker.tsx` | Inline picker triggered by `+ Verfahren hinzufügen`. Forum chip row Verfahren chip row `Hinzufügen` |
| `ScenarioListPanel` | `frontend/src/components/ScenarioListPanel.tsx` | Side panel: Aktiv / Geteilt / Promoted / Archiviert buckets + new-scenario CTA |
| `PromoteToProjectWizard` | `frontend/src/components/PromoteToProjectWizard.tsx` | 3-step modal: Bestätigen / Parteien / Metadaten |
| `PageHeaderControls` | `frontend/src/components/PageHeaderControls.tsx` | Scenario picker + Benennen/Teilen/Promote buttons + Akte picker + Stichtag input |
| `EntryModeChrome` | `frontend/src/components/EntryModeChrome.tsx` | Cold-open / event-triggered / Akte mode radio; ephemeral UI affordance that fades into canvas state |
### §6.2 Client TS files
Mirror the React-ish component split:
- `frontend/src/client/builder.ts` root orchestrator (auto-save loop, URL state, mode routing, scenario fetch)
- `frontend/src/client/builder-scenario.ts` scenario CRUD against `/api/scenarios`
- `frontend/src/client/builder-event-card.ts` per-card state machine + optional-horizon control
- `frontend/src/client/builder-promote-wizard.ts` 3-step wizard state machine
- `frontend/src/client/builder-search.ts` universal search (events + scenarios + Akten)
- `frontend/src/client/builder-shares.ts` share-with-team UI
### §6.3 Backend services + routes
| Service | File | Endpoints |
|---|---|---|
| `ScenarioService` | `internal/services/scenario_service.go` | List / Get / Create / Update / Archive / Promote |
| `ScenarioProceedingService` | `internal/services/scenario_proceeding_service.go` | Add / Remove / Update (flags, perspective, ordinal, detailgrad) |
| `ScenarioEventService` | `internal/services/scenario_event_service.go` | List / Update state / Set date / Set notes / Set horizon |
| `ScenarioShareService` | `internal/services/scenario_share_service.go` | List / Add / Remove shares |
| `ScenarioPromoteService` | `internal/services/scenario_promote_service.go` | Wizard-driven transactional promote |
Routes (added under existing API namespace):
```
GET /api/scenarios — list user's scenarios (filtered by status)
POST /api/scenarios — create new scenario
GET /api/scenarios/{id} — get scenario + proceedings + events (deep)
PATCH /api/scenarios/{id} — update name / stichtag / notes / status
DELETE /api/scenarios/{id} — archive (soft delete; status='archived')
POST /api/scenarios/{id}/proceedings — add proceeding to scenario
PATCH /api/scenarios/{id}/proceedings/{pid} — update flags / perspective / ordinal / detailgrad
DELETE /api/scenarios/{id}/proceedings/{pid} — remove proceeding (cascades to events)
PATCH /api/scenarios/{id}/events/{eid} — update state / date / notes / horizon
POST /api/scenarios/{id}/shares — share with user (read-only)
DELETE /api/scenarios/{id}/shares/{sid} — revoke share
POST /api/scenarios/{id}/promote — promote to project (3-step wizard payload)
POST /api/scenarios/from-project/{project_id} — export project to a new scenario (what-if)
GET /api/search — universal search (events + scenarios + Akten)
```
Existing endpoints used unchanged:
- `GET /api/tools/fristenrechner/search?kind=events` for the events corpus.
- `GET /api/projects` Akte picker source.
- `POST /api/projects/{id}/deadlines/bulk` promotion writes deadlines through this.
- `PATCH /api/projects/{id}/scenario-flags` Akte-mode flag sync.
---
## §7 Migration plan from current live shape
Current live (`/tools/procedures` on main @ `ed3c5d1`) = cronus's U0-U4 4-tab catalog. Migration is a 6-slice train, every slice ships visibly. No feature flag (m's pattern preference per #152 Q7).
### §7.1 Slice train
| Slice | What ships | DB | Visible to user |
|---|---|---|---|
| **B0 — Scenario DB foundation** | New tables (scenarios + scenario_proceedings + scenario_events + scenario_shares) + RLS + minimal API (list / create / get). Scenarios writable from a developer-only test route at first. | Mig #N (new tables + RLS + `paliad.projects.origin_scenario_id`) | No user-visible change. |
| **B1 — Builder shell + cold-open mode** | New `/tools/procedures` page replaces the 4-tab catalog. Renders: page header (scenario picker + Akte picker + Stichtag + search), entry-mode radio (cold-open active), filter strip, empty canvas + "Neues Szenario starten" CTA + recent list. Add-proceeding picker works; first triplet renders with the existing Verfahrensablauf-core calc. Auto-save active scenario. Side panel "Meine Szenarien" with Aktiv bucket only. | | New page visible. Single triplet works end-to-end. |
| **B2 — Multi-triplet + spawn nesting + per-event state** | Vertical multi-triplet stack with `+ Verfahren hinzufügen`. Per-triplet perspective + flag strip. Spawn child triplets render inline. Event cards get the 3-state machine (planned/filed/skipped) + date editor + per-card optional horizon chip. Page-header Stichtag drives default dates. | | Full scenario builder works without Akte integration. |
| **B3 — Event-triggered mode + universal search** | "Ereignis" entry mode wires the search box to land on a single-triplet anchored view (scratch scenario). Universal search returns events + scenarios + Akten with type-scoped result groups. Filter pills (forum/proc/party/kind) reset on mode switch. | | Event lookup works. |
| **B4 — Akte mode + project-backed scenarios** | "Aus Akte" entry mode + page-header Akte picker. Loads project state into the builder (proceeding + perspective + scenario_flags + deadlines actuals). Akte-backed scenarios write through to `paliad.deadlines` + `paliad.projects.scenario_flags`; non-Akte scenarios write to `paliad.scenario_events`. Cross-surface scenario-flag-changed event listener reused from #152 T3. | | Akte integration works end-to-end. |
| **B5 — Share + Promote-to-project wizard** | "Teilen" button + user picker + share row. "Geteilt mit mir" bucket in side panel. "Als Projekt anlegen" opens the 3-step wizard (Bestätigen Parteien ergänzen Akte-Metadaten). Successful commit creates project + cascades deadlines + sets `origin_scenario_id`, navigates to /projects/{id}. "Promoted" bucket in side panel. | | Sharing + promotion work. |
| **B6 — Mobile basic-read + cleanup + i18n polish** | Mobile (<640px) shows scenarios + cards read-only; mutating affordances prompt "Auf größerem Bildschirm öffnen". Cleanup: delete dead U0-U4 catalog code (4-tab control, legacy `verfahrensablauf.ts`, etc.). All i18n keys finalised (DE + EN). | | Mobile works; codebase cleaner. |
### §7.2 Why this train shape
- **B0 is DB-only**. The schema can land independently and be exercised via test routes / Supabase MCP before any UI sees it. Keeps mig risk isolated.
- **B1-B2 are the MVP**. After B2, a user can build and save a multi-proceeding scenario fully kontextfrei. That alone replaces 60% of today's catalog use.
- **B3 adds the lookup path**. After B3, "what's next after Klageerwiderung?" works without saving.
- **B4 makes it real**. Akte integration is the load-bearing piece for daily use; ships once the foundation is stable.
- **B5 unlocks team value**. Sharing + promotion are the difference between "personal tool" and "team tool". Ship after the core works.
- **B6 is cleanup**. Mobile read + dead code removal land last to avoid coupling to in-flight features.
### §7.3 What stays unchanged
- URL `/tools/procedures` keeps it (the new builder lives there).
- Sidebar entry "Verfahren & Fristen" keeps it.
- cmd-K palette keeps it.
- `/tools/fristenrechner` + `/tools/verfahrensablauf` legacy redirects (from cronus's U4) stay alive: 301 `/tools/procedures` (the builder).
- `pkg/litigationplanner.CalculateRule` untouched.
- `/admin/procedural-events` untouched.
- `/projects/{id}` Verlauf untouched (new "Im Builder öffnen" button is the only addition).
### §7.4 Cleanup at B6
Dead code to delete (verify with grep before deletion):
- `frontend/src/components/VerfahrensablaufBody.tsx` (replaced by ProceedingTriplet)
- `frontend/src/client/verfahrensablauf.ts` (replaced by builder.ts orchestration)
- `frontend/src/client/views/verfahrensablauf-state.ts` (replaced by scenario-backed state)
- `frontend/src/client/views/verfahrensablauf-state.test.ts`
- `frontend/src/client/verfahrensablauf-detail-mode.ts` (replaced by per-triplet Detailgrad)
- Existing scratch tab content in `frontend/src/client/procedures.ts` (4-tab toggling logic, mode routing)
**Kept**:
- `frontend/src/client/views/verfahrensablauf-core.ts` (calculation engine; reused by EventCard + ProceedingTriplet)
- Legacy URL redirects in Go (`/tools/fristenrechner` + `/tools/verfahrensablauf` `/tools/procedures`)
---
## §8 Open follow-ups (out of scope for v1)
Tracked for v1.1 / future tickets:
- **Cross-proceeding peer triggers** (UPC-inf judgment EPA opp choice deadline). New `paliad.scenario_event_links` table. UI: trigger-picker chip on event cards.
- **DE / EPA / DPMA full expansion**. v1 supports EPA + DPMA proceedings at the data layer (calc engine handles them), but the spawn flags and CCR-style nestings are UPC-specific. Other jurisdictions get proper coverage in v1.1.
- **Scenario versioning / snapshots**. m's Q8 alternative ("versioned snapshots") deferred. Add when scenarios start driving client briefings.
- **Multi-user concurrent editing**. Out of scope. Single-editor model with read-only shares is sufficient until usage shows otherwise.
- **Fork-a-shared-scenario**. Read-only sharing in v1 doesn't expose "fork into my workspace". Add when team usage demands it.
- **Comments on scenarios / event cards**. Out of scope (separate ticket).
- **PDF export of a scenario for client briefings**. Out of scope.
- **Mobile-parity edits**. v1.1 full mobile interaction loop.
- **Audit log on scenario edits**. Out of scope (exploratory data).
- **Cross-scenario comparison view**. ("Compare planned vs actual" lives on the project page via promote-then-compare; explicit comparison tool is v2.)
---
## §9 Synthesis links
- **mBrian**: file as `[synthesis]` linked `triggered_by` t-paliad-339; `related_to` atlas's reverted tracker design, cronus's unified-procedural-events-tool design, atlas's deadline-system-revision.
- **Cross-refs in this repo**: `docs/design-procedures-workflow-tracker-2026-05-27.md` (atlas, reverted), `docs/design-unified-procedural-events-tool-2026-05-27.md` (cronus, live), `docs/design-deadline-system-revision-2026-05-27.md` (atlas Phase 2), `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26).
- **Gitea**: m/paliad#153 (this PRD), m/paliad#152 (atlas's tracker, reverted), m/paliad#151 (cronus U0-U4 shipped), m/paliad#149 (atlas Phase 2 in flight).
- **Coder phase** (deferred per inventor SKILL): runs after m ratifies this PRD. Slice ordering per §7.1. NOT edison (parked at DESIGN READY FOR REVIEW). NOT atlas (just-rejected tracker framing bias). NOT cronus (parked on Fristenrechner inventor branch). A pattern-fluent Sonnet coder picks up B0 first.
---
## §10 Coder hand-off notes
(Pre-emptive for whoever picks up B0.)
- **Migration number**: check `internal/db/migrations/` for the max slot at coder shift start. Two recent migrations (curie's t-paliad-336, ritchie's t-paliad-149 P0) are in flight; coordinate via paliadin/head before claiming a slot.
- **Akte integration nuance**: when the builder is in Akte mode and the scenario is project-backed, writes flow to `paliad.deadlines` / `paliad.projects.scenario_flags` instead of `paliad.scenario_*` tables the scenario row itself just records the canvas view-state (which triplets are visible, ordinal, collapsed state, per-card horizon). This dual-write rule is the load-bearing complexity of B4; design tests for it explicitly.
- **Auto-save throttling**: 500ms debounce per change. Avoid PATCH-per-keystroke on notes textareas (use blur-trigger + 2s debounce there).
- **Search performance**: universal search (events + scenarios + Akten) needs to stay snappy. Events corpus is ~3000 rows; scenarios/Akten are per-user. Use existing trgm indexes; avoid joining across all three for ranking.
- **B5 transactional promotion**: do the wizard's commit in a single Postgres transaction. If any of (project insert / parties / deadlines / scenario status update) fails, roll back atomically. No partial promotions.
- **Mobile rendering**: B6 is meant to be cheap. Column-triplet CSS grid that collapses to single-column at `@media (max-width: 640px)`. Mutating affordances get `pointer-events: none` + a click-handler that surfaces the "Auf größerem Bildschirm öffnen" toast keeps the desktop interaction code paths unchanged.
- **i18n keys**: every user-facing string gets `data-i18n` from B1. Don't accumulate i18n debt across slices.

View File

@@ -0,0 +1,507 @@
// Fristenrechner overhaul Mode A — "Direkt suchen" (design §3.1).
//
// Power-user surface: a filter strip (Forum / Verfahren / Was passierte /
// Partei) over a free-text search box over a result list of
// procedural_events. Clicking a row locks the event as the trigger and
// transitions to the shared result view (S2). Inbox channel chip lives
// as a secondary "Erweitert" toggle per design §3.3 — picking CMS / beA
// / Postal auto-sets the Forum chip.
//
// Section-split visual hierarchy per m §11.Q3: filter strip on top
// ("Filter (eingrenzen)") with the four chip groups, search box and
// result list below — clicking a result row IS the qualifier action.
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
import { getLang, t, tDyn } from "./i18n";
import { mountResultView } from "./fristenrechner-result";
// Wire shape from GET /api/tools/fristenrechner/search?kind=events.
// Mirrors services.EventSearchResponse server-side.
interface EventSearchHit {
id: string;
code: string;
name_de: string;
name_en: string;
event_kind?: string;
description?: string;
primary_party?: string;
proceeding_type: {
id: number;
code: string;
name_de: string;
name_en: string;
jurisdiction?: string;
};
anchor_rule_id: string;
follow_up_count: number;
concept_id?: string;
score: number;
}
interface EventSearchResponse {
query: string;
events: EventSearchHit[];
total: number;
}
interface ProceedingChip {
code: string;
name: string;
nameEN: string;
group: string;
}
// Module-local state — single Mode A surface at a time.
interface ModeAState {
jurisdiction: string; // "" = Alle
proc: string; // proceeding_types.code, "" = Alle
eventKind: string; // "" = Alle
party: string; // "" = Alle (Mode A's filter semantics, §11.Q8)
q: string; // free-text query
inbox: string; // CMS / bea / postal / "" — secondary, design §3.3
inboxOpen: boolean;
}
const state: ModeAState = {
jurisdiction: "",
proc: "",
eventKind: "",
party: "",
q: "",
inbox: "",
inboxOpen: false,
};
// Debounce token for search input — avoid hammering the server on
// every keystroke.
let searchSeq = 0;
let searchTimer: ReturnType<typeof setTimeout> | null = null;
// Chip data — static. Forum and event-kind are closed-set per design;
// party is closed-set with "Beide" option (Mode A is filter mode,
// §11.Q8). Inbox secondary chip set per §3.3.
const FORUMS = ["UPC", "DE", "EPA", "DPMA"] as const;
const EVENT_KINDS = ["filing", "hearing", "decision", "order"] as const;
const PARTIES = ["claimant", "defendant", "both"] as const;
// Forum auto-derivation from inbox chip per §3.3: CMS → UPC, beA → DE,
// Postal → no narrowing (postal arrives at every jurisdiction).
const INBOX_TO_FORUM: Record<string, string> = {
cms: "UPC",
bea: "DE",
postal: "",
};
// MODE_A_HOST_ID is the DOM id of the container Mode A renders into.
// The mode shell (fristenrechner-result.mountModeShell) creates this
// element under the overhaul root and hands it to Mode A; Mode A
// otherwise has no opinion about its placement on the page.
const MODE_A_HOST_ID = "fristen-overhaul-mode-host";
export function isModeASurfaceMounted(): boolean {
return !!document.getElementById("fristen-mode-a-root");
}
// mountModeA renders the Mode A surface into the overhaul root. Reads
// initial state from URL params so deep links restore the previous
// filter / search state.
export async function mountModeA(): Promise<void> {
const root = document.getElementById(MODE_A_HOST_ID);
if (!root) return;
// Hydrate state from URL.
const params = new URLSearchParams(window.location.search);
state.jurisdiction = (params.get("forum") || "").toUpperCase();
state.proc = params.get("pt") || "";
state.eventKind = params.get("kind") || "";
state.party = params.get("party") || "";
state.q = params.get("q") || "";
renderShell();
await loadProceedingChips();
void runSearch();
}
// renderShell builds the Mode A markup. Idempotent re-call from the
// boot path; row-level rewrites use renderResults / renderFilterStrip
// for finer-grained updates.
function renderShell(): void {
const root = document.getElementById("fristen-overhaul-root");
if (!root) return;
root.innerHTML = `
<div id="fristen-mode-a-root" class="fristen-mode-a-root">
<section class="fristen-mode-a-filters" aria-label="${escAttr(t("deadlines.overhaul.modea.filters.label"))}">
<header class="fristen-mode-a-filters-header">
<span class="fristen-mode-a-filters-title">${escHtml(t("deadlines.overhaul.modea.filters.heading"))}</span>
</header>
<div class="fristen-mode-a-chip-row" data-axis="forum">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.forum"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-forum"></div>
</div>
<div class="fristen-mode-a-chip-row" data-axis="proc">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.proc"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-proc"></div>
</div>
<div class="fristen-mode-a-chip-row" data-axis="kind">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.kind"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-kind"></div>
</div>
<div class="fristen-mode-a-chip-row" data-axis="party">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.party"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-party"></div>
</div>
<details class="fristen-mode-a-inbox" ${state.inboxOpen ? "open" : ""}>
<summary class="fristen-mode-a-inbox-summary">${escHtml(t("deadlines.overhaul.modea.inbox.summary"))}</summary>
<div class="fristen-mode-a-chip-row" data-axis="inbox">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.inbox"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-inbox"></div>
</div>
</details>
</section>
<section class="fristen-mode-a-search" aria-label="${escAttr(t("deadlines.overhaul.modea.search.label"))}">
<div class="fristen-mode-a-search-input-wrap">
<svg class="fristen-mode-a-search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="search" id="fristen-mode-a-search-input"
class="fristen-mode-a-search-input"
autocomplete="off" spellcheck="false"
data-i18n-placeholder="deadlines.overhaul.modea.search.placeholder"
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing…"
value="${escAttr(state.q)}" />
</div>
</section>
<section class="fristen-mode-a-results" aria-label="${escAttr(t("deadlines.overhaul.modea.results.label"))}">
<header class="fristen-mode-a-results-header">
<span class="fristen-mode-a-results-title">${escHtml(t("deadlines.overhaul.modea.results.heading"))}</span>
<span class="fristen-mode-a-results-count" id="fristen-mode-a-results-count"></span>
</header>
<ul class="fristen-mode-a-result-list" id="fristen-mode-a-result-list" role="listbox" aria-live="polite"></ul>
</section>
</div>
`;
renderForumChips();
renderKindChips();
renderPartyChips();
renderInboxChips();
// Proceeding chips render later, after fetch.
// Wire search input.
const input = document.getElementById("fristen-mode-a-search-input") as HTMLInputElement | null;
if (input) {
input.addEventListener("input", () => {
state.q = input.value;
scheduleSearch(180);
});
input.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") {
e.preventDefault();
scheduleSearch(0);
}
});
}
}
// Filter-strip chip renderers ----------------------------------------
function renderForumChips(): void {
const host = document.getElementById("fristen-mode-a-chips-forum");
if (!host) return;
const chips = [
chipHtml("forum", "", t("deadlines.overhaul.modea.chip.all"), state.jurisdiction === ""),
...FORUMS.map((j) => chipHtml("forum", j, j, state.jurisdiction === j)),
];
host.innerHTML = chips.join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
const v = btn.dataset.value || "";
state.jurisdiction = v;
// Forum change invalidates the proc pick if it falls outside.
state.proc = "";
syncUrl();
renderForumChips();
void loadProceedingChips();
scheduleSearch(0);
});
});
}
function renderKindChips(): void {
const host = document.getElementById("fristen-mode-a-chips-kind");
if (!host) return;
const chips = [
chipHtml("kind", "", t("deadlines.overhaul.modea.chip.all"), state.eventKind === ""),
...EVENT_KINDS.map((k) => chipHtml("kind", k, t(`deadlines.overhaul.kind.${k}` as never), state.eventKind === k, eventKindIconForChip(k))),
];
host.innerHTML = chips.join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
state.eventKind = btn.dataset.value || "";
syncUrl();
renderKindChips();
scheduleSearch(0);
});
});
}
function renderPartyChips(): void {
const host = document.getElementById("fristen-mode-a-chips-party");
if (!host) return;
const chips = [
chipHtml("party", "", t("deadlines.overhaul.modea.chip.all"), state.party === ""),
...PARTIES.map((p) => chipHtml("party", p, t(`deadlines.party.${p}` as never), state.party === p)),
];
host.innerHTML = chips.join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
state.party = btn.dataset.value || "";
syncUrl();
renderPartyChips();
scheduleSearch(0);
});
});
}
function renderInboxChips(): void {
const host = document.getElementById("fristen-mode-a-chips-inbox");
if (!host) return;
const opts = [
{ v: "", label: t("deadlines.overhaul.modea.chip.all") },
{ v: "cms", label: "CMS" },
{ v: "bea", label: "beA" },
{ v: "postal", label: t("deadlines.overhaul.modea.inbox.postal") },
];
host.innerHTML = opts.map((o) => chipHtml("inbox", o.v, o.label, state.inbox === o.v)).join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
const v = btn.dataset.value || "";
state.inbox = v;
// Auto-nudge forum from inbox per design §3.3.
const nudge = INBOX_TO_FORUM[v];
if (nudge !== undefined && nudge !== "") {
state.jurisdiction = nudge;
state.proc = "";
renderForumChips();
void loadProceedingChips();
}
renderInboxChips();
scheduleSearch(0);
});
});
}
// Proceeding chips — dynamic fetch.
let lastProcFetchKey = "";
async function loadProceedingChips(): Promise<void> {
const host = document.getElementById("fristen-mode-a-chips-proc");
if (!host) return;
const key = `j=${state.jurisdiction}`;
if (lastProcFetchKey === key) return; // cached for current jurisdiction
lastProcFetchKey = key;
host.innerHTML = `<span class="fristen-mode-a-chip-loading">${escHtml(t("deadlines.overhaul.modea.loading"))}</span>`;
const url = new URL("/api/tools/proceeding-types", window.location.origin);
url.searchParams.set("kind", "proceeding");
if (state.jurisdiction) url.searchParams.set("jurisdiction", state.jurisdiction);
let chips: ProceedingChip[] = [];
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (resp.ok) {
const data = (await resp.json()) as ProceedingChip[] | null;
chips = data || [];
}
} catch {
// Soft-fail: chip strip just hides; search still runs without
// proceeding narrowing.
}
renderProceedingChips(chips);
}
function renderProceedingChips(chips: ProceedingChip[]): void {
const host = document.getElementById("fristen-mode-a-chips-proc");
if (!host) return;
const lang = getLang();
if (chips.length === 0) {
host.innerHTML = `<span class="fristen-mode-a-chip-empty">${escHtml(t("deadlines.overhaul.modea.no_proceedings"))}</span>`;
return;
}
const rendered = [
chipHtml("proc", "", t("deadlines.overhaul.modea.chip.all"), state.proc === ""),
...chips.map((c) => {
const label = lang === "en" ? c.nameEN || c.name : c.name;
return chipHtml("proc", c.code, label, state.proc === c.code, undefined, c.code);
}),
];
host.innerHTML = rendered.join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
state.proc = btn.dataset.value || "";
syncUrl();
renderProceedingChips(chips);
scheduleSearch(0);
});
});
}
// Search ------------------------------------------------------------
function scheduleSearch(delayMs: number): void {
if (searchTimer !== null) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
searchTimer = null;
void runSearch();
}, delayMs);
}
async function runSearch(): Promise<void> {
searchSeq++;
const mySeq = searchSeq;
const list = document.getElementById("fristen-mode-a-result-list");
const count = document.getElementById("fristen-mode-a-results-count");
if (!list || !count) return;
list.innerHTML = `<li class="fristen-mode-a-result-loading">${escHtml(t("deadlines.overhaul.modea.loading"))}</li>`;
count.textContent = "";
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
url.searchParams.set("kind", "events");
if (state.q) url.searchParams.set("q", state.q);
if (state.jurisdiction) url.searchParams.set("jurisdiction", state.jurisdiction);
if (state.proc) url.searchParams.set("proc", state.proc);
if (state.eventKind) url.searchParams.set("event_kind", state.eventKind);
if (state.party) url.searchParams.set("party", state.party);
let data: EventSearchResponse;
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
if (mySeq === searchSeq) {
list.innerHTML = `<li class="fristen-mode-a-result-error">${escHtml(t("deadlines.overhaul.modea.search_error"))}</li>`;
}
return;
}
data = (await resp.json()) as EventSearchResponse;
} catch {
if (mySeq === searchSeq) {
list.innerHTML = `<li class="fristen-mode-a-result-error">${escHtml(t("deadlines.overhaul.modea.search_error"))}</li>`;
}
return;
}
if (mySeq !== searchSeq) return; // stale response
renderResults(data);
}
function renderResults(data: EventSearchResponse): void {
const list = document.getElementById("fristen-mode-a-result-list");
const count = document.getElementById("fristen-mode-a-results-count");
if (!list || !count) return;
count.textContent = tDyn("deadlines.overhaul.modea.results.count").replace("{n}", String(data.total));
if (data.events.length === 0) {
list.innerHTML = `<li class="fristen-mode-a-result-empty">${escHtml(t("deadlines.overhaul.modea.no_results"))}</li>`;
return;
}
const lang = getLang();
list.innerHTML = data.events.map((e) => {
const name = lang === "en" ? e.name_en || e.name_de : e.name_de;
const pt = e.proceeding_type;
const ptName = lang === "en" ? pt.name_en || pt.name_de : pt.name_de;
const icon = eventKindIconForChip(e.event_kind);
const followUps = tDyn("deadlines.overhaul.modea.row.followups").replace("{n}", String(e.follow_up_count));
const juris = pt.jurisdiction || "";
return `
<li class="fristen-mode-a-result" data-event-code="${escAttr(e.code)}" tabindex="0" role="option">
<span class="fristen-mode-a-result-icon" aria-hidden="true">${icon}</span>
<div class="fristen-mode-a-result-body">
<div class="fristen-mode-a-result-title">${escHtml(name)}</div>
<div class="fristen-mode-a-result-meta">
<span class="fristen-mode-a-result-pt">${escHtml(pt.code)}</span>
<span class="fristen-mode-a-result-pt-name">${escHtml(ptName)}</span>
${juris ? `<span class="fristen-mode-a-result-juris">${escHtml(juris)}</span>` : ""}
<span class="fristen-mode-a-result-followups">${escHtml(followUps)}</span>
</div>
</div>
<span class="fristen-mode-a-result-cta" aria-hidden="true">&rarr;</span>
</li>
`;
}).join("");
list.querySelectorAll<HTMLLIElement>(".fristen-mode-a-result").forEach((li) => {
li.addEventListener("click", () => commitEvent(li.dataset.eventCode || ""));
li.addEventListener("keydown", (e) => {
const k = (e as KeyboardEvent).key;
if (k === "Enter" || k === " ") {
e.preventDefault();
commitEvent(li.dataset.eventCode || "");
}
});
});
}
// Commit — user picked a result; lock the event as trigger and
// transition to the §4 result view (S2).
function commitEvent(code: string): void {
if (!code) return;
// Reflect in URL before re-mounting so the result view's deep link
// is consistent.
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
url.searchParams.set("event", code);
// Preserve project / forum / kind filters so a back-navigation
// brings Mode A back with the same filters.
history.pushState(null, "", url.pathname + url.search + url.hash);
void mountResultView({
eventRef: code,
party: state.party || undefined,
});
}
// Helpers -----------------------------------------------------------
function chipHtml(axis: string, value: string, label: string, active: boolean, icon?: string, title?: string): string {
const cls = `fristen-mode-a-chip${active ? " is-active" : ""}`;
const t = title ? ` title="${escAttr(title)}"` : "";
const i = icon ? `<span class="fristen-mode-a-chip-icon" aria-hidden="true">${icon}</span>` : "";
return `<button type="button" class="${cls}" data-axis="${escAttr(axis)}" data-value="${escAttr(value)}"${t}>${i}<span class="fristen-mode-a-chip-label">${escHtml(label)}</span></button>`;
}
function eventKindIconForChip(kind?: string): string {
switch (kind) {
case "filing": return "&#128229;";
case "hearing": return "&#127963;&#65039;";
case "decision": return "&#9878;&#65039;";
case "order": return "&#128220;";
default: return "&#128269;";
}
}
// syncUrl writes the active filter set into the URL so the deep link
// restores Mode A in the same state.
function syncUrl(): void {
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
setOrClear(url, "forum", state.jurisdiction);
setOrClear(url, "pt", state.proc);
setOrClear(url, "kind", state.eventKind);
setOrClear(url, "party", state.party);
setOrClear(url, "q", state.q);
history.replaceState(null, "", url.pathname + url.search + url.hash);
}
function setOrClear(url: URL, key: string, val: string): void {
if (val) url.searchParams.set(key, val);
else url.searchParams.delete(key);
}

View File

@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test";
import {
defaultChecked,
groupFollowUps,
type FollowUpRule,
} from "./fristenrechner-result";
// Pure helpers exercised here; the DOM-driven render path is covered
// by the live page test path (S2 is mount-on-deep-link, S3+S4 add the
// entry-mode UIs in later slices).
function mk(partial: Partial<FollowUpRule>): FollowUpRule {
return {
rule_id: "r" + Math.random().toString(36).slice(2, 8),
event_code: "evt",
title_de: "Frist",
title_en: "Deadline",
priority: "mandatory",
is_court_set: false,
is_spawn: false,
is_bilateral: false,
has_condition: false,
...partial,
};
}
describe("groupFollowUps — design §4.2 priority+condition buckets", () => {
test("groups by priority; conditional takes precedence over priority", () => {
const rows = [
mk({ priority: "mandatory" }),
mk({ priority: "recommended" }),
mk({ priority: "optional" }),
mk({ priority: "mandatory", has_condition: true }), // → conditional
mk({ priority: "optional", has_condition: true }), // → conditional
];
const g = groupFollowUps(rows);
expect(g.mandatory.length).toBe(1);
expect(g.recommended.length).toBe(1);
expect(g.optional.length).toBe(1);
expect(g.conditional.length).toBe(2);
});
test("unknown priority falls through to optional", () => {
const g = groupFollowUps([mk({ priority: "informational" })]);
expect(g.optional.length).toBe(1);
expect(g.mandatory.length).toBe(0);
});
});
describe("defaultChecked — pre-checks mandatory + recommended, not conditional/court-set", () => {
test("mandatory rules pre-checked", () => {
expect(defaultChecked(mk({ priority: "mandatory" }))).toBe(true);
});
test("recommended rules pre-checked", () => {
expect(defaultChecked(mk({ priority: "recommended" }))).toBe(true);
});
test("optional rules unchecked", () => {
expect(defaultChecked(mk({ priority: "optional" }))).toBe(false);
});
test("conditional rules unchecked", () => {
expect(defaultChecked(mk({ priority: "mandatory", has_condition: true }))).toBe(false);
});
test("court-set rules unchecked even when mandatory", () => {
expect(defaultChecked(mk({ priority: "mandatory", is_court_set: true }))).toBe(false);
});
test("spawned rules pre-checked when mandatory", () => {
expect(defaultChecked(mk({ priority: "mandatory", is_spawn: true }))).toBe(true);
});
test("spawned optional rules unchecked", () => {
expect(defaultChecked(mk({ priority: "optional", is_spawn: true }))).toBe(false);
});
});

View File

@@ -0,0 +1,693 @@
// Fristenrechner overhaul — shared result view (design §4).
//
// Given a locked trigger event + a trigger date, this module renders
// the result surface: a sticky trigger card on top, then four priority
// groups (mandatory / recommended / optional / conditional) of follow-up
// rules with computed dates, then a write-back footer that calls the
// existing POST /api/projects/{id}/deadlines/bulk.
//
// The two future entry paths (Mode A "Direkt suchen" in S3, Mode B
// wizard in S4) both land here once they've identified a trigger
// procedural_event. S2 mounts the surface under `?overhaul=1` and is
// deep-linkable on its own via `?overhaul=1&event=<code>&trigger_date=…`.
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
import { getLang, t, tDyn } from "./i18n";
// Wire shape from GET /api/tools/fristenrechner/follow-ups. Mirrors
// services.FollowUpsResponse server-side.
export interface FollowUpRule {
rule_id: string;
event_code: string;
title_de: string;
title_en: string;
priority: string;
primary_party?: string;
// m/paliad#149 Phase 2 S1 (design §2.4) — true when the rule's
// primary_party is the side opposite the perspective. Drives the
// Gegenseitig badge + muted style + unchecked default.
is_cross_party: boolean;
duration_value?: number;
duration_unit?: string;
timing?: string;
due_date?: string;
original_due_date?: string;
was_adjusted?: boolean;
is_court_set: boolean;
is_spawn: boolean;
is_bilateral: boolean;
has_condition: boolean;
rule_code?: string;
legal_source?: string;
legal_source_display?: string;
legal_source_url?: string;
notes_de?: string;
notes_en?: string;
spawn_label?: string;
spawn_proceeding_code?: string;
concept_id?: string;
}
export interface FollowUpsResponse {
trigger: {
id: string;
code: string;
name_de: string;
name_en: string;
event_kind?: string;
proceeding_type: {
id: number;
code: string;
name_de: string;
name_en: string;
jurisdiction?: string;
};
anchor_rule_id: string;
};
trigger_date: string;
party?: string;
follow_ups: FollowUpRule[];
}
// Per-rule UI state — checkbox, optional date override.
interface RuleSelection {
checked: boolean;
override?: string;
}
// Module-local state. Single result view at a time; the surface
// re-renders in place when the user changes the trigger date or
// re-locks a different event.
let currentResponse: FollowUpsResponse | null = null;
const selections = new Map<string, RuleSelection>();
let currentProjectId: string | null = null;
// Public API ----------------------------------------------------------
// isOverhaulMode reports whether the page is in overhaul mode.
// After Slice S5 (t-paliad-323), overhaul is the default; the legacy
// wizard / row-stack / cascade is only reachable via `?legacy=1` for
// a two-week deprecation window. The `?overhaul=1` deep links from
// S2-S4 still work — they're now redundant with the default but kept
// alive so bookmarks don't 302 / lose state.
export function isOverhaulMode(): boolean {
return new URLSearchParams(window.location.search).get("legacy") !== "1";
}
// resolveProjectId reads the active Akte from the URL query string.
// Returns null when in kontextfrei mode (no project picked).
function resolveProjectId(): string | null {
const p = new URLSearchParams(window.location.search).get("project");
return p && p.length > 0 ? p : null;
}
// MODE_TAB_KEYS — the two entry-mode tabs landed by S3 + S4. S2's deep
// link path bypasses these (jumps straight to the result view via
// ?event=); the tabs appear when no event is locked yet.
export type ModeTab = "search" | "wizard";
// mountModeShell renders the mode-tab pair under the page header and
// hosts whichever mode panel is currently active. Called from the boot
// path when no `?event=` is present. S3 wires Mode A; S4 will add
// Mode B and the actual tab switching.
export async function mountModeShell(activeTab: ModeTab): Promise<void> {
const root = document.getElementById("fristen-overhaul-root");
if (!root) return;
root.hidden = false;
// Defer to the per-mode module to render into the root. The tab
// strip itself is a small header above the mode panel — for S3 we
// render the shell + Mode A in one shot.
// S4 will replace this with a real tab switcher.
const tabs = `
<nav class="fristen-mode-tabs" role="tablist" aria-label="${escAttr(t("deadlines.overhaul.modes.label"))}">
<button type="button" class="fristen-mode-tab${activeTab === "search" ? " is-active" : ""}" role="tab"
aria-selected="${activeTab === "search"}" data-tab="search">
<span class="fristen-mode-tab-icon" aria-hidden="true">&#9889;</span>
<span class="fristen-mode-tab-label">${escHtml(t("deadlines.overhaul.modes.search"))}</span>
</button>
<button type="button" class="fristen-mode-tab${activeTab === "wizard" ? " is-active" : ""}" role="tab"
aria-selected="${activeTab === "wizard"}" data-tab="wizard">
<span class="fristen-mode-tab-icon" aria-hidden="true">&#129517;</span>
<span class="fristen-mode-tab-label">${escHtml(t("deadlines.overhaul.modes.wizard"))}</span>
</button>
</nav>
<div id="fristen-overhaul-mode-host"></div>
`;
root.innerHTML = tabs;
// Wire tab switching. S3 only has Mode A wired; Mode B is a
// placeholder until S4.
root.querySelectorAll<HTMLButtonElement>(".fristen-mode-tab").forEach((btn) => {
btn.addEventListener("click", () => {
const tab = (btn.dataset.tab || "search") as ModeTab;
void mountModeShell(tab);
});
});
// Mount the active mode panel into the host. S3 only routes "search";
// "wizard" renders a placeholder until S4 lands.
const host = document.getElementById("fristen-overhaul-mode-host");
if (!host) return;
if (activeTab === "search") {
// Lazy import to keep the bundle layered and avoid a circular ref
// between fristenrechner-result.ts ↔ fristenrechner-mode-a.ts.
const mod = await import("./fristenrechner-mode-a");
await mod.mountModeA();
} else {
const mod = await import("./fristenrechner-wizard");
await mod.mountWizard();
}
}
// MountOptions configures the surface entry. Both entry-mode paths
// (Mode A in S3, Mode B in S4) call mount() with the event reference
// that the user committed.
export interface MountOptions {
// eventRef is the procedural_event code OR its uuid OR the anchor
// sequencing_rule id. Resolved server-side; the wire returns the
// canonical code so the URL bookmark is stable.
eventRef: string;
// triggerDate is YYYY-MM-DD. Defaults to today when omitted.
triggerDate?: string;
// party is "claimant" | "defendant"; mode A may pass "both" or
// "court". When omitted, follow-ups are returned without party
// narrowing.
party?: string;
// courtId selects the holiday calendar for the per-rule date
// adjustment. Optional.
courtId?: string;
}
// mountResultView fetches /follow-ups and renders the result surface
// into the host container. Re-callable: replaces previous state.
export async function mountResultView(opts: MountOptions): Promise<void> {
const root = document.getElementById("fristen-overhaul-root");
if (!root) return;
root.hidden = false;
const triggerDate = opts.triggerDate || todayIso();
currentProjectId = resolveProjectId();
// Show a quick "loading…" placeholder so the user sees something
// immediately, even on a cold fetch.
root.innerHTML = `<div class="fristen-overhaul-loading">${escHtml(t("deadlines.overhaul.loading"))}</div>`;
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
url.searchParams.set("event", opts.eventRef);
url.searchParams.set("trigger_date", triggerDate);
if (opts.party) url.searchParams.set("party", opts.party);
if (opts.courtId) url.searchParams.set("court_id", opts.courtId);
let data: FollowUpsResponse;
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
const body = await resp.json().catch(() => ({}) as { error?: string });
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(body.error || t("deadlines.overhaul.load_error"))}</div>`;
return;
}
data = (await resp.json()) as FollowUpsResponse;
} catch (err) {
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(t("deadlines.overhaul.load_error"))}</div>`;
return;
}
currentResponse = data;
selections.clear();
for (const r of data.follow_ups) {
selections.set(r.rule_id, { checked: defaultChecked(r) });
}
renderSurface();
// Reflect the canonical event code + trigger date in the URL so the
// deep-link survives a reload.
syncUrlState(data.trigger.code, data.trigger_date);
}
// Render --------------------------------------------------------------
function renderSurface(): void {
const root = document.getElementById("fristen-overhaul-root");
if (!root || !currentResponse) return;
const lang = getLang();
const trig = currentResponse.trigger;
const triggerName = lang === "en" ? trig.name_en || trig.name_de : trig.name_de;
const ptName = lang === "en" ? trig.proceeding_type.name_en || trig.proceeding_type.name_de : trig.proceeding_type.name_de;
const juris = trig.proceeding_type.jurisdiction || "";
const kindIcon = eventKindIcon(trig.event_kind);
const triggerCard = `
<section class="fristen-overhaul-trigger" aria-label="${escAttr(t("deadlines.overhaul.trigger.label"))}">
<header class="fristen-overhaul-trigger-header">
<span class="fristen-overhaul-kind-icon" aria-hidden="true">${kindIcon}</span>
<h2 class="fristen-overhaul-trigger-title">${escHtml(triggerName)}</h2>
</header>
<div class="fristen-overhaul-trigger-meta">
<span class="fristen-overhaul-trigger-code">${escHtml(trig.code)}</span>
<span class="fristen-overhaul-trigger-pt">${escHtml(ptName)}</span>
${juris ? `<span class="fristen-overhaul-trigger-juris">${escHtml(juris)}</span>` : ""}
</div>
<div class="fristen-overhaul-trigger-date">
<label for="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-label">
${escHtml(t("deadlines.overhaul.trigger.date"))}
</label>
<input type="date" id="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-input"
value="${escAttr(currentResponse.trigger_date)}" />
</div>
</section>
`;
const groups = groupFollowUps(currentResponse.follow_ups);
const groupHtml = renderGroups(groups, lang);
const nudge = currentProjectId
? ""
: `<div class="fristen-overhaul-nudge">${escHtml(t("deadlines.overhaul.nudge.no_project"))}</div>`;
const footer = currentProjectId
? renderFooter()
: "";
root.innerHTML = `
${triggerCard}
${nudge}
<section class="fristen-overhaul-groups" aria-label="${escAttr(t("deadlines.overhaul.followups.label"))}">
${groupHtml}
</section>
${footer}
<div class="fristen-overhaul-msg" id="fristen-overhaul-msg" role="status" aria-live="polite"></div>
`;
wireSurfaceEvents();
}
export interface GroupedFollowUps {
mandatory: FollowUpRule[];
recommended: FollowUpRule[];
optional: FollowUpRule[];
conditional: FollowUpRule[];
}
// groupFollowUps splits the wire list into the four visible groups per
// design §4.2. Conditional (sr.condition_expr IS NOT NULL) takes
// precedence over the priority bucket so a "nur wenn CCR" mandatory
// rule renders under Conditional with the gating language visible.
export function groupFollowUps(rows: FollowUpRule[]): GroupedFollowUps {
const out: GroupedFollowUps = { mandatory: [], recommended: [], optional: [], conditional: [] };
for (const r of rows) {
if (r.has_condition) {
out.conditional.push(r);
continue;
}
switch (r.priority) {
case "mandatory":
out.mandatory.push(r);
break;
case "recommended":
out.recommended.push(r);
break;
case "optional":
out.optional.push(r);
break;
default:
// unknown / informational — fold into optional so the row is at
// least visible. Future Phase 2 'informational' tier gets a
// dedicated bucket once seeded.
out.optional.push(r);
}
}
return out;
}
function renderGroups(groups: GroupedFollowUps, lang: "de" | "en"): string {
const blocks: string[] = [];
if (groups.mandatory.length > 0) {
blocks.push(renderGroup("mandatory", t("deadlines.overhaul.group.mandatory"), groups.mandatory, lang));
}
if (groups.recommended.length > 0) {
blocks.push(renderGroup("recommended", t("deadlines.overhaul.group.recommended"), groups.recommended, lang));
}
if (groups.optional.length > 0) {
blocks.push(renderGroup("optional", t("deadlines.overhaul.group.optional"), groups.optional, lang));
}
if (groups.conditional.length > 0) {
blocks.push(renderGroup("conditional", t("deadlines.overhaul.group.conditional"), groups.conditional, lang));
}
if (blocks.length === 0) {
return `<div class="fristen-overhaul-empty">${escHtml(t("deadlines.overhaul.empty"))}</div>`;
}
return blocks.join("");
}
function renderGroup(slug: string, label: string, rows: FollowUpRule[], lang: "de" | "en"): string {
const items = rows.map((r) => renderRule(r, lang)).join("");
return `
<div class="fristen-overhaul-group fristen-overhaul-group--${escAttr(slug)}">
<h3 class="fristen-overhaul-group-title">${escHtml(label)}</h3>
<ul class="fristen-overhaul-rule-list">
${items}
</ul>
</div>
`;
}
function renderRule(r: FollowUpRule, lang: "de" | "en"): string {
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
const sel = selections.get(r.rule_id);
const checked = sel ? sel.checked : defaultChecked(r);
const dateOverride = sel?.override;
const computedDate = r.due_date || "";
const effectiveDate = dateOverride || computedDate;
const disabled = r.is_court_set || (r.is_spawn && !r.due_date);
// Duration phrase: "3 Monate" / "14 Tage" — language-aware.
const durationPhrase = formatDurationPhrase(r, lang);
const dateCell = r.is_court_set
? `<span class="fristen-overhaul-rule-court-set">${escHtml(t("deadlines.court.set"))}</span>`
: effectiveDate
? `<span class="fristen-overhaul-rule-date" data-rule-id="${escAttr(r.rule_id)}">${escHtml(formatDateForLang(effectiveDate, lang))}</span>`
: `<span class="fristen-overhaul-rule-date fristen-overhaul-rule-date--unknown">&mdash;</span>`;
const partyBadge = r.primary_party
? `<span class="fristen-overhaul-rule-party fristen-overhaul-rule-party--${escAttr(r.primary_party)}">${escHtml(t(`deadlines.party.${r.primary_party}` as never))}</span>`
: "";
const sourceBadge = r.legal_source_display
? r.legal_source_url
? `<a class="fristen-overhaul-rule-source" href="${escAttr(r.legal_source_url)}" target="_blank" rel="noreferrer">${escHtml(r.legal_source_display)}</a>`
: `<span class="fristen-overhaul-rule-source">${escHtml(r.legal_source_display)}</span>`
: r.rule_code
? `<span class="fristen-overhaul-rule-source">${escHtml(r.rule_code)}</span>`
: "";
const spawnBadge = r.is_spawn
? `<span class="fristen-overhaul-rule-spawn" title="${escAttr(t("deadlines.overhaul.spawn.tooltip"))}">${escHtml(t("deadlines.overhaul.spawn.badge"))}${r.spawn_proceeding_code ? ` · ${escHtml(r.spawn_proceeding_code)}` : ""}</span>`
: "";
const condBadge = r.has_condition
? `<span class="fristen-overhaul-rule-cond">${escHtml(t("deadlines.overhaul.condition.badge"))}</span>`
: "";
const crossPartyBadge = r.is_cross_party
? `<span class="fristen-overhaul-rule-crossparty" title="${escAttr(t("deadlines.overhaul.crossparty.tooltip"))}">${escHtml(t("deadlines.overhaul.crossparty.badge"))}</span>`
: "";
const notesHtml = notes
? `<details class="fristen-overhaul-rule-notes"><summary>${escHtml(t("deadlines.overhaul.notes.summary"))}</summary><p>${escHtml(notes)}</p></details>`
: "";
const editBtn = r.is_court_set || r.is_spawn || !computedDate
? ""
: `<button type="button" class="fristen-overhaul-rule-edit-date" data-rule-id="${escAttr(r.rule_id)}" title="${escAttr(t("deadlines.overhaul.edit_date.title"))}" aria-label="${escAttr(t("deadlines.overhaul.edit_date.title"))}">${escHtml(t("deadlines.overhaul.edit_date.label"))}</button>`;
return `
<li class="fristen-overhaul-rule${disabled ? " is-disabled" : ""}${r.is_cross_party ? " is-cross-party" : ""}" data-rule-id="${escAttr(r.rule_id)}">
<label class="fristen-overhaul-rule-check">
<input type="checkbox" data-rule-id="${escAttr(r.rule_id)}"
${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
<span class="visually-hidden">${escHtml(t("deadlines.overhaul.select_rule"))}</span>
</label>
<div class="fristen-overhaul-rule-body">
<div class="fristen-overhaul-rule-title-row">
<span class="fristen-overhaul-rule-title">${escHtml(title)}</span>
${spawnBadge}
${condBadge}
${crossPartyBadge}
</div>
<div class="fristen-overhaul-rule-meta-row">
${durationPhrase ? `<span class="fristen-overhaul-rule-duration">${escHtml(durationPhrase)}</span>` : ""}
${partyBadge}
${sourceBadge}
</div>
${notesHtml}
</div>
<div class="fristen-overhaul-rule-date-cell">
${dateCell}
${editBtn}
</div>
</li>
`;
}
function renderFooter(): string {
const selectedCount = countSelected();
return `
<footer class="fristen-overhaul-footer" id="fristen-overhaul-footer">
<span class="fristen-overhaul-footer-count" id="fristen-overhaul-footer-count">
${escHtml(tDyn("deadlines.overhaul.footer.count").replace("{n}", String(selectedCount)))}
</span>
<button type="button" class="fristen-overhaul-footer-cta btn-primary btn-cta-lime"
id="fristen-overhaul-write-back"
${selectedCount === 0 ? "disabled" : ""}>
${escHtml(t("deadlines.overhaul.footer.cta"))}
</button>
</footer>
`;
}
// Event wiring --------------------------------------------------------
function wireSurfaceEvents(): void {
// Trigger-date change → re-fetch with new date.
const dateInput = document.getElementById("fristen-overhaul-trigger-date") as HTMLInputElement | null;
if (dateInput && currentResponse) {
dateInput.addEventListener("change", () => {
if (!currentResponse) return;
const newDate = dateInput.value;
if (!newDate) return;
void mountResultView({
eventRef: currentResponse.trigger.code,
triggerDate: newDate,
party: currentResponse.party,
});
});
}
// Checkbox toggles → update selections + footer count.
const root = document.getElementById("fristen-overhaul-root");
if (root) {
root.querySelectorAll<HTMLInputElement>(".fristen-overhaul-rule-check input[type=checkbox]").forEach((cb) => {
cb.addEventListener("change", () => {
const id = cb.dataset.ruleId || "";
const sel = selections.get(id) ?? { checked: cb.checked };
sel.checked = cb.checked;
selections.set(id, sel);
refreshFooterCount();
});
});
// Per-rule date override.
root.querySelectorAll<HTMLButtonElement>(".fristen-overhaul-rule-edit-date").forEach((btn) => {
btn.addEventListener("click", () => editRuleDate(btn));
});
}
// Write-back CTA.
const cta = document.getElementById("fristen-overhaul-write-back");
if (cta) cta.addEventListener("click", () => void submitWriteBack());
}
function editRuleDate(btn: HTMLButtonElement): void {
const ruleId = btn.dataset.ruleId || "";
const rule = currentResponse?.follow_ups.find((r) => r.rule_id === ruleId);
if (!rule) return;
const sel = selections.get(ruleId) ?? { checked: defaultChecked(rule) };
const current = sel.override || rule.due_date || todayIso();
const dateCell = btn.parentElement;
if (!dateCell) return;
const dateSpan = dateCell.querySelector<HTMLSpanElement>(".fristen-overhaul-rule-date");
if (!dateSpan) return;
const input = document.createElement("input");
input.type = "date";
input.value = current;
input.className = "fristen-overhaul-rule-date-input";
dateSpan.replaceWith(input);
btn.disabled = true;
input.focus();
const commit = () => {
const newDate = input.value;
if (newDate && newDate !== current) {
sel.override = newDate;
selections.set(ruleId, sel);
}
renderSurface();
};
input.addEventListener("blur", commit, { once: true });
input.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") {
e.preventDefault();
input.blur();
} else if ((e as KeyboardEvent).key === "Escape") {
renderSurface();
}
});
}
function refreshFooterCount(): void {
const countEl = document.getElementById("fristen-overhaul-footer-count");
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
const n = countSelected();
if (countEl) {
countEl.textContent = tDyn("deadlines.overhaul.footer.count").replace("{n}", String(n));
}
if (cta) cta.disabled = n === 0;
}
function countSelected(): number {
let n = 0;
if (!currentResponse) return 0;
for (const r of currentResponse.follow_ups) {
if (r.is_court_set) continue;
// Cross-party rows are unconditionally excluded from write-back
// (design §2.4). Even if the user manually checks the box, they
// describe what the opponent files — not Akte work for our side.
if (r.is_cross_party) continue;
const sel = selections.get(r.rule_id);
if (sel?.checked) n++;
}
return n;
}
// Write-back ----------------------------------------------------------
async function submitWriteBack(): Promise<void> {
if (!currentResponse) return;
if (!currentProjectId) return;
const msg = document.getElementById("fristen-overhaul-msg");
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
const lang = getLang();
const deadlines: Array<Record<string, unknown>> = [];
for (const r of currentResponse.follow_ups) {
const sel = selections.get(r.rule_id);
if (!sel?.checked) continue;
if (r.is_court_set) continue;
// Skip cross-party rows even if checked — they describe opposing-
// side filings and don't belong in our side's Akte deadline set
// (design §2.4, write-back exclusion).
if (r.is_cross_party) continue;
const dueDate = sel.override || r.due_date;
if (!dueDate) continue;
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
deadlines.push({
title,
rule_code: r.rule_code || undefined,
due_date: dueDate,
original_due_date: r.original_due_date || r.due_date || undefined,
source: "fristenrechner",
rule_id: r.rule_id,
notes: notes || undefined,
audit_reason: auditReason(),
});
}
if (deadlines.length === 0 || !msg || !cta) return;
cta.disabled = true;
msg.textContent = "";
msg.className = "fristen-overhaul-msg";
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(currentProjectId)}/deadlines/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deadlines }),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = body.error || t("deadlines.save.error");
msg.className = "fristen-overhaul-msg form-msg-error";
cta.disabled = false;
return;
}
msg.innerHTML = `${escHtml(t("deadlines.save.success"))} <a href="/deadlines?project_id=${encodeURIComponent(currentProjectId)}">${escHtml(t("deadlines.save.success.link"))}</a>`;
msg.className = "fristen-overhaul-msg form-msg-ok";
setTimeout(() => {
if (cta) cta.disabled = false;
}, 1500);
} catch {
msg.textContent = t("deadlines.save.error");
msg.className = "fristen-overhaul-msg form-msg-error";
cta.disabled = false;
}
}
// audit reason per design §11.Q12: "Aus Fristenrechner — Trigger: {name} ({date})".
function auditReason(): string {
if (!currentResponse) return "";
const name = currentResponse.trigger.name_de;
const date = currentResponse.trigger_date;
return `Aus Fristenrechner — Trigger: ${name} (${date})`;
}
// Helpers -------------------------------------------------------------
export function defaultChecked(r: FollowUpRule): boolean {
// Cross-party rows are unchecked by default — they describe what the
// OTHER side files. They render to honestly show the workflow, but
// the Akte write-back excludes them unconditionally (design §2.4).
if (r.is_cross_party) return false;
if (r.is_court_set) return false;
if (r.is_spawn) return r.priority === "mandatory";
if (r.has_condition) return false;
return r.priority === "mandatory" || r.priority === "recommended";
}
function formatDurationPhrase(r: FollowUpRule, lang: "de" | "en"): string {
if (!r.duration_value || !r.duration_unit) return "";
const unitDE: Record<string, string> = {
days: "Tage",
months: "Monate",
weeks: "Wochen",
years: "Jahre",
};
const unitEN: Record<string, string> = {
days: "days",
months: "months",
weeks: "weeks",
years: "years",
};
const u = (lang === "en" ? unitEN : unitDE)[r.duration_unit] || r.duration_unit;
return `${r.duration_value} ${u}`;
}
function formatDateForLang(iso: string, lang: "de" | "en"): string {
// YYYY-MM-DD → DE: DD.MM.YYYY / EN: DD MMM YYYY (short).
if (!iso || iso.length < 10) return iso;
const [y, m, d] = iso.split("-");
if (!y || !m || !d) return iso;
if (lang === "en") {
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const idx = parseInt(m, 10) - 1;
const mn = idx >= 0 && idx < months.length ? months[idx] : m;
return `${parseInt(d, 10)} ${mn} ${y}`;
}
return `${d}.${m}.${y}`;
}
function eventKindIcon(kind?: string): string {
switch (kind) {
case "filing": return "&#128229;"; // inbox/letter
case "hearing": return "&#127963;&#65039;"; // courthouse
case "decision": return "&#9878;&#65039;"; // scales
case "order": return "&#128220;"; // page
default: return "&#128197;"; // calendar
}
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
function syncUrlState(eventCode: string, triggerDate: string): void {
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
url.searchParams.set("event", eventCode);
url.searchParams.set("trigger_date", triggerDate);
history.replaceState(null, "", url.pathname + url.search + url.hash);
}

View File

@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test";
import { followUpsDifferByParty } from "./fristenrechner-wizard";
describe("followUpsDifferByParty — R5 trigger condition (S4, design §3.2)", () => {
test("true when both claimant and defendant rules present", () => {
expect(followUpsDifferByParty([
{ primary_party: "claimant" },
{ primary_party: "defendant" },
])).toBe(true);
});
test("false when all claimant", () => {
expect(followUpsDifferByParty([
{ primary_party: "claimant" },
{ primary_party: "claimant" },
])).toBe(false);
});
test("false when all defendant", () => {
expect(followUpsDifferByParty([
{ primary_party: "defendant" },
])).toBe(false);
});
test("false when only 'both' rules", () => {
// "Both" rules are bilateral procedural moves (Vertraulichkeits-
// Erwiderung); they don't gate R5 because either party can be
// looking at them.
expect(followUpsDifferByParty([
{ primary_party: "both" },
{ primary_party: "both" },
])).toBe(false);
});
test("false when only court rules", () => {
expect(followUpsDifferByParty([
{ primary_party: "court" },
])).toBe(false);
});
test("true when mixed with both / court alongside the asymmetric pair", () => {
expect(followUpsDifferByParty([
{ primary_party: "both" },
{ primary_party: "claimant" },
{ primary_party: "court" },
{ primary_party: "defendant" },
])).toBe(true);
});
test("false on empty list", () => {
expect(followUpsDifferByParty([])).toBe(false);
});
});

View File

@@ -0,0 +1,711 @@
// Fristenrechner overhaul Mode B — "Geführt" / wizard (design §3.2).
//
// 3-5 question row stack that lands the user on one procedural_event
// (the trigger), then transitions to the shared §4 result view.
//
// R1 Was ist passiert? (event_kind) always asked
// R2 Vor welchem Gericht? (jurisdiction) skip if R1 narrows
// R3 In welchem Verfahren? (proceeding_type) auto-skip when 1 option
// R4 Welches Schriftstück? (procedural_event — land) always asked
// R5 Welche Seite vertreten Sie? (party) only when follow-ups differ
//
// Row badges per §11.Q3: R1+R2 = "Filter", R3+R4+R5 = "Qualifier".
// R5 has NO "Beide" option per §11.Q8 (Mode B is the file-mode where
// perspective is a qualifier).
// Pre-fill + collapse rows from project (project.proceeding_type →
// R3 + R2 derived; project.our_side → R5). Preserve compatible
// downstream picks on back-navigation (§11.Q10).
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
import { getLang, t, tDyn } from "./i18n";
import { mountResultView } from "./fristenrechner-result";
// Wire shapes — duplicates the parts of fristenrechner-mode-a.ts we
// need; kept local so the wizard doesn't depend on Mode A.
interface EventSearchHit {
id: string;
code: string;
name_de: string;
name_en: string;
event_kind?: string;
proceeding_type: {
id: number;
code: string;
name_de: string;
name_en: string;
jurisdiction?: string;
};
follow_up_count: number;
}
interface EventSearchResponse {
events: EventSearchHit[];
total: number;
}
interface ProceedingChip {
code: string;
name: string;
nameEN: string;
group: string;
}
interface ProjectSummary {
id: string;
proceeding_type_id?: number | null;
our_side?: string | null;
}
type Forum = "UPC" | "DE" | "EPA" | "DPMA";
type EventKindRow = "filing" | "hearing" | "decision" | "order" | "missed";
type WizardParty = "claimant" | "defendant";
// WIZARD_HOST_ID is the DOM id the wizard renders into. Mounted by
// fristenrechner-result.mountModeShell which creates the host element
// under the overhaul root.
const WIZARD_HOST_ID = "fristen-overhaul-mode-host";
// FORUMS + EVENT_KINDS — closed sets. Keep parallel to Mode A's lists
// so re-grouping happens in one place.
const FORUMS: Forum[] = ["UPC", "DE", "EPA", "DPMA"];
const EVENT_KINDS: EventKindRow[] = ["filing", "hearing", "decision", "order", "missed"];
// Single wizard state. Module-local; one wizard at a time.
interface WizardState {
// Picks. "" = not answered. R5 only set when the question is asked.
r1: EventKindRow | "";
r2: Forum | "";
r3: string; // proceeding_types.code
r4: string; // procedural_events.code
r5: WizardParty | "";
// Pre-fill provenance — when a pick came from the project context,
// the row renders with an "aus Akte" tag so the user notices.
r2FromProject: boolean;
r3FromProject: boolean;
r5FromProject: boolean;
// Implicit fills — R2 auto-derived from R1 when R1 narrows to one
// forum (e.g. "missed" → no narrowing, "filing" → cross-forum, but
// if downstream R3 lookup returns a single forum we can mark R2 as
// implicit).
r2Implicit: boolean;
r3Implicit: boolean;
}
const state: WizardState = {
r1: "", r2: "", r3: "", r4: "", r5: "",
r2FromProject: false, r3FromProject: false, r5FromProject: false,
r2Implicit: false, r3Implicit: false,
};
// Loaded from the project (if any).
let projectSummary: ProjectSummary | null = null;
// Proceeding chip cache key: jurisdiction × event_kind.
let lastProcCacheKey = "";
let cachedProcChips: ProceedingChip[] = [];
// Event chip cache: keyed on R3 code + R1 event_kind.
let lastEventCacheKey = "";
let cachedEventChips: EventSearchHit[] = [];
// Public API ---------------------------------------------------------
export async function mountWizard(): Promise<void> {
const host = document.getElementById(WIZARD_HOST_ID);
if (!host) return;
// Hydrate from URL state (mode=wizard&forum=UPC&pt=upc.inf.cfi&…).
const params = new URLSearchParams(window.location.search);
state.r1 = (params.get("kind") as EventKindRow) || "";
state.r2 = (params.get("forum") as Forum) || "";
state.r3 = params.get("pt") || "";
state.r4 = params.get("event") || "";
state.r5 = (params.get("party") as WizardParty) || "";
// Project prefills.
const projectId = params.get("project");
if (projectId) {
projectSummary = await fetchProject(projectId);
await applyProjectPrefills();
} else {
projectSummary = null;
}
renderShell();
void renderRows();
}
// applyProjectPrefills derives R2 + R3 + R5 from the project when they
// haven't been set explicitly. Project picks take precedence over
// unspecified state, but a user-supplied URL pick wins over the
// project default.
async function applyProjectPrefills(): Promise<void> {
if (!projectSummary) return;
// Map our_side → R5.
if (!state.r5) {
const side = projectSummary.our_side;
if (side === "claimant" || side === "applicant" || side === "appellant") {
state.r5 = "claimant";
state.r5FromProject = true;
} else if (side === "defendant" || side === "respondent") {
state.r5 = "defendant";
state.r5FromProject = true;
}
}
// Map proceeding_type_id → R3 + infer R2 jurisdiction.
if (projectSummary.proceeding_type_id && !state.r3) {
const pt = await fetchProceedingByID(projectSummary.proceeding_type_id);
if (pt) {
state.r3 = pt.code;
state.r3FromProject = true;
if (pt.group && !state.r2) {
state.r2 = pt.group as Forum;
state.r2FromProject = true;
}
}
}
}
// Render -------------------------------------------------------------
function renderShell(): void {
const host = document.getElementById(WIZARD_HOST_ID);
if (!host) return;
host.innerHTML = `
<div class="fristen-wizard-root">
<header class="fristen-wizard-header">
<h2 class="fristen-wizard-title">${escHtml(t("deadlines.overhaul.wizard.heading"))}</h2>
<p class="fristen-wizard-hint">${escHtml(t("deadlines.overhaul.wizard.hint"))}</p>
</header>
<div class="fristen-wizard-rows" id="fristen-wizard-rows" aria-live="polite"></div>
</div>
`;
}
async function renderRows(): Promise<void> {
const host = document.getElementById("fristen-wizard-rows");
if (!host) return;
// Resolve dynamic row prerequisites BEFORE building markup so chip
// sets are populated.
if (state.r1 && state.r2) {
await ensureProceedingChips(state.r2, state.r1);
// Auto-skip R3 when the narrowed pool has exactly one option.
if (!state.r3 && cachedProcChips.length === 1) {
state.r3 = cachedProcChips[0].code;
state.r3Implicit = true;
}
}
if (state.r1 && state.r3) {
await ensureEventChips(state.r3, state.r1);
}
const rows: string[] = [];
rows.push(rowR1());
if (shouldShowR2()) rows.push(rowR2());
if (shouldShowR3()) rows.push(rowR3());
if (shouldShowR4()) rows.push(rowR4());
if (state.r4 && shouldShowR5Sync()) rows.push(rowR5Loading());
host.innerHTML = rows.join("");
wireRowEvents();
// R5 conditional check — fires after R4 picked. Inspects /follow-ups
// to see whether they actually differ by party. If yes, show R5. If
// no, or R5 already set, transition straight to result view.
if (state.r4) {
void maybeAdvanceFromR4();
}
}
// Should-show predicates --------------------------------------------
function shouldShowR2(): boolean {
// Skip R2 only when R1 narrows to a single forum — which today
// never happens for the closed event_kind set (every kind exists in
// multiple jurisdictions). Always show R2 until we have empirical
// evidence otherwise.
return state.r1 !== "" && state.r1 !== "missed";
}
function shouldShowR3(): boolean {
if (state.r1 === "" || state.r2 === "") return false;
if (state.r3 && state.r3Implicit) return true; // visible compact
return true;
}
function shouldShowR4(): boolean {
return state.r3 !== "" && state.r1 !== "";
}
// shouldShowR5Sync renders the placeholder row immediately; the actual
// asked-or-not decision happens after the async follow-ups probe in
// maybeAdvanceFromR4.
function shouldShowR5Sync(): boolean {
return state.r4 !== "";
}
// Row builders ------------------------------------------------------
function rowR1(): string {
const chips = EVENT_KINDS.map((k) => {
const label = t(`deadlines.overhaul.kind.${k}` as never);
const icon = eventKindIcon(k);
return chipHtml("r1", k, label, state.r1 === k, icon);
}).join("");
return rowShell({
n: 1,
badge: "filter",
label: t("deadlines.overhaul.wizard.r1.label"),
active: !state.r1,
answeredText: state.r1 ? t(`deadlines.overhaul.kind.${state.r1}` as never) : "",
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
function rowR2(): string {
const chips = FORUMS.map((f) => chipHtml("r2", f, f, state.r2 === f)).join("");
return rowShell({
n: 2,
badge: "filter",
label: t("deadlines.overhaul.wizard.r2.label"),
active: !state.r2,
fromProject: state.r2FromProject,
answeredText: state.r2 || "",
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
function rowR3(): string {
if (cachedProcChips.length === 0) {
return rowShell({
n: 3, badge: "qualifier",
label: t("deadlines.overhaul.wizard.r3.label"),
active: true,
body: `<div class="fristen-wizard-empty">${escHtml(t("deadlines.overhaul.wizard.r3.empty"))}</div>`,
});
}
const lang = getLang();
const chips = cachedProcChips.map((p) => {
const label = lang === "en" ? p.nameEN || p.name : p.name;
return chipHtml("r3", p.code, label, state.r3 === p.code, undefined, p.code);
}).join("");
let answered = "";
if (state.r3) {
const hit = cachedProcChips.find((p) => p.code === state.r3);
if (hit) answered = lang === "en" ? hit.nameEN || hit.name : hit.name;
}
return rowShell({
n: 3,
badge: "qualifier",
label: t("deadlines.overhaul.wizard.r3.label"),
active: !state.r3,
fromProject: state.r3FromProject,
implicit: state.r3Implicit,
answeredText: answered,
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
function rowR4(): string {
if (cachedEventChips.length === 0) {
return rowShell({
n: 4, badge: "qualifier",
label: t("deadlines.overhaul.wizard.r4.label"),
active: true,
body: `<div class="fristen-wizard-empty">${escHtml(t("deadlines.overhaul.wizard.r4.empty"))}</div>`,
});
}
const lang = getLang();
const chips = cachedEventChips.map((e) => {
const label = lang === "en" ? e.name_en || e.name_de : e.name_de;
return chipHtml("r4", e.code, label, state.r4 === e.code, eventKindIcon(e.event_kind as EventKindRow));
}).join("");
let answered = "";
if (state.r4) {
const hit = cachedEventChips.find((e) => e.code === state.r4);
if (hit) answered = lang === "en" ? hit.name_en || hit.name_de : hit.name_de;
}
return rowShell({
n: 4,
badge: "qualifier",
label: t("deadlines.overhaul.wizard.r4.label"),
active: !state.r4,
answeredText: answered,
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
function rowR5Loading(): string {
// Placeholder while we probe whether R5 is needed. The async
// follow-ups probe replaces this with rowR5 chips or skips
// straight to the result view.
return rowShell({
n: 5, badge: "qualifier",
label: t("deadlines.overhaul.wizard.r5.label"),
active: !state.r5,
fromProject: state.r5FromProject,
answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "",
body: `<div class="fristen-wizard-probe">${escHtml(t("deadlines.overhaul.wizard.r5.probing"))}</div>`,
});
}
function rowR5Chips(): string {
const chips = (["claimant", "defendant"] as const).map((p) =>
chipHtml("r5", p, t(`deadlines.party.${p}` as never), state.r5 === p)).join("");
return rowShell({
n: 5, badge: "qualifier",
label: t("deadlines.overhaul.wizard.r5.label"),
active: !state.r5,
fromProject: state.r5FromProject,
answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "",
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
interface RowShellOpts {
n: number;
badge: "filter" | "qualifier";
label: string;
active: boolean;
body: string;
answeredText?: string;
fromProject?: boolean;
implicit?: boolean;
}
function rowShell(o: RowShellOpts): string {
const cls = `fristen-wizard-row fristen-wizard-row--${o.badge}` +
(o.active ? " is-active" : " is-answered") +
(o.fromProject ? " is-from-project" : "") +
(o.implicit ? " is-implicit" : "");
const badgeText = o.badge === "filter"
? t("deadlines.overhaul.wizard.badge.filter")
: t("deadlines.overhaul.wizard.badge.qualifier");
const annotations: string[] = [];
if (o.fromProject) annotations.push(`<span class="fristen-wizard-row-anno">${escHtml(t("deadlines.overhaul.wizard.anno.from_project"))}</span>`);
if (o.implicit) annotations.push(`<span class="fristen-wizard-row-anno">${escHtml(t("deadlines.overhaul.wizard.anno.implicit"))}</span>`);
const answered = o.answeredText
? `<span class="fristen-wizard-row-answer">${escHtml(o.answeredText)}</span>`
: "";
const edit = !o.active
? `<button type="button" class="fristen-wizard-row-edit" data-row="${o.n}">${escHtml(t("deadlines.overhaul.wizard.edit"))}</button>`
: "";
return `
<section class="${cls}" data-row="${o.n}">
<header class="fristen-wizard-row-header">
<span class="fristen-wizard-row-n">${o.n}</span>
<span class="fristen-wizard-row-badge fristen-wizard-row-badge--${o.badge}">${escHtml(badgeText)}</span>
<span class="fristen-wizard-row-label">${escHtml(o.label)}</span>
${annotations.join("")}
${answered}
${edit}
</header>
${o.active ? `<div class="fristen-wizard-row-body">${o.body}</div>` : ""}
</section>
`;
}
// Event wiring ------------------------------------------------------
function wireRowEvents(): void {
document.querySelectorAll<HTMLButtonElement>(".fristen-wizard-row .fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
const axis = btn.dataset.axis || "";
const value = btn.dataset.value || "";
handleChip(axis, value);
});
});
document.querySelectorAll<HTMLButtonElement>(".fristen-wizard-row-edit").forEach((btn) => {
btn.addEventListener("click", () => {
const n = parseInt(btn.dataset.row || "0", 10);
handleEdit(n);
});
});
}
function handleChip(axis: string, value: string): void {
switch (axis) {
case "r1": {
if (state.r1 === value) return;
state.r1 = value as EventKindRow;
// R1 change resets R3/R4 (event-kind narrows the pools).
state.r3 = "";
state.r3Implicit = false;
state.r4 = "";
state.r5 = state.r5FromProject ? state.r5 : "";
cachedEventChips = [];
lastEventCacheKey = "";
cachedProcChips = [];
lastProcCacheKey = "";
break;
}
case "r2": {
if (state.r2 === value) return;
state.r2 = value as Forum;
state.r2FromProject = false;
state.r2Implicit = false;
// R2 change may invalidate R3 → reset.
state.r3 = "";
state.r3FromProject = false;
state.r3Implicit = false;
state.r4 = "";
cachedProcChips = [];
lastProcCacheKey = "";
cachedEventChips = [];
lastEventCacheKey = "";
break;
}
case "r3": {
if (state.r3 === value) return;
state.r3 = value;
state.r3FromProject = false;
state.r3Implicit = false;
state.r4 = "";
cachedEventChips = [];
lastEventCacheKey = "";
break;
}
case "r4": {
if (state.r4 === value) return;
state.r4 = value;
break;
}
case "r5": {
if (state.r5 === value) return;
state.r5 = value as WizardParty;
state.r5FromProject = false;
break;
}
}
syncUrl();
void renderRows();
}
function handleEdit(n: number): void {
switch (n) {
case 1:
state.r1 = ""; state.r2 = ""; state.r3 = ""; state.r4 = ""; state.r5 = state.r5FromProject ? state.r5 : "";
cachedProcChips = []; lastProcCacheKey = "";
cachedEventChips = []; lastEventCacheKey = "";
break;
case 2:
state.r2 = ""; state.r2FromProject = false; state.r2Implicit = false;
state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false;
state.r4 = "";
cachedProcChips = []; lastProcCacheKey = "";
cachedEventChips = []; lastEventCacheKey = "";
break;
case 3:
state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false;
state.r4 = "";
cachedEventChips = []; lastEventCacheKey = "";
break;
case 4:
state.r4 = "";
state.r5 = state.r5FromProject ? state.r5 : "";
break;
case 5:
state.r5 = ""; state.r5FromProject = false;
break;
}
syncUrl();
void renderRows();
}
// maybeAdvanceFromR4 fetches /follow-ups for the picked event to
// decide whether R5 is needed. If R5 is already set OR the
// follow-ups don't differ by party, transition straight to the
// result view. Else swap the R5 loading row for the chip picker.
async function maybeAdvanceFromR4(): Promise<void> {
if (!state.r4) return;
if (state.r5) {
// R5 already answered (project prefill or explicit pick) → go.
void launchResult();
return;
}
// Probe follow-ups.
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
url.searchParams.set("event", state.r4);
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
// Soft-fail → swap to R5 chips so the user can decide manually.
swapR5(rowR5Chips());
return;
}
const data = (await resp.json()) as { follow_ups: Array<{ primary_party?: string }> };
const differs = followUpsDifferByParty(data.follow_ups);
if (!differs) {
void launchResult();
return;
}
swapR5(rowR5Chips());
} catch {
swapR5(rowR5Chips());
}
}
function swapR5(html: string): void {
const host = document.getElementById("fristen-wizard-rows");
if (!host) return;
const r5 = host.querySelector('.fristen-wizard-row[data-row="5"]');
if (!r5) {
host.insertAdjacentHTML("beforeend", html);
} else {
r5.outerHTML = html;
}
wireRowEvents();
}
function launchResult(): void {
// Hand off to the §4 result view. The URL already carries the
// picks via syncUrl(); add event= so the boot path treats this
// as a deep-link.
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
url.searchParams.set("event", state.r4);
if (state.r5) url.searchParams.set("party", state.r5);
else url.searchParams.delete("party");
history.pushState(null, "", url.pathname + url.search + url.hash);
void mountResultView({ eventRef: state.r4, party: state.r5 || undefined });
}
export function followUpsDifferByParty(rows: Array<{ primary_party?: string }>): boolean {
let hasClaimant = false, hasDefendant = false;
for (const r of rows) {
if (r.primary_party === "claimant") hasClaimant = true;
else if (r.primary_party === "defendant") hasDefendant = true;
if (hasClaimant && hasDefendant) return true;
}
return false;
}
// Fetches -----------------------------------------------------------
async function fetchProject(id: string): Promise<ProjectSummary | null> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`, { headers: { Accept: "application/json" } });
if (!resp.ok) return null;
return (await resp.json()) as ProjectSummary;
} catch {
return null;
}
}
async function fetchProceedingByID(id: number): Promise<ProceedingChip | null> {
// The proceeding-types endpoint returns codes, names, jurisdictions
// but doesn't carry the id (the wire shape FristenrechnerType is
// code-keyed). Walk the unfiltered list and pick by sort-order
// proximity / sort-fallback: we need the row whose id matches; since
// the wire doesn't expose id, fetch the projects detail to get the
// code directly. Cheap workaround: rely on /api/projects/{id}'s
// proceeding_type_id being matched against the proceeding-types list
// by jurisdiction round-trip is not possible without id. Instead
// expose the proceeding-types-by-id mapping via a follow-up endpoint
// later. For now hit the unfiltered list and assume the project's
// pick is in the active set.
//
// Pragmatic fallback: query the full list and return the only entry
// whose pseudo-id-via-sort-order matches. The lookup is unreliable
// until the wire shape includes id; for the project-prefill case the
// user can always re-pick R3 / R2 if the prefill misfires.
try {
const resp = await fetch(`/api/tools/proceeding-types`, { headers: { Accept: "application/json" } });
if (!resp.ok) return null;
const list = (await resp.json()) as ProceedingChip[] | null;
if (!list || list.length === 0) return null;
// Without id in the wire we cannot match by id. Skip the prefill
// silently — R3 stays unanswered and the user picks manually.
// (S5/follow-up can extend the wire shape to include id.)
void id;
return null;
} catch {
return null;
}
}
async function ensureProceedingChips(forum: Forum, kind: EventKindRow): Promise<void> {
const key = `${forum}\x00${kind}`;
if (lastProcCacheKey === key) return;
lastProcCacheKey = key;
const url = new URL("/api/tools/proceeding-types", window.location.origin);
url.searchParams.set("kind", "proceeding");
url.searchParams.set("jurisdiction", forum);
if (kind !== "missed") url.searchParams.set("event_kind", kind);
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
cachedProcChips = [];
return;
}
const data = (await resp.json()) as ProceedingChip[] | null;
cachedProcChips = data || [];
} catch {
cachedProcChips = [];
}
}
async function ensureEventChips(procCode: string, kind: EventKindRow): Promise<void> {
const key = `${procCode}\x00${kind}`;
if (lastEventCacheKey === key) return;
lastEventCacheKey = key;
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
url.searchParams.set("kind", "events");
url.searchParams.set("proc", procCode);
if (kind !== "missed") url.searchParams.set("event_kind", kind);
url.searchParams.set("limit", "100");
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
cachedEventChips = [];
return;
}
const data = (await resp.json()) as EventSearchResponse;
cachedEventChips = data.events || [];
} catch {
cachedEventChips = [];
}
}
// Helpers -----------------------------------------------------------
function chipHtml(axis: string, value: string, label: string, active: boolean, icon?: string, title?: string): string {
const cls = `fristen-mode-a-chip${active ? " is-active" : ""}`;
const tt = title ? ` title="${escAttr(title)}"` : "";
const i = icon ? `<span class="fristen-mode-a-chip-icon" aria-hidden="true">${icon}</span>` : "";
return `<button type="button" class="${cls}" data-axis="${escAttr(axis)}" data-value="${escAttr(value)}"${tt}>${i}<span class="fristen-mode-a-chip-label">${escHtml(label)}</span></button>`;
}
function eventKindIcon(kind?: EventKindRow): string {
switch (kind) {
case "filing": return "&#128229;";
case "hearing": return "&#127963;&#65039;";
case "decision": return "&#9878;&#65039;";
case "order": return "&#128220;";
case "missed": return "&#9202;";
default: return "&#128197;";
}
}
function syncUrl(): void {
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
url.searchParams.set("mode", "wizard");
setOrClear(url, "kind", state.r1);
setOrClear(url, "forum", state.r2);
setOrClear(url, "pt", state.r3);
// event=… is set only on launchResult; the wizard URL carries the
// R4 candidate via r4= so back/forward navigates within the wizard.
setOrClear(url, "r4", state.r4);
setOrClear(url, "party", state.r5);
history.replaceState(null, "", url.pathname + url.search + url.hash);
}
function setOrClear(url: URL, key: string, val: string): void {
if (val) url.searchParams.set(key, val);
else url.searchParams.delete(key);
}

View File

@@ -212,43 +212,6 @@ const translations: Record<Lang, Record<string, string>> = {
"procedures.tab.wizard": "Gef\u00fchrt",
"procedures.tab.akte": "Aus Akte",
"procedures.panel.akte.placeholder": "Akten-Einstieg folgt in einem sp\u00e4teren Slice.",
// Workflow-tracker shell (m/paliad#152 T1+) \u2014 keys for the new
// /tools/procedures shape (find header + per-proceeding cards).
"procedures.filter.axis.date": "Stichtag:",
"procedures.filter.forum.all": "Alle",
"procedures.filter.party.all": "Alle",
"procedures.timelines.loading": "Verfahren werden geladen\u2026",
"procedures.timelines.empty": "Keine Verfahren passen. Filter zur\u00fccksetzen.",
"procedures.timelines.error": "Fehler beim Laden dieses Verfahrens.",
"procedures.timelines.options": "Optionen:",
"procedures.timelines.court_set": "vom Gericht bestimmt",
"procedures.cold_open.hint": "Suchen oder filtern, um andere Verfahren einzublenden.",
"procedures.find.summary.empty": "Keine Treffer.",
"procedures.find.summary.one": "{n} Verfahren",
"procedures.find.summary.many": "{n} Verfahren",
"procedures.find.summary.anchor": "Anker: {name}",
"procedures.find.summary.akte": "Akte: {name}",
"procedures.node.actual.done": "Erledigt",
"procedures.node.actual.overdue": "Überfällig",
"procedures.node.actual.open": "Offen",
"procedures.node.cross": "Gegenseitige Handlung",
"procedures.node.cross.short": "Gegen.",
"procedures.proceeding.detail.title": "Detailgrad umschalten",
"procedures.proceeding.detail.selected": "· Gewählt ·",
"procedures.proceeding.detail.all": "Alle Optionen",
"procedures.appeal_target.label": "Berufung gegen:",
"procedures.node.pin": "An dieses Ereignis anheften",
"procedures.node.fokus": "Fokus \u2014 andere Zweige ausblenden",
"procedures.node.here": "\u2500\u2500 DU BIST HIER \u2500\u2500",
"procedures.zoom.breadcrumb": "Pfad",
"procedures.zoom.hidden": "{n} weitere Schritte verborgen \u2014 Fokus aufheben f\u00fcr volle Ansicht",
"procedures.proceeding.toggle": "Verfahren ein-/ausblenden",
"procedures.proceeding.show": "zeigen",
"procedures.proceeding.hide": "ausblenden",
"deadlines.flag.amend": "Mit Antrag auf Patent\u00e4nderung",
"deadlines.flag.cci": "Mit Verletzungswiderklage",
"nav.procedures": "Verfahren & Fristen",
"deadlines.step1": "Verfahrensart w\u00e4hlen",
@@ -3453,42 +3416,6 @@ const translations: Record<Lang, Record<string, string>> = {
"procedures.tab.wizard": "Guided",
"procedures.tab.akte": "From matter",
"procedures.panel.akte.placeholder": "Matter entry ships in a later slice.",
// Workflow-tracker shell (m/paliad#152 T1+).
"procedures.filter.axis.date": "As of:",
"procedures.filter.forum.all": "All",
"procedures.filter.party.all": "All",
"procedures.timelines.loading": "Loading proceedings…",
"procedures.timelines.empty": "No proceedings match. Reset filters.",
"procedures.timelines.error": "Failed to load this proceeding.",
"procedures.timelines.options": "Options:",
"procedures.timelines.court_set": "court-set",
"procedures.cold_open.hint": "Search or filter to surface other proceedings.",
"procedures.find.summary.empty": "No matches.",
"procedures.find.summary.one": "{n} proceeding",
"procedures.find.summary.many": "{n} proceedings",
"procedures.find.summary.anchor": "Anchor: {name}",
"procedures.find.summary.akte": "Matter: {name}",
"procedures.node.actual.done": "Done",
"procedures.node.actual.overdue": "Overdue",
"procedures.node.actual.open": "Open",
"procedures.node.cross": "Opposing-side action",
"procedures.node.cross.short": "Opp.",
"procedures.proceeding.detail.title": "Toggle detail level",
"procedures.proceeding.detail.selected": "· Selected ·",
"procedures.proceeding.detail.all": "All options",
"procedures.appeal_target.label": "Appeal target:",
"procedures.node.pin": "Pin this event as the anchor",
"procedures.node.fokus": "Focus — hide sibling branches",
"procedures.node.here": "── YOU ARE HERE ──",
"procedures.zoom.breadcrumb": "Path",
"procedures.zoom.hidden": "{n} more steps hidden — unfocus to see all",
"procedures.proceeding.toggle": "Toggle proceeding",
"procedures.proceeding.show": "show",
"procedures.proceeding.hide": "hide",
"deadlines.flag.amend": "With patent amendment request",
"deadlines.flag.cci": "With infringement counterclaim",
"nav.procedures": "Procedures & Deadlines",
"deadlines.step1": "Select Proceeding Type",

View File

@@ -1,704 +0,0 @@
// procedures-tracker — render module for /tools/procedures (m/paliad#152
// T1 + onwards, docs/design-procedures-workflow-tracker-2026-05-27.md).
//
// Responsibilities:
// - Per-proceeding card render: header + chained tree by parent_id +
// priority-styled bullets + scenario-flag fork checkboxes.
// - Tree layout: children grouped under their parentRuleCode in
// deadlines-list order, root rules surface at depth 0.
// - Default detail mode = "selected" (mandatory + recommended +
// scenario-flag-enabled). Conditional rules whose gate is OFF are
// filtered out by the calculator and don't surface; the
// corresponding fork checkbox on the gating node reveals them when
// toggled ON.
//
// T1 floor: card-level "Optionen" strip carries the scenario-flag
// forks at the top of each proceeding card. Per-node inline placement
// (the design's stated final shape — fork checkbox on the actual
// gating node) is a T2 refinement; T1 keeps forks discoverable but
// scoped per proceeding so they're not the global-page strip m's bug
// #5 flagged.
import { t, tDyn, getLang } from "./i18n";
import {
type CalculatedDeadline,
type DeadlineResponse,
calculateDeadlines,
escHtml,
formatDate,
} from "./views/verfahrensablauf-core";
import { filterByDetailMode } from "./verfahrensablauf-detail-mode";
// ProceedingDef — the catalog of proceedings the find header pills and
// the cold-open default surface against. Kept in sync with paliad's
// proceeding_types catalog as of 2026-05-27 (matches
// VerfahrensablaufBody.tsx's listing).
export interface ProceedingDef {
code: string;
forum: "upc" | "de" | "epa" | "dpma";
i18nKey: string;
nameDE: string;
nameEN: string;
}
export const PROCEEDINGS: ProceedingDef[] = [
{ code: "upc.inf.cfi", forum: "upc", i18nKey: "deadlines.upc.inf.cfi", nameDE: "Verletzungsverfahren", nameEN: "Infringement (CFI)" },
{ code: "upc.rev.cfi", forum: "upc", i18nKey: "deadlines.upc.rev.cfi", nameDE: "Nichtigkeitsklage", nameEN: "Revocation (CFI)" },
{ code: "upc.ccr.cfi", forum: "upc", i18nKey: "deadlines.upc.ccr.cfi", nameDE: "Widerklage auf Nichtigkeit", nameEN: "Counterclaim for revocation" },
{ code: "upc.pi.cfi", forum: "upc", i18nKey: "deadlines.upc.pi.cfi", nameDE: "Einstw. Maßnahmen", nameEN: "Provisional measures" },
{ code: "upc.apl.unified", forum: "upc", i18nKey: "deadlines.upc.apl.unified", nameDE: "Berufung UPC", nameEN: "Appeal UPC" },
{ code: "upc.dmgs.cfi", forum: "upc", i18nKey: "deadlines.upc.dmgs.cfi", nameDE: "Schadensbemessung", nameEN: "Damages" },
{ code: "upc.disc.cfi", forum: "upc", i18nKey: "deadlines.upc.disc.cfi", nameDE: "Bucheinsicht", nameEN: "Inspection of accounts" },
{ code: "de.inf.lg", forum: "de", i18nKey: "deadlines.de.inf.lg", nameDE: "LG Verletzungsklage", nameEN: "LG infringement (DE 1st inst.)" },
{ code: "de.inf.olg", forum: "de", i18nKey: "deadlines.de.inf.olg", nameDE: "OLG Berufung", nameEN: "OLG appeal" },
{ code: "de.inf.bgh", forum: "de", i18nKey: "deadlines.de.inf.bgh", nameDE: "BGH Revision / NZB", nameEN: "BGH revision / NZB" },
{ code: "de.null.bpatg", forum: "de", i18nKey: "deadlines.de.null.bpatg", nameDE: "BPatG Nichtigkeit", nameEN: "BPatG revocation" },
{ code: "de.null.bgh", forum: "de", i18nKey: "deadlines.de.null.bgh", nameDE: "BGH Berufung (Nichtigkeit)", nameEN: "BGH revocation appeal" },
{ code: "epa.opp.opd", forum: "epa", i18nKey: "deadlines.epa.opp.opd", nameDE: "Einspruchsverfahren EPA", nameEN: "EPO opposition" },
{ code: "epa.opp.boa", forum: "epa", i18nKey: "deadlines.epa.opp.boa", nameDE: "Beschwerdeverfahren EPA", nameEN: "EPO appeal" },
{ code: "epa.grant.exa", forum: "epa", i18nKey: "deadlines.epa.grant.exa", nameDE: "EP-Erteilungsverfahren", nameEN: "EP grant" },
{ code: "dpma.opp.dpma", forum: "dpma", i18nKey: "deadlines.dpma.opp.dpma", nameDE: "Einspruch DPMA", nameEN: "DPMA opposition" },
{ code: "dpma.appeal.bpatg", forum: "dpma", i18nKey: "deadlines.dpma.appeal.bpatg", nameDE: "Beschwerde BPatG (DPMA)", nameEN: "BPatG appeal (DPMA)" },
{ code: "dpma.appeal.bgh", forum: "dpma", i18nKey: "deadlines.dpma.appeal.bgh", nameDE: "Rechtsbeschwerde BGH", nameEN: "BGH legal appeal" },
];
// COLD_OPEN_DEFAULTS — design §8 / §11.Q4. When no URL params and no
// Akte context, the page renders these 6 proceedings stacked. Hint
// text above the timelines invites the user to filter for more.
export const COLD_OPEN_DEFAULTS: string[] = [
"upc.inf.cfi",
"upc.rev.cfi",
"upc.apl.unified",
"de.inf.lg",
"epa.opp.opd",
"dpma.opp.dpma",
];
// FORUM_LABEL mirrors the forum-pill label and the proceeding card
// header jurisdiction prefix. Same slugs the proceeding-types catalog
// carries.
const FORUM_LABEL: Record<string, string> = {
upc: "UPC",
de: "DE",
epa: "EPA",
dpma: "DPMA",
};
export function lookupProceeding(code: string): ProceedingDef | undefined {
return PROCEEDINGS.find((p) => p.code === code);
}
export function proceedingDisplayName(code: string): string {
const def = lookupProceeding(code);
if (!def) return code;
const lang = getLang();
return lang === "en" ? def.nameEN : def.nameDE;
}
// ─── condition_expr flag extraction ─────────────────────────────────────────
//
// Walks the jsonb tree shape documented in pkg/litigationplanner/expr.go:
// {"flag": "<name>"} — leaf
// {"op": "and|or|not", "args":[…]} — composite
// Returns the set of flag names mentioned in the expression. The page
// uses this to decide which scenario_flag forks apply to a given
// proceeding (the union over all conditional rules' expressions).
function collectFlagsFromExpr(node: unknown, out: Set<string>): void {
if (!node || typeof node !== "object") return;
const n = node as Record<string, unknown>;
if (typeof n.flag === "string" && n.flag) {
out.add(n.flag);
return;
}
if (Array.isArray(n.args)) {
for (const arg of n.args) collectFlagsFromExpr(arg, out);
}
}
export function gatingFlagsForProceeding(deadlines: CalculatedDeadline[]): string[] {
const set = new Set<string>();
for (const dl of deadlines) {
if (!dl.conditionExpr) continue;
collectFlagsFromExpr(dl.conditionExpr, set);
}
return Array.from(set).sort();
}
// Hard-coded fallback set: when the calc was run without a flag, the
// conditional rules gated by that flag are filtered out server-side
// (selected mode doesn't surface their condition_expr). The fallback
// lists the flags each proceeding *can* gate so the per-card Optionen
// strip surfaces them even on first render with the flag off.
//
// Mirrors mig 084's backfill: each scenario_flag → the proceedings
// where it's referenced by at least one rule. Today's catalog: 18
// conditional rules across upc.inf.cfi (with_ccr / with_amend) and
// upc.rev.cfi (with_amend / with_cci). Keep in sync with
// paliad.sequencing_rules.condition_expr.
const FALLBACK_FLAGS: Record<string, string[]> = {
"upc.inf.cfi": ["with_ccr", "with_amend"],
"upc.rev.cfi": ["with_amend", "with_cci"],
};
// Appeal-target slugs the engine accepts for the upc.apl.unified
// proceeding (mig 137 / B1). Each chip filters the appeal timeline to
// the rule subset whose applies_to_target jsonb contains the slug.
// Same vocabulary the VerfahrensablaufBody chip group exposes.
export const APPEAL_TARGETS = [
"endentscheidung",
"kostenentscheidung",
"anordnung",
"schadensbemessung",
"bucheinsicht",
] as const;
const APPEAL_TARGET_PROCEEDINGS = new Set(["upc.apl.unified"]);
function hasAppealTarget(code: string): boolean {
return APPEAL_TARGET_PROCEEDINGS.has(code);
}
// Per-proceeding detail mode persistence (T4 §3.4). State is keyed by
// proceeding code so a page with 3 proceedings can have one in "Alle
// Optionen" without affecting the others.
const DETAIL_MODE_PREFIX = "procedures.tracker.detail_mode:";
export function readDetailMode(code: string): "selected" | "all_options" {
try {
const raw = localStorage.getItem(DETAIL_MODE_PREFIX + code);
if (raw === "all_options") return "all_options";
} catch {
// fall through
}
return "selected";
}
export function writeDetailMode(code: string, mode: "selected" | "all_options"): void {
try {
if (mode === "selected") localStorage.removeItem(DETAIL_MODE_PREFIX + code);
else localStorage.setItem(DETAIL_MODE_PREFIX + code, mode);
} catch {
// localStorage unavailable — runtime state stays in memory only;
// toggle still works for this session.
}
}
export function applicableFlagsForProceeding(
code: string,
deadlines: CalculatedDeadline[],
): string[] {
const fromExpr = gatingFlagsForProceeding(deadlines);
if (fromExpr.length > 0) return fromExpr;
return FALLBACK_FLAGS[code] || [];
}
// flagLabel maps scenario_flag keys to i18n keys (matches the
// existing deadlines.flag.* keys used by VerfahrensablaufBody).
const FLAG_I18N: Record<string, string> = {
with_ccr: "deadlines.flag.ccr",
with_amend: "deadlines.flag.amend",
with_cci: "deadlines.flag.cci",
};
function labelForFlag(flagKey: string, proceeding: string): string {
// upc.inf.cfi with_amend = R.30 amendment request; upc.rev.cfi
// with_amend = R.49.2(a) amendment. Different labels even though the
// flag key is the same. Honour the inf vs rev distinction.
if (flagKey === "with_amend" && proceeding === "upc.inf.cfi") return t("deadlines.flag.inf_amend");
if (flagKey === "with_amend" && proceeding === "upc.rev.cfi") return t("deadlines.flag.rev_amend");
if (flagKey === "with_cci" && proceeding === "upc.rev.cfi") return t("deadlines.flag.rev_cci");
const key = FLAG_I18N[flagKey];
return key ? t(key) : flagKey;
}
// ─── per-proceeding render ──────────────────────────────────────────────────
// ActualStatus is the per-rule overlay derived from paliad.deadlines /
// paliad.appointments for an Akte (§6.4). The tracker reads this map
// when ?project= is set and stamps a status badge on each node.
export interface ActualStatus {
status: "done" | "open" | "overdue" | "court_set";
// dueDate / completedAt fall back to the calculator's projected date
// when not set (open / future). Format is ISO date.
dueDate?: string;
completedAt?: string;
// deadlineId lets the renderer deep-link to /projects/{p}/deadlines/{id}.
deadlineId?: string;
appointmentId?: string;
}
export type ActualsMap = Map<string, ActualStatus>;
export interface TimelineRenderParams {
proceedingType: string;
triggerDate: string;
flags: string[];
anchorRuleId?: string;
zoom?: boolean;
// collapsed=true renders the card as a one-line header only with a
// [zeigen] link. Used by §6.5: when an anchor is pinned + >1
// proceeding visible, non-anchored proceedings collapse so the
// anchor's full context owns the page.
collapsed?: boolean;
// actuals carries the per-rule overlay when the page is bound to an
// Akte via ?project=. Empty map / undefined = template render.
actuals?: ActualsMap;
// projectId carries through to deep-links and write-back paths.
projectId?: string;
// T4 — Verfahren-card detail mode toggle. "selected" (default) shows
// mandatory + recommended + active-flag-gated; "all_options" reveals
// conditional rules whose flag is off and unselected optionals,
// muted. Per-proceeding state lives in localStorage keyed by code.
detailMode?: "selected" | "all_options";
// T4 — appeal-target slug for proceedings with `applies_to_target`
// (upc.apl.unified). Drives the chip group at the appeal root. Empty
// = use the engine's default (endentscheidung for upc.apl).
appealTarget?: string;
// T4 — perspective for the cross-party muted treatment (§3.6). Rows
// whose party doesn't match are rendered with a "Gegenseitig" badge
// and a muted style. Empty = no perspective applied, render all
// rows at full saturation.
party?: "claimant" | "defendant" | "";
}
export interface RenderedTimeline {
card: HTMLElement;
data: DeadlineResponse | null;
// hasAnchor true iff the rendered card contains the active
// anchorRuleId. Drives the multi-proceeding auto-collapse decision
// back in procedures.ts.
hasAnchor: boolean;
}
// renderCard takes a proceeding code + flag set + trigger date and
// returns a fully-wired card element ready to mount. Re-running with a
// new flag set requires a fresh fetch (calc applies the flag set
// server-side).
export async function renderCard(params: TimelineRenderParams): Promise<RenderedTimeline> {
const card = document.createElement("article");
card.className = "tracker-proceeding";
card.dataset.proceeding = params.proceedingType;
// Header — proceeding name + jurisdiction badge + detail-mode
// toggle + collapse toggle. Detail-mode renders only on non-
// collapsed cards; collapse toggle renders always.
const def = lookupProceeding(params.proceedingType);
const jur = def ? (FORUM_LABEL[def.forum] || "") : "";
const procName = proceedingDisplayName(params.proceedingType);
const detailMode = params.detailMode || "selected";
const detailToggle = params.collapsed
? ""
: `<button type="button" class="tracker-proceeding-detail" data-action="detail-toggle"
data-code="${escHtml(params.proceedingType)}"
aria-pressed="${detailMode === "all_options" ? "true" : "false"}"
title="${escHtml(t("procedures.proceeding.detail.title"))}">
${escHtml(detailMode === "all_options" ? t("procedures.proceeding.detail.all") : t("procedures.proceeding.detail.selected"))}
</button>`;
const header = document.createElement("header");
header.className = "tracker-proceeding-header";
header.innerHTML = `
<span class="tracker-proceeding-jur">${escHtml(jur)}</span>
<h3 class="tracker-proceeding-name">${escHtml(procName)}</h3>
<span class="tracker-proceeding-code" title="${escHtml(params.proceedingType)}">${escHtml(params.proceedingType)}</span>
${detailToggle}
<button type="button" class="tracker-proceeding-toggle" data-action="proc-toggle"
data-code="${escHtml(params.proceedingType)}"
aria-label="${escHtml(t("procedures.proceeding.toggle"))}">
${escHtml(params.collapsed ? t("procedures.proceeding.show") : t("procedures.proceeding.hide"))}
</button>
`;
card.appendChild(header);
// Collapsed state — header-only render (§6.5). Bail before issuing
// the calc fetch; the card has no body. hasAnchor stays false here
// because we never resolved the data.
if (params.collapsed) {
card.classList.add("tracker-proceeding--collapsed");
return { card, data: null, hasAnchor: false };
}
// Appeal-target chip group — visible only on proceedings with
// applies_to_target rules (today: upc.apl.unified). The picked slug
// feeds the calc's appealTarget param so the timeline narrows to the
// rule subset (Endentscheidung / Kostenentscheidung / Anordnung /
// Schadensbemessung / Bucheinsicht).
if (hasAppealTarget(params.proceedingType)) {
const targetGroup = document.createElement("div");
targetGroup.className = "tracker-proceeding-targets";
const lbl = document.createElement("span");
lbl.className = "tracker-proceeding-options-label";
lbl.textContent = t("procedures.appeal_target.label");
targetGroup.appendChild(lbl);
const active = params.appealTarget || "endentscheidung";
for (const slug of APPEAL_TARGETS) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "tracker-pill tracker-pill--sm";
btn.dataset.action = "appeal-target";
btn.dataset.code = params.proceedingType;
btn.dataset.target = slug;
btn.textContent = t(`deadlines.appeal_target.${slug}` as never);
if (slug === active) btn.classList.add("is-active");
targetGroup.appendChild(btn);
}
card.appendChild(targetGroup);
}
// Optionen strip — scenario flag checkboxes scoped to this card.
// Hydrated after the calc response so the applicable flag set is
// known. T1 floor: card-level placement; T2+ may move these to
// inline on the actual gating node per the design.
const optionsStrip = document.createElement("div");
optionsStrip.className = "tracker-proceeding-options";
optionsStrip.hidden = true;
card.appendChild(optionsStrip);
// Body — chained tree mounts here once the calc returns.
const body = document.createElement("div");
body.className = "tracker-proceeding-body";
body.innerHTML = `<div class="tracker-proceeding-loading">${escHtml(t("procedures.timelines.loading"))}</div>`;
card.appendChild(body);
// Fetch + render.
const data = await calculateDeadlines({
proceedingType: params.proceedingType,
triggerDate: params.triggerDate,
flags: params.flags,
appealTarget: hasAppealTarget(params.proceedingType)
? (params.appealTarget || "endentscheidung")
: undefined,
// includeHidden=true under "all_options" so the calculator
// re-surfaces previously-skipped conditional rules with isHidden
// set; the tracker mutes them.
includeHidden: params.detailMode === "all_options" || undefined,
});
if (!data) {
body.innerHTML = `<div class="tracker-proceeding-error">${escHtml(t("procedures.timelines.error"))}</div>`;
return { card, data: null, hasAnchor: false };
}
// Hydrate the Optionen strip with the applicable scenario flags.
const applicable = applicableFlagsForProceeding(params.proceedingType, data.deadlines);
if (applicable.length > 0) {
const labelSpan = document.createElement("span");
labelSpan.className = "tracker-proceeding-options-label";
labelSpan.textContent = t("procedures.timelines.options");
optionsStrip.appendChild(labelSpan);
for (const flag of applicable) {
const label = document.createElement("label");
label.className = "tracker-proceeding-option";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.value = flag;
cb.checked = params.flags.includes(flag);
cb.dataset.flag = flag;
label.appendChild(cb);
const text = document.createElement("span");
text.textContent = labelForFlag(flag, params.proceedingType);
label.appendChild(text);
optionsStrip.appendChild(label);
}
optionsStrip.hidden = false;
}
// Filter per detail mode (§3.4).
// selected → mandatory + recommended + active-flag-gated
// all_options → everything; unselected optionals + conditional-off
// rules render muted (the renderer stamps the
// __detailUnselected flag via filterByDetailMode).
const filtered = filterByDetailMode(data.deadlines, detailMode, null);
// Anchor-present detection: does this card's rule set contain the
// active anchor rule id? Drives the multi-proceeding scope logic
// and the zoom branch below.
const hasAnchor = !!(params.anchorRuleId && filtered.some((d) => d.ruleId === params.anchorRuleId));
if (params.zoom && hasAnchor && params.anchorRuleId) {
body.innerHTML = renderZoomedBody(filtered, params.anchorRuleId, params.actuals, params.party);
} else {
body.innerHTML = renderTreeBody(filtered, params.anchorRuleId, params.actuals, params.party);
}
if (hasAnchor) card.classList.add("tracker-proceeding--anchored");
return { card, data, hasAnchor };
}
// ─── tree builder ──────────────────────────────────────────────────────────
//
// Builds a `parentRuleCode → child[]` map, then walks from each root
// (no parent) emitting nested <ul class="tracker-tree-…"> nodes. The
// calculator already sorts the deadlines into a sensible chain (root
// → linear-deepest-first); the tree builder preserves that order.
function renderTreeBody(
deadlines: CalculatedDeadline[],
anchorRuleId?: string,
actuals?: ActualsMap,
party?: "claimant" | "defendant" | "",
): string {
if (deadlines.length === 0) {
return `<div class="tracker-proceeding-empty">${escHtml(t("procedures.timelines.empty"))}</div>`;
}
// Index by code so children can find their parent visually. Track
// every code that appears in the filtered set — children whose
// parent isn't in the set surface as orphan roots.
const present = new Set(deadlines.map((d) => d.code));
const childrenOf: Record<string, CalculatedDeadline[]> = {};
const roots: CalculatedDeadline[] = [];
for (const dl of deadlines) {
const parent = dl.parentRuleCode || "";
if (parent && present.has(parent)) {
(childrenOf[parent] ||= []).push(dl);
} else {
roots.push(dl);
}
}
const parts: string[] = [`<ul class="tracker-tree tracker-tree-root">`];
for (const root of roots) {
parts.push(renderTreeNode(root, childrenOf, 0, anchorRuleId, actuals, party));
}
parts.push(`</ul>`);
return parts.join("");
}
// isCrossParty — §3.6. When perspective is set, rows whose primary_party
// is the OPPOSITE side render with a "Gegenseitig" badge and a muted
// style. court / both / informational rows are never cross-party.
function isCrossParty(dl: CalculatedDeadline, party: "claimant" | "defendant" | ""): boolean {
if (!party) return false;
if (!dl.party) return false;
if (dl.party === "court" || dl.party === "both") return false;
return dl.party !== party;
}
function renderTreeNode(
dl: CalculatedDeadline,
childrenOf: Record<string, CalculatedDeadline[]>,
depth: number,
anchorRuleId?: string,
actuals?: ActualsMap,
party?: "claimant" | "defendant" | "",
): string {
const children = childrenOf[dl.code] || [];
const isAnchored = !!(anchorRuleId && dl.ruleId === anchorRuleId);
const lang = getLang();
const crossParty = party ? isCrossParty(dl, party) : false;
// __detailUnselected is stamped by filterByDetailMode under
// all_options mode (verfahrensablauf-detail-mode.ts). Read via
// unknown-prop cast so we don't pollute the public CalculatedDeadline
// type for one transient ui hint.
const unselected = !!(dl as unknown as { __detailUnselected?: boolean }).__detailUnselected;
const isHidden = !!dl.isHidden;
// Priority-driven bullet style.
const priorityClass = `tracker-node--${dl.priority || "mandatory"}`;
const anchorClass = isAnchored ? " tracker-node--anchored" : "";
const courtClass = dl.isCourtSet ? " tracker-node--court" : "";
const crossClass = crossParty ? " tracker-node--cross" : "";
const unselectedClass = unselected ? " tracker-node--unselected" : "";
const hiddenClass = isHidden ? " tracker-node--hidden" : "";
const name = lang === "en" ? (dl.nameEN || dl.name) : (dl.name || dl.nameEN);
const ref = dl.legalSourceDisplay || dl.ruleRef || "";
// Actuals overlay (§6.4). When the page is Akte-bound and this rule
// has an actuals row, the badge replaces the priority bullet's
// status — done = ✓, overdue = ⚠, open ≠ projected = 📅, open ≡
// projected = ◇. Date column shows the actual date when present.
const actual = (actuals && dl.ruleId) ? actuals.get(dl.ruleId) : undefined;
let dateLabel = "";
let statusBadge = "";
let actualClass = "";
if (actual) {
actualClass = ` tracker-node--actual-${actual.status}`;
if (actual.status === "done") {
statusBadge = `<span class="tracker-node-actual tracker-node-actual--done" title="${escHtml(t("procedures.node.actual.done"))}">✓</span>`;
dateLabel = actual.completedAt ? formatDate(actual.completedAt) : (actual.dueDate ? formatDate(actual.dueDate) : "");
} else if (actual.status === "overdue") {
statusBadge = `<span class="tracker-node-actual tracker-node-actual--overdue" title="${escHtml(t("procedures.node.actual.overdue"))}">⚠</span>`;
dateLabel = actual.dueDate ? formatDate(actual.dueDate) : "";
} else if (actual.status === "open") {
// Open + actual due differs from projected = 📅, else ◇.
const projectedDate = dl.dueDate || "";
const actualDate = actual.dueDate || "";
const differs = projectedDate && actualDate && projectedDate !== actualDate;
statusBadge = `<span class="tracker-node-actual tracker-node-actual--open" title="${escHtml(t("procedures.node.actual.open"))}">${differs ? "📅" : "◇"}</span>`;
dateLabel = actualDate ? formatDate(actualDate) : (projectedDate ? formatDate(projectedDate) : "");
}
}
if (!dateLabel) {
dateLabel = dl.isCourtSet
? t("procedures.timelines.court_set")
: (dl.dueDate ? formatDate(dl.dueDate) : "");
}
// Party badge — one-letter affordance to the right.
const partyBadge = dl.party === "court"
? "G"
: dl.party === "claimant"
? "K"
: dl.party === "defendant"
? "B"
: dl.party === "both" ? "K+B" : "";
const ruleIdAttr = dl.ruleId ? ` data-rule-id="${escHtml(dl.ruleId)}"` : "";
const codeAttr = ` data-code="${escHtml(dl.code)}"`;
// Pin + Fokus affordances. Pin is always available on nodes with a
// real ruleId (synthetic appeal-trigger markers carry no id). Fokus
// only on the currently anchored node.
const pinBtn = dl.ruleId
? `<button type="button" class="tracker-node-pin" data-action="pin"
data-rule-id="${escHtml(dl.ruleId)}"
aria-label="${escHtml(t("procedures.node.pin"))}"
title="${escHtml(t("procedures.node.pin"))}">📌</button>`
: "";
const fokusBtn = isAnchored
? `<button type="button" class="tracker-node-fokus" data-action="fokus"
aria-label="${escHtml(t("procedures.node.fokus"))}"
title="${escHtml(t("procedures.node.fokus"))}">🔍</button>`
: "";
const crossBadge = crossParty
? `<span class="tracker-node-cross" title="${escHtml(t("procedures.node.cross"))}">${escHtml(t("procedures.node.cross.short"))}</span>`
: "";
const meta = `
<div class="tracker-node-line">
<span class="tracker-node-bullet" aria-hidden="true"></span>
${statusBadge}
<span class="tracker-node-name">${escHtml(name)}</span>
${crossBadge}
${ref ? `<span class="tracker-node-ref">${escHtml(ref)}</span>` : ""}
${dateLabel ? `<span class="tracker-node-date">${escHtml(dateLabel)}</span>` : ""}
${partyBadge ? `<span class="tracker-node-party tracker-node-party--${escHtml(dl.party || "")}">${escHtml(partyBadge)}</span>` : ""}
${pinBtn}
${fokusBtn}
</div>
${isAnchored ? `<div class="tracker-node-here" role="note">${escHtml(t("procedures.node.here"))}</div>` : ""}
`;
let inner = meta;
if (children.length > 0) {
const kids = children
.map((c) => renderTreeNode(c, childrenOf, depth + 1, anchorRuleId, actuals, party))
.join("");
inner += `<ul class="tracker-tree tracker-tree--depth-${depth + 1}">${kids}</ul>`;
}
return `<li class="tracker-node ${priorityClass}${anchorClass}${courtClass}${crossClass}${unselectedClass}${hiddenClass}${actualClass}"${ruleIdAttr}${codeAttr}>${inner}</li>`;
}
// ─── zoom mode (§6.2) ──────────────────────────────────────────────────────
//
// When the user clicks [🔍] on the anchored node, the proceeding card
// re-renders into zoom mode:
// - Ancestors of the anchor collapse to a single breadcrumb at the
// top of the card (proceeding-code ▸ root ▸ … ▸ anchor).
// - Sibling branches at each ancestor depth fold to a one-line
// "… N weitere verborgen — [zeigen]" summary.
// - The anchored node renders full with all its successors.
//
// Sibling-expand-on-demand: when the user clicks [zeigen] on a fold
// summary, the corresponding sibling subtree expands inline. State is
// per-card in sessionStorage so a reload keeps it.
function renderZoomedBody(
deadlines: CalculatedDeadline[],
anchorRuleId: string,
actuals?: ActualsMap,
party?: "claimant" | "defendant" | "",
): string {
const anchor = deadlines.find((d) => d.ruleId === anchorRuleId);
if (!anchor) {
// The anchor is no longer in the filtered set (e.g. the user
// toggled a flag that hid it). Fall back to the full tree so the
// user can re-pin.
return renderTreeBody(deadlines, anchorRuleId, actuals, party);
}
// Build the parent chain (anchor → root). The chain is walked via
// parentRuleCode; bail at the first missing parent or at >20 hops
// (defensive — the deepest tree today is ~5).
const byCode: Record<string, CalculatedDeadline> = {};
for (const dl of deadlines) byCode[dl.code] = dl;
const ancestors: CalculatedDeadline[] = [];
let cursor: CalculatedDeadline | undefined = anchor;
let safety = 20;
while (cursor && safety-- > 0) {
const parentCode = cursor.parentRuleCode || "";
if (!parentCode) break;
const parent = byCode[parentCode];
if (!parent) break;
ancestors.unshift(parent);
cursor = parent;
}
const lang = getLang();
const breadcrumbParts: string[] = [];
for (const a of ancestors) {
const name = lang === "en" ? (a.nameEN || a.name) : (a.name || a.nameEN);
breadcrumbParts.push(`<span class="tracker-zoom-crumb">${escHtml(name)}</span>`);
}
const anchorName = lang === "en" ? (anchor.nameEN || anchor.name) : (anchor.name || anchor.nameEN);
breadcrumbParts.push(`<span class="tracker-zoom-crumb tracker-zoom-crumb--anchor">${escHtml(anchorName)}</span>`);
const breadcrumb = `<nav class="tracker-zoom-breadcrumb" aria-label="${escHtml(t("procedures.zoom.breadcrumb"))}">
${breadcrumbParts.join('<span class="tracker-zoom-crumb-sep">▸</span>')}
</nav>`;
// Subtree: anchor + all descendants (parentRuleCode chain rooted
// at the anchor).
const descendants = new Set<string>([anchor.code]);
const queue = [anchor.code];
while (queue.length > 0) {
const code = queue.shift()!;
for (const dl of deadlines) {
if (dl.parentRuleCode === code && !descendants.has(dl.code)) {
descendants.add(dl.code);
queue.push(dl.code);
}
}
}
const subtree = deadlines.filter((d) => descendants.has(d.code));
const subtreeBody = renderTreeBody(subtree, anchorRuleId, actuals, party);
// Sibling count summary — descendants ignored. Stays terse so the
// page tells the user how much is hidden without listing it.
const totalCount = deadlines.length;
const hiddenCount = totalCount - subtree.length;
const hiddenLine = hiddenCount > 0
? `<div class="tracker-zoom-hidden">${escHtml(tDyn("procedures.zoom.hidden").replace("{n}", String(hiddenCount)))}</div>`
: "";
return breadcrumb + subtreeBody + hiddenLine;
}
// ─── search hit highlight (URL `?event=`) ──────────────────────────────────
//
// T1 affordance: when `?event=<rule_id>` is set, scroll the matching
// node into view and apply a transient highlight. Anchor pin + zoom is
// a T2 layering on top of this.
export function scrollAnchorIntoView(card: HTMLElement, anchorRuleId: string): void {
const node = card.querySelector<HTMLElement>(`[data-rule-id="${CSS.escape(anchorRuleId)}"]`);
if (!node) return;
node.classList.add("tracker-node--highlight");
node.scrollIntoView({ behavior: "smooth", block: "center" });
setTimeout(() => node.classList.remove("tracker-node--highlight"), 3000);
}
// summarise — short status line for the find-header summary.
export function summariseRender(rendered: RenderedTimeline[]): string {
const proc = rendered.length;
if (proc === 0) return t("procedures.find.summary.empty");
if (proc === 1) return tDyn("procedures.find.summary.one").replace("{n}", "1");
return tDyn("procedures.find.summary.many").replace("{n}", String(proc));
}

View File

@@ -1,756 +1,150 @@
// /tools/procedures client (m/paliad#152 T1,
// docs/design-procedures-workflow-tracker-2026-05-27.md §9 T1).
// /tools/procedures client (m/paliad#151,
// docs/design-unified-procedural-events-tool-2026-05-27.md).
//
// Workflow-tracker shell — replaces the 4-tab catalog (U0-U4) shipped
// earlier today with a single canonical shape:
// Boot logic + tab switching for the unified procedural-events tool.
// Each entry tab mounts its own module; the search box and chip
// filters in the top filter strip are wired in U1+ as each slice adds
// its dimension-aware behaviour.
//
// 1. Sticky find header — search input + forum / Verfahren / Partei
// pill rows + global Stichtag. The header narrows the timeline
// set rendered below.
// 2. Timeline body — one card per matched proceeding, rendered as
// a chained tree by parent_id with priority-styled bullets.
// U0 — Skeleton + tab toggling.
// U1 — Direkt suchen mounts Mode A.
// U2 — Geführt mounts Mode B wizard.
// U3 — Verfahren wählen wires the Verfahrensablauf wizard + detail-mode toggle.
//
// URL state (T1):
// ?q=<text> — free-text search
// ?forum=<id> — single forum (upc/de/epa/dpma)
// ?procs=<csv> — comma-separated proceeding codes
// ?party=<x> — claimant/defendant/both/""
// ?trigger_date=<iso> — global Stichtag
// ?event=<rule_id> — scroll-highlight matching node (no anchor
// pin / zoom yet; T2 layering)
// ?flags=<csv> — scenario flag overrides; default off
//
// Legacy ?mode= params from the catalog UI are dropped silently — the
// /tools/fristenrechner + /tools/verfahrensablauf URLs still 301
// redirect here.
//
// T2-T4 layer on this shell:
// T2 — anchor pin + zoom + multi-proceeding scope.
// T3 — Akte landing + actuals overlay.
// T4 — appeal-target + court-set choices + per-proceeding Alle Optionen.
// Mode A renders its shell into #fristen-overhaul-root (replacing
// children); Mode B renders into #fristen-overhaul-mode-host; the
// result view (post-commit) writes into #fristen-overhaul-root. To
// keep those IDs unique in the DOM, only the active tab's panel ever
// hosts the overhaul scaffold — installOverhaulHost() tears down any
// existing host and installs a fresh one inside the target panel
// before handing off to the per-mode module.
import { initI18n, t, tDyn } from "./i18n";
import { initI18n } from "./i18n";
import { initSidebar } from "./sidebar";
import {
COLD_OPEN_DEFAULTS,
PROCEEDINGS,
type ActualStatus,
type ActualsMap,
type RenderedTimeline,
proceedingDisplayName,
renderCard,
scrollAnchorIntoView,
summariseRender,
} from "./procedures-tracker";
import {
fetchScenarioFlags,
patchScenarioFlags,
SCENARIO_FLAG_CHANGED_EVENT,
type ScenarioFlagChangedDetail,
} from "./scenario-flags";
import { readDetailMode, writeDetailMode } from "./procedures-tracker";
import { mountModeA } from "./fristenrechner-mode-a";
import { mountResultView } from "./fristenrechner-result";
import { mountWizard } from "./fristenrechner-wizard";
import { initVerfahrensablauf } from "./verfahrensablauf";
// Per-proceeding appeal-target state. Today only upc.apl.unified has
// applies_to_target rules; the map is keyed by proceeding_type code
// so future appeal-style proceedings (de.apl, etc.) can opt in without
// touching the state shape.
const appealTargets: Record<string, string> = {};
type ProceduresTab = "proceeding" | "search" | "wizard" | "akte";
type ForumId = "upc" | "de" | "epa" | "dpma" | "";
type PartyId = "claimant" | "defendant" | "both" | "";
const TABS: ProceduresTab[] = ["proceeding", "search", "wizard", "akte"];
// Find state. Single source of truth; URL keeps it shareable.
const state = {
q: "",
forum: "" as ForumId,
procs: [] as string[],
party: "" as PartyId,
triggerDate: todayISO(),
event: "",
zoom: false,
flags: [] as string[],
// T3 Akte state — loaded on demand from /api/projects/{id}/timeline
// when ?project= is set in the URL. Kept off the URL writer so a
// shared link without ?project= doesn't accidentally leak.
projectId: "",
projectTitle: "",
projectProceeding: "",
actuals: new Map() as ActualsMap,
akteLoaded: false,
};
// Per-anchor user-expanded set — when the multi-proceeding auto-collapse
// kicks in (§6.5), the user can [zeigen] specific proceedings. We track
// the explicit expansions so re-renders keep them open. Reset whenever
// the anchor changes (new pin clears prior expansion state).
let userExpanded = new Set<string>();
let lastAnchor = "";
function todayISO(): string {
return new Date().toISOString().split("T")[0];
}
// ─── URL state ─────────────────────────────────────────────────────────────
function readStateFromURL(): void {
function readTabFromUrl(): ProceduresTab {
const params = new URLSearchParams(window.location.search);
state.q = params.get("q") || "";
state.forum = (params.get("forum") || "") as ForumId;
const procs = params.get("procs") || "";
state.procs = procs ? procs.split(",").map((s) => s.trim()).filter(Boolean) : [];
state.party = (params.get("party") || "") as PartyId;
state.triggerDate = params.get("trigger_date") || todayISO();
state.event = params.get("event") || "";
state.zoom = params.get("zoom") === "1";
const flags = params.get("flags") || "";
state.flags = flags ? flags.split(",").map((s) => s.trim()).filter(Boolean) : [];
state.projectId = params.get("project") || "";
lastAnchor = state.event;
const raw = params.get("mode");
if (raw && (TABS as string[]).includes(raw)) return raw as ProceduresTab;
return "proceeding";
}
function writeStateToURL(): void {
function writeTabToUrl(tab: ProceduresTab): void {
const url = new URL(window.location.href);
const sp = url.searchParams;
setOrDelete(sp, "q", state.q);
setOrDelete(sp, "forum", state.forum);
setOrDelete(sp, "procs", state.procs.join(","));
setOrDelete(sp, "party", state.party);
setOrDelete(sp, "trigger_date", state.triggerDate === todayISO() ? "" : state.triggerDate);
setOrDelete(sp, "event", state.event);
setOrDelete(sp, "zoom", state.event && state.zoom ? "1" : "");
setOrDelete(sp, "flags", state.flags.join(","));
setOrDelete(sp, "project", state.projectId);
// Legacy ?mode= from the U0-U4 catalog era → drop on every state write
// so a bookmarked URL self-cleans on first interaction.
sp.delete("mode");
history.replaceState(null, "", url.pathname + (sp.toString() ? "?" + sp.toString() : "") + url.hash);
if (tab === "proceeding") {
url.searchParams.delete("mode");
} else {
url.searchParams.set("mode", tab);
}
history.replaceState(null, "", url.pathname + url.search + url.hash);
}
// onAnchorChanged keeps userExpanded in sync. A fresh pin clears the
// prior expansion set so the auto-collapse rule (§6.5) kicks in from
// scratch; unpinning clears it too so the full multi-proceeding view
// returns.
function onAnchorChanged(next: string): void {
if (next === lastAnchor) return;
userExpanded = new Set();
if (!next) state.zoom = false;
lastAnchor = next;
// installOverhaulHost moves the (legacy) #fristen-overhaul-root /
// #fristen-overhaul-mode-host scaffold under `panelId`. Always clears
// any existing host first, so the IDs stay unique across the page even
// when the user toggles between Direkt-suchen and Geführt — both Mode
// A and the wizard read these IDs from document.getElementById which
// returns the first match in DOM order, so two parallel hosts would
// cross-wire.
function installOverhaulHost(panelId: string): HTMLElement | null {
document.querySelectorAll("#fristen-overhaul-root").forEach((el) => el.remove());
const panel = document.getElementById(panelId);
if (!panel) return null;
panel.innerHTML = `
<div class="procedures-overhaul-host">
<div class="fristen-overhaul-root" id="fristen-overhaul-root">
<div id="fristen-overhaul-mode-host"></div>
</div>
</div>
`;
return panel;
}
function setOrDelete(sp: URLSearchParams, key: string, value: string): void {
if (value) sp.set(key, value);
else sp.delete(key);
}
// ─── pill hydration ────────────────────────────────────────────────────────
function hydrateForumPills(): void {
const host = document.getElementById("tracker-pills-forum");
if (!host) return;
host.innerHTML = "";
const all: { id: ForumId; label: string }[] = [
{ id: "", label: t("procedures.filter.forum.all") },
{ id: "upc", label: "UPC" },
{ id: "de", label: "DE" },
{ id: "epa", label: "EPA" },
{ id: "dpma", label: "DPMA" },
];
for (const f of all) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "tracker-pill";
btn.textContent = f.label;
btn.dataset.forum = f.id;
if (state.forum === f.id) btn.classList.add("is-active");
btn.addEventListener("click", () => {
state.forum = f.id;
// Drop procs that no longer match the active forum.
if (state.forum) {
state.procs = state.procs.filter((code) => {
const def = PROCEEDINGS.find((p) => p.code === code);
return def && def.forum === state.forum;
});
}
writeStateToURL();
hydrateForumPills();
hydrateProcPills();
void rerender();
});
host.appendChild(btn);
function setActiveTabUI(tab: ProceduresTab): void {
for (const t of TABS) {
const btn = document.getElementById(`procedures-tab-${t}`);
const panel = document.getElementById(`procedures-panel-${t}`);
const active = t === tab;
if (btn) {
btn.classList.toggle("is-active", active);
btn.setAttribute("aria-selected", active ? "true" : "false");
}
if (panel) panel.hidden = !active;
}
}
function hydrateProcPills(): void {
const host = document.getElementById("tracker-pills-proc");
if (!host) return;
host.innerHTML = "";
const visible = state.forum
? PROCEEDINGS.filter((p) => p.forum === state.forum)
: PROCEEDINGS;
for (const p of visible) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "tracker-pill";
btn.textContent = proceedingDisplayName(p.code);
btn.dataset.code = p.code;
btn.title = p.code;
if (state.procs.includes(p.code)) btn.classList.add("is-active");
btn.addEventListener("click", () => {
if (state.procs.includes(p.code)) {
state.procs = state.procs.filter((c) => c !== p.code);
} else {
state.procs = [...state.procs, p.code];
}
writeStateToURL();
hydrateProcPills();
void rerender();
});
host.appendChild(btn);
// Verfahrensablauf wiring is idempotent-unfriendly (module-local
// selectedType + lastResponse + listeners that re-bind on every
// proceeding click). Wire it exactly once per page load; on subsequent
// activations the existing DOM + listeners are reused so picked
// proceeding / dates / flags persist across tab switches.
let verfahrensablaufWired = false;
async function activateTab(tab: ProceduresTab): Promise<void> {
setActiveTabUI(tab);
if (tab === "search") {
installOverhaulHost("procedures-panel-search");
await mountModeA();
return;
}
if (tab === "wizard") {
installOverhaulHost("procedures-panel-wizard");
await mountWizard();
return;
}
if (tab === "proceeding") {
if (!verfahrensablaufWired) {
initVerfahrensablauf();
verfahrensablaufWired = true;
}
}
}
function hydratePartyPills(): void {
const host = document.getElementById("tracker-pills-party");
if (!host) return;
host.innerHTML = "";
const all: { id: PartyId; key: string }[] = [
{ id: "", key: "procedures.filter.party.all" },
{ id: "claimant", key: "deadlines.side.claimant" },
{ id: "defendant", key: "deadlines.side.defendant" },
];
for (const p of all) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "tracker-pill";
btn.textContent = t(p.key as never);
btn.dataset.party = p.id;
if (state.party === p.id) btn.classList.add("is-active");
function wireTabs(): void {
for (const t of TABS) {
const btn = document.getElementById(`procedures-tab-${t}`);
if (!btn) continue;
btn.addEventListener("click", () => {
state.party = p.id;
writeStateToURL();
hydratePartyPills();
void rerender();
void activateTab(t);
writeTabToUrl(t);
});
host.appendChild(btn);
}
}
// ─── search box ────────────────────────────────────────────────────────────
//
// Debounced 200ms. Free-text matches procedural events via the existing
// /api/tools/fristenrechner/search?kind=events endpoint; the hits drive
// proceeding pre-selection (the proceedings the hits live in surface
// in the timeline body) and the first hit's rule_id becomes the
// `?event=` anchor.
// boot dispatches on the URL: a deep link with `?event=` jumps straight
// to the linear result view (the Direkt-suchen tab stays as the visible
// context). Otherwise the requested tab — defaulting to "proceeding" —
// activates per readTabFromUrl().
async function boot(): Promise<void> {
const params = new URLSearchParams(window.location.search);
const eventRef = params.get("event") || "";
let searchTimer: ReturnType<typeof setTimeout> | null = null;
function wireSearchInput(): void {
const input = document.getElementById("tracker-search-input") as HTMLInputElement | null;
if (!input) return;
input.value = state.q;
input.addEventListener("input", () => {
if (searchTimer !== null) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
searchTimer = null;
void onSearchChanged(input.value.trim());
}, 200);
});
}
async function onSearchChanged(q: string): Promise<void> {
state.q = q;
// Empty query → revert to pill-driven set (or cold-open default).
if (!q) {
state.event = "";
writeStateToURL();
void rerender();
if (eventRef) {
setActiveTabUI("search");
installOverhaulHost("procedures-panel-search");
await mountResultView({
eventRef,
triggerDate: params.get("trigger_date") || undefined,
party: params.get("party") || undefined,
courtId: params.get("court_id") || undefined,
});
return;
}
try {
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
url.searchParams.set("q", q);
url.searchParams.set("kind", "events");
url.searchParams.set("limit", "20");
if (state.forum) url.searchParams.set("jurisdiction", state.forum.toUpperCase());
if (state.party) url.searchParams.set("party", state.party);
const resp = await fetch(url.pathname + url.search, { headers: { Accept: "application/json" } });
if (!resp.ok) {
writeStateToURL();
void rerender();
return;
}
const body = await resp.json();
const events = Array.isArray(body.events) ? body.events : [];
// Collect distinct proceeding_type codes from the hits and pre-seed
// state.procs. If exactly one hit, scroll-highlight its anchor rule.
const procs: string[] = [];
for (const ev of events) {
const code = ev?.proceeding_type?.code;
if (typeof code === "string" && code && !procs.includes(code)) procs.push(code);
}
state.procs = procs;
const nextEvent = events.length === 1 && events[0]?.anchor_rule_id
? String(events[0].anchor_rule_id)
: "";
onAnchorChanged(nextEvent);
state.event = nextEvent;
writeStateToURL();
hydrateProcPills();
void rerender();
} catch (e) {
console.error("tracker search failed", e);
writeStateToURL();
void rerender();
}
await activateTab(readTabFromUrl());
}
// ─── trigger date input ────────────────────────────────────────────────────
function wireTriggerDateInput(): void {
const input = document.getElementById("tracker-trigger-date") as HTMLInputElement | null;
if (!input) return;
input.value = state.triggerDate;
input.addEventListener("change", () => {
const next = input.value || todayISO();
if (next === state.triggerDate) return;
state.triggerDate = next;
writeStateToURL();
void rerender();
});
}
// ─── flag toggle wiring ────────────────────────────────────────────────────
//
// Forks on the per-proceeding "Optionen" strip dispatch via event
// delegation so re-rendering doesn't need to re-bind. Each tick mutates
// state.flags and triggers a re-render of the specific card.
function wireFlagDelegation(): void {
const host = document.getElementById("tracker-timelines");
if (!host) return;
host.addEventListener("change", (ev) => {
const target = ev.target as HTMLInputElement;
if (!target || target.tagName !== "INPUT") return;
if (target.type !== "checkbox") return;
const flagKey = target.dataset.flag || "";
if (!flagKey) return;
if (target.checked) {
if (!state.flags.includes(flagKey)) state.flags = [...state.flags, flagKey];
} else {
state.flags = state.flags.filter((f) => f !== flagKey);
}
writeStateToURL();
// T3: when bound to a project, persist the flag delta via
// patchScenarioFlags so a reload (or another surface — Mode B
// Fristenrechner / Verlauf) sees the same scenario. Fire-and-
// forget; the cross-surface re-sync fires a CustomEvent that
// doesn't reach back here today (the tracker has no listener),
// but the persistence side-effect is what matters.
if (state.projectId) {
void patchScenarioFlags(state.projectId, { [flagKey]: target.checked });
}
void rerender();
});
}
// wireClickDelegation handles pin / fokus / proc-toggle. Single listener
// on the timelines host; the render functions stamp data-action on the
// affordances they emit.
function wireClickDelegation(): void {
const host = document.getElementById("tracker-timelines");
if (!host) return;
host.addEventListener("click", (ev) => {
const btn = (ev.target as HTMLElement | null)?.closest<HTMLButtonElement>("button[data-action]");
if (!btn) return;
const action = btn.dataset.action || "";
ev.preventDefault();
if (action === "pin") {
const ruleId = btn.dataset.ruleId || "";
if (!ruleId) return;
// Click on the already-anchored node un-pins. Toggle pattern.
const next = state.event === ruleId ? "" : ruleId;
onAnchorChanged(next);
state.event = next;
writeStateToURL();
void rerender();
return;
}
if (action === "fokus") {
// Toggle zoom on the current anchor.
if (!state.event) return;
state.zoom = !state.zoom;
writeStateToURL();
void rerender();
return;
}
if (action === "proc-toggle") {
const code = btn.dataset.code || "";
if (!code) return;
if (userExpanded.has(code)) userExpanded.delete(code);
else userExpanded.add(code);
void rerender();
return;
}
if (action === "detail-toggle") {
// Per-proceeding "Alle Optionen" ↔ "Gewählt" toggle (§3.4).
const code = btn.dataset.code || "";
if (!code) return;
const next = readDetailMode(code) === "all_options" ? "selected" : "all_options";
writeDetailMode(code, next);
void rerender();
return;
}
if (action === "appeal-target") {
const code = btn.dataset.code || "";
const target = btn.dataset.target || "";
if (!code || !target) return;
appealTargets[code] = target;
void rerender();
return;
}
});
}
// ─── render driver ─────────────────────────────────────────────────────────
let currentRender = 0;
function pickProceedingsToRender(): string[] {
if (state.procs.length > 0) return state.procs;
if (state.forum) {
return PROCEEDINGS.filter((p) => p.forum === state.forum).map((p) => p.code);
}
return COLD_OPEN_DEFAULTS;
}
async function rerender(): Promise<void> {
const seq = ++currentRender;
const host = document.getElementById("tracker-timelines");
const summary = document.getElementById("tracker-find-summary");
if (!host) return;
// Loading placeholder during render. The placeholder is replaced
// atomically once all cards return, so the user doesn't see
// intermediate flicker between cards.
host.innerHTML = `<div class="tracker-timelines-placeholder">${t("procedures.timelines.loading")}</div>`;
const codes = pickProceedingsToRender();
// Cold-open hint: if we're showing the curated default set, surface
// a small instruction so the user knows the page expects further
// narrowing for non-default proceedings.
const isColdOpen = state.procs.length === 0 && !state.forum && !state.q;
const hasAnchor = !!state.event;
const multiProceeding = codes.length > 1;
// Multi-proceeding anchor scope (§6.5). When an anchor is pinned and
// multiple proceedings are visible, non-anchored proceedings render
// as a one-line header card with a [zeigen] link. We don't know yet
// which card carries the anchor — that's a property of the calc
// response. Two-pass render: first pass resolves anchor location;
// second pass renders collapsed/full per code. Cheap because the
// collapsed render skips the calc fetch entirely.
//
// Optimisation path: probe just one card per render to find the
// anchor's home. Probe the matching code first when we already know
// it (cached on `lastAnchorProceeding` below).
// First-pass: render every card, collect which ones carry the anchor.
const firstPass: RenderedTimeline[] = await Promise.all(
codes.map((code) =>
renderCard({
proceedingType: code,
triggerDate: state.triggerDate,
flags: state.flags,
anchorRuleId: state.event || undefined,
zoom: state.zoom,
actuals: state.akteLoaded ? state.actuals : undefined,
projectId: state.projectId || undefined,
detailMode: readDetailMode(code),
appealTarget: appealTargets[code] || undefined,
party: state.party || "",
}),
),
);
if (seq !== currentRender) return; // a newer render started; bail.
// If we have an anchor + multi-proceeding, decide which cards
// collapse. Cards with hasAnchor=true stay expanded; others collapse
// unless the user explicitly expanded them via [zeigen].
let rendered: RenderedTimeline[] = firstPass;
if (hasAnchor && multiProceeding) {
rendered = await Promise.all(
firstPass.map(async (r) => {
if (r.hasAnchor) return r;
if (userExpanded.has(r.card.dataset.proceeding || "")) return r;
// Re-render collapsed.
return renderCard({
proceedingType: r.card.dataset.proceeding || "",
triggerDate: state.triggerDate,
flags: state.flags,
anchorRuleId: state.event || undefined,
collapsed: true,
actuals: state.akteLoaded ? state.actuals : undefined,
projectId: state.projectId || undefined,
detailMode: readDetailMode(r.card.dataset.proceeding || ""),
appealTarget: appealTargets[r.card.dataset.proceeding || ""] || undefined,
party: state.party || "",
});
}),
);
if (seq !== currentRender) return;
}
host.innerHTML = "";
if (isColdOpen) {
const hint = document.createElement("div");
hint.className = "tracker-cold-open-hint";
hint.textContent = t("procedures.cold_open.hint");
host.appendChild(hint);
}
for (const r of rendered) host.appendChild(r.card);
if (rendered.length === 0) {
const empty = document.createElement("div");
empty.className = "tracker-timelines-empty";
empty.textContent = t("procedures.timelines.empty");
host.appendChild(empty);
}
// Find-header summary line. When an anchor is pinned, surface the
// anchor's name so the user has a visual confirmation of where
// they are.
if (summary) {
const parts: string[] = [summariseRender(rendered)];
if (state.akteLoaded && state.projectTitle) {
parts.push(tDyn("procedures.find.summary.akte").replace("{name}", state.projectTitle));
}
if (hasAnchor) {
const anchorName = findAnchorName(firstPass, state.event);
if (anchorName) {
parts.push(tDyn("procedures.find.summary.anchor").replace("{name}", anchorName));
}
}
summary.textContent = parts.join(" · ");
}
// Scroll-highlight the anchored node, if any. Walks every card so a
// ?event= deep link works even when the same rule appears in a
// shared-chain proceeding (e.g. inf.cfi and ccr.cfi).
if (state.event) {
for (const r of rendered) scrollAnchorIntoView(r.card, state.event);
}
}
// findAnchorName resolves the anchored rule's display name across the
// rendered cards. Returns "" when the anchor isn't in any visible
// proceeding (e.g. invalid ?event= deep link).
function findAnchorName(rendered: RenderedTimeline[], ruleId: string): string {
if (!ruleId) return "";
for (const r of rendered) {
if (!r.data) continue;
const hit = r.data.deadlines.find((d) => d.ruleId === ruleId);
if (!hit) continue;
return hit.name || hit.nameEN || ruleId;
}
return "";
}
// ─── Akte landing (§6.4) ───────────────────────────────────────────────────
//
// When ?project=<uuid> is in the URL, we load:
// 1. /api/projects/{id} — title + proceeding_type for header context
// 2. /api/projects/{id}/timeline — actuals (deadlines + appointments)
// 3. /api/projects/{id}/scenario-flags — seeds state.flags + provides
// write-back path
//
// The actuals overlay maps deadline_rule_id → ActualStatus. The
// fristenrechner calc returns ruleId on each TimelineEntry; the
// tracker stamps the matching badge on each node.
//
// On first load, the anchor auto-pins to the latest status='done'
// deadline (design Q5). Subsequent renders preserve the user's pin.
async function loadAkte(projectId: string): Promise<void> {
if (!projectId) {
state.actuals = new Map();
state.akteLoaded = false;
state.projectTitle = "";
state.projectProceeding = "";
return;
}
// 1. Project header / proceeding_type.
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}`, {
headers: { Accept: "application/json" },
});
if (resp.ok) {
const proj = await resp.json();
state.projectTitle = String(proj?.title || proj?.name || "");
const procCode = String(proj?.proceeding_type?.code || proj?.proceeding_type_code || "");
state.projectProceeding = procCode;
if (procCode && !state.procs.includes(procCode)) {
state.procs = [procCode];
}
}
} catch (e) {
console.warn("project fetch failed", e);
}
// 2. Timeline → actuals map.
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/timeline`, {
headers: { Accept: "application/json" },
});
if (resp.ok) {
const body = await resp.json();
const events = Array.isArray(body?.events) ? body.events : Array.isArray(body) ? body : [];
state.actuals = buildActualsMap(events);
// Auto-pin anchor: latest status='done' (most recent completed
// deadline). Only when no anchor is set yet — preserve URL
// ?event= for shared links.
if (!state.event) {
const anchor = pickLatestDoneAnchor(events);
if (anchor) {
state.event = anchor;
lastAnchor = anchor;
}
}
}
} catch (e) {
console.warn("project timeline fetch failed", e);
}
// 3. Scenario flags — seed state.flags + future write-back.
try {
const view = await fetchScenarioFlags(projectId);
if (view && view.flags) {
const onFlags: string[] = [];
for (const [k, v] of Object.entries(view.flags)) {
// Filter to top-level scenario flags (not per-rule deviations).
// Per-rule flags are keyed `rule:<uuid>` and not consumed by
// the calc's flags[] payload.
if (k.startsWith("rule:")) continue;
if (v === true) onFlags.push(k);
}
state.flags = onFlags;
}
} catch (e) {
console.warn("scenario-flags fetch failed", e);
}
state.akteLoaded = true;
}
function buildActualsMap(events: unknown[]): ActualsMap {
const map: ActualsMap = new Map();
for (const ev of events) {
const e = ev as Record<string, unknown> | null;
if (!e) continue;
const ruleId = String(e.deadline_rule_id || "");
if (!ruleId) continue;
const kind = String(e.kind || "");
const status = String(e.status || "");
const date = typeof e.date === "string" ? e.date.split("T")[0] : "";
// Map SmartTimeline statuses to ActualStatus.status.
let mapped: ActualStatus["status"];
if (status === "done" || kind === "appointment") {
mapped = "done";
} else if (status === "overdue") {
mapped = "overdue";
} else if (status === "court_set") {
mapped = "court_set";
} else if (status === "open") {
mapped = "open";
} else {
// projected / predicted / off_script — don't overlay.
continue;
}
const entry: ActualStatus = { status: mapped };
if (mapped === "done") entry.completedAt = date || undefined;
else entry.dueDate = date || undefined;
if (typeof e.deadline_id === "string") entry.deadlineId = e.deadline_id;
if (typeof e.appointment_id === "string") entry.appointmentId = e.appointment_id;
map.set(ruleId, entry);
}
return map;
}
function pickLatestDoneAnchor(events: unknown[]): string {
let latest = "";
let latestDate = "";
for (const ev of events) {
const e = ev as Record<string, unknown> | null;
if (!e) continue;
if (e.status !== "done") continue;
const ruleId = String(e.deadline_rule_id || "");
if (!ruleId) continue;
const date = typeof e.date === "string" ? e.date : "";
if (!latest || date > latestDate) {
latest = ruleId;
latestDate = date;
}
}
return latest;
}
// ─── boot ──────────────────────────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
readStateFromURL();
hydrateForumPills();
hydrateProcPills();
hydratePartyPills();
wireSearchInput();
wireTriggerDateInput();
wireFlagDelegation();
wireClickDelegation();
// T3: when bound to an Akte via ?project=, load actuals + scenario
// flags + auto-pin to latest done deadline BEFORE the first render.
// Otherwise the first render fires on template data and re-renders
// once the Akte resolves — visible flicker on a slow connection.
if (state.projectId) {
void loadAkte(state.projectId).then(() => {
hydrateProcPills();
void rerender();
});
} else {
void rerender();
}
// T4: cross-surface scenario-flag re-sync. When another surface
// (Mode B Fristenrechner, Verfahrensablauf, /admin) PATCHes the
// same project's flags, scenario-flags.ts dispatches this event.
// We re-seed state.flags from the detail payload and re-render so
// the tracker stays coherent without a fresh GET.
document.addEventListener(SCENARIO_FLAG_CHANGED_EVENT, (ev) => {
const detail = (ev as CustomEvent<ScenarioFlagChangedDetail>).detail;
if (!detail || !state.projectId) return;
if (detail.projectId !== state.projectId) return;
const onFlags: string[] = [];
for (const [k, v] of Object.entries(detail.flags)) {
if (k.startsWith("rule:")) continue;
if (v === true) onFlags.push(k);
}
state.flags = onFlags;
void rerender();
});
wireTabs();
void boot();
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,320 @@
// Per-event-card choice popover + chip indicator (t-paliad-265 /
// m/paliad#96).
//
// The shared rendering core (verfahrensablauf-core.ts) emits a caret
// button on cards that carry a non-empty `choices_offered` declaration
// and an inert chip span next to the title. This module:
//
// 1. Wires a delegated click handler on the result container so the
// caret opens a popover with the offered choice-kinds.
// 2. Commits the user's pick — either by POSTing to the project-
// bound endpoint or by mutating the in-memory state for the
// unbound (no-project) case.
// 3. Rehydrates the chip on every render + after every commit so the
// glanceable indicator matches the active state.
//
// Two consumer pages — /tools/verfahrensablauf (unbound) and
// /tools/fristenrechner (project-bound) — both wire this module
// once at boot via attachEventCardChoices().
import { escAttr, escHtml } from "./verfahrensablauf-core";
import { t } from "../i18n";
export type ChoiceKind = "appellant" | "include_ccr" | "skip";
export interface EventChoice {
submission_code: string;
choice_kind: ChoiceKind;
choice_value: string;
}
// State surface — the page passes in callbacks that own persistence.
// commit / remove must trigger a recalc on the page side (the popover
// only owns its own visual state).
export interface EventCardChoicesOpts {
container: HTMLElement;
// Initial state: a list of choices. The page seeds this from the
// server response (project-bound) or from URL params (unbound).
initial: EventChoice[];
// commit gets called for an UPSERT. The page POSTs to the API (or
// mutates URL state) AND triggers a recalc.
commit: (choice: EventChoice) => Promise<void> | void;
// remove gets called when the user resets a choice.
remove: (submissionCode: string, kind: ChoiceKind) => Promise<void> | void;
}
// One mutable bag per attach() call. The current implementation is a
// single-page singleton — paginated views (admin tables) are not in
// scope. Last-write-wins on the in-memory state.
interface AttachedState {
opts: EventCardChoicesOpts;
// active: submission_code → kind → value. Rebuilt from `initial`
// on every reseed() call.
active: Map<string, Map<ChoiceKind, string>>;
popover: HTMLDivElement | null;
}
const states = new WeakMap<HTMLElement, AttachedState>();
// attachEventCardChoices wires the delegated click + popover lifecycle
// to the given container. Call once per page after mount; safe to call
// again with a fresh container.
export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
const state: AttachedState = {
opts,
active: new Map(),
popover: null,
};
for (const c of opts.initial) {
if (!state.active.has(c.submission_code)) {
state.active.set(c.submission_code, new Map());
}
state.active.get(c.submission_code)!.set(c.choice_kind, c.choice_value);
}
states.set(opts.container, state);
opts.container.addEventListener("click", (e) => {
const targetEl = e.target as HTMLElement | null;
const caret = targetEl?.closest<HTMLElement>(".event-card-choices-caret");
if (caret) {
e.stopPropagation();
openPopover(state, caret);
return;
}
// Outside-click closes the popover.
if (state.popover && !state.popover.contains(e.target as Node)) {
closePopover(state);
}
});
// ESC also closes.
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && state.popover) {
closePopover(state);
}
});
// Repaint chips on every renderResults() call. The page is
// responsible for calling reseedChips() after re-render so the chip
// dom node (re-created by the renderer) picks the active state up.
reseedChips(opts.container);
}
// reseedChips walks every chip span in the container and re-renders
// its content from the active state map. Idempotent.
export function reseedChips(container: HTMLElement): void {
const state = states.get(container);
if (!state) return;
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
const code = chip.dataset.submissionCode || "";
const kinds = state.active.get(code);
if (!kinds || kinds.size === 0) {
chip.innerHTML = "";
chip.dataset.empty = "true";
return;
}
chip.dataset.empty = "false";
chip.innerHTML = renderChip(kinds);
});
// Skipped rows fade out via a class on the card-item ancestor.
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
const code = chip.dataset.submissionCode || "";
const skipped = state.active.get(code)?.get("skip") === "true";
const itemEl = chip.closest<HTMLElement>(".timeline-item, .fr-col-item");
if (itemEl) itemEl.classList.toggle("timeline-item--skipped", skipped);
});
}
function renderChip(kinds: Map<ChoiceKind, string>): string {
const parts: string[] = [];
if (kinds.get("skip") === "true") {
parts.push(`<span class="event-card-choices-chip-part event-card-choices-chip-part--skipped">${escHtml(t("choices.skipped.chip"))}</span>`);
}
const ap = kinds.get("appellant");
if (ap && ap !== "" ) {
let label = "";
switch (ap) {
case "claimant": label = t("choices.appellant.claimant"); break;
case "defendant": label = t("choices.appellant.defendant"); break;
case "both": label = t("choices.appellant.both"); break;
case "none": label = t("choices.appellant.none"); break;
}
if (label) {
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.appellant.chip"))} ${escHtml(label)}</span>`);
}
}
if (kinds.get("include_ccr") === "true") {
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.include_ccr.chip"))}</span>`);
}
return parts.join(" ");
}
function openPopover(state: AttachedState, caret: HTMLElement): void {
closePopover(state);
const code = caret.dataset.submissionCode || "";
if (!code) return;
let offered: Record<string, unknown> = {};
try {
offered = JSON.parse(caret.dataset.choicesOffered || "{}");
} catch {
return;
}
const isHidden = caret.dataset.isHidden === "1";
const pop = document.createElement("div");
pop.className = "event-card-choices-popover";
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-label", t("choices.caret.title"));
const blocks: string[] = [];
// t-paliad-293: hidden-card prominence. When the user opens the
// popover on a re-surfaced hidden card, "Wieder einblenden" is the
// most likely intent — surface it as a single high-contrast action
// at the top of the popover (rather than burying it under the skip
// toggle's reset link). Clicking it clears the `skip` choice, which
// is the same wire effect as the legacy inline chip from t-paliad-290.
if (isHidden) {
blocks.push(renderUnhideBlock());
}
if (Array.isArray(offered.appellant)) {
blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[]));
}
if (Array.isArray(offered.include_ccr)) {
blocks.push(renderToggleBlock(state, code, "include_ccr"));
}
if (Array.isArray(offered.skip)) {
blocks.push(renderToggleBlock(state, code, "skip"));
}
pop.innerHTML = blocks.join("");
document.body.appendChild(pop);
state.popover = pop;
positionPopover(pop, caret);
pop.addEventListener("click", async (e) => {
const btn = (e.target as HTMLElement | null)?.closest<HTMLButtonElement>("button[data-choice-action]");
if (!btn) return;
e.stopPropagation();
const kind = btn.dataset.choiceKind as ChoiceKind | undefined;
const value = btn.dataset.choiceValue || "";
const action = btn.dataset.choiceAction;
if (!kind) return;
try {
if (action === "set") {
await state.opts.commit({ submission_code: code, choice_kind: kind, choice_value: value });
if (!state.active.has(code)) state.active.set(code, new Map());
state.active.get(code)!.set(kind, value);
} else if (action === "clear") {
await state.opts.remove(code, kind);
state.active.get(code)?.delete(kind);
}
reseedChips(state.opts.container);
closePopover(state);
} catch (err) {
console.error("event card choice commit failed", err);
// Surface a soft inline error inside the popover; do NOT close.
const errEl = document.createElement("div");
errEl.className = "event-card-choices-error";
errEl.textContent = t("choices.commit.error");
pop.appendChild(errEl);
}
});
}
function renderAppellantBlock(state: AttachedState, code: string, values: unknown[]): string {
const current = state.active.get(code)?.get("appellant") || "";
const buttons = values
.filter((v): v is string => typeof v === "string")
.map((v) => {
const labelKey = `choices.appellant.${v}` as const;
const isActive = v === current;
return `<button type="button"
data-choice-action="set"
data-choice-kind="appellant"
data-choice-value="${escAttr(v)}"
class="event-card-choices-option${isActive ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
})
.join("");
const reset = current
? `<button type="button" data-choice-action="clear" data-choice-kind="appellant"
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
: "";
return `<div class="event-card-choices-block">
<div class="event-card-choices-title">${escHtml(t("choices.appellant.title"))}</div>
<div class="event-card-choices-options">${buttons}</div>
${reset}
</div>`;
}
function renderToggleBlock(state: AttachedState, code: string, kind: "include_ccr" | "skip"): string {
const current = state.active.get(code)?.get(kind) || "false";
const titleKey = kind === "include_ccr" ? "choices.include_ccr.title" : "choices.skip.title";
const trueKey = kind === "include_ccr" ? "choices.include_ccr.true" : "choices.skip.true";
const falseKey = kind === "include_ccr" ? "choices.include_ccr.false" : "choices.skip.false";
const opt = (v: "true" | "false", labelKey: string) => `<button type="button"
data-choice-action="set"
data-choice-kind="${kind}"
data-choice-value="${v}"
class="event-card-choices-option${v === current ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
const reset = state.active.get(code)?.has(kind)
? `<button type="button" data-choice-action="clear" data-choice-kind="${kind}"
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
: "";
return `<div class="event-card-choices-block">
<div class="event-card-choices-title">${escHtml(t(titleKey as any))}</div>
<div class="event-card-choices-options">
${opt("true", trueKey)}
${opt("false", falseKey)}
</div>
${reset}
</div>`;
}
// renderUnhideBlock is the popover's prominent "Wieder einblenden"
// action — surfaced only when the caret is opened on a re-surfaced
// hidden card (data-is-hidden="1" on the caret). Clicking it dispatches
// the same `clear` action as the skip-block reset link below, but
// labelled in the user's terms ("restore this card" rather than
// "reset skip choice"). Drops out of the popover automatically on
// non-hidden cards so the popover stays minimal. (t-paliad-293)
function renderUnhideBlock(): string {
const label = t("choices.unhide.chip");
return `<div class="event-card-choices-block event-card-choices-block--unhide">
<button type="button"
data-choice-action="clear"
data-choice-kind="skip"
class="event-card-choices-unhide-btn">${escHtml(label)}</button>
</div>`;
}
function closePopover(state: AttachedState): void {
if (state.popover) {
state.popover.remove();
state.popover = null;
}
}
function positionPopover(pop: HTMLDivElement, caret: HTMLElement): void {
const rect = caret.getBoundingClientRect();
const scrollY = window.scrollY || document.documentElement.scrollTop;
const scrollX = window.scrollX || document.documentElement.scrollLeft;
pop.style.position = "absolute";
pop.style.top = `${rect.bottom + scrollY + 4}px`;
pop.style.left = `${Math.max(8, rect.right + scrollX - 240)}px`;
pop.style.zIndex = "1000";
}
// Returns the current in-memory choice list for the given container —
// used by the unbound /tools/verfahrensablauf page to keep the URL
// param in sync.
export function currentChoices(container: HTMLElement): EventChoice[] {
const state = states.get(container);
if (!state) return [];
const out: EventChoice[] = [];
state.active.forEach((kinds, code) => {
kinds.forEach((value, kind) => {
out.push({ submission_code: code, choice_kind: kind, choice_value: value });
});
});
return out;
}

View File

@@ -0,0 +1,309 @@
// Unit tests for the /tools/verfahrensablauf URL + scenario-localStorage
// state contract (t-paliad-308 / m/paliad#137). Run with `bun test`.
//
// The contract:
// 1. URL params (proceeding, side, target, trigger_date) define which
// timeline kind the user is looking at — paste-able, shareable,
// refresh-resistant.
// 2. localStorage (paliad.verfahrensablauf.scenario.*) holds the
// per-user scenario tweaks (event_choices, court_id, flags,
// show_hidden) — these never leak into a shared link.
// 3. On hydrate, URL wins. localStorage fills the rest.
import { describe, expect, test } from "bun:test";
import {
APPEAL_TARGETS,
SCENARIO_KEYS,
SCENARIO_PREFIX,
URL_KEYS,
applyFiltersToSearch,
hydrate,
makeMemoryStorage,
parseAppealTargetFromSearch,
parseProceedingFromSearch,
parseSideFromSearch,
parseTriggerDateFromSearch,
readBoolFlag,
readCourtId,
readEventChoices,
readScenario,
writeBoolFlag,
writeCourtId,
writeEventChoices,
} from "./verfahrensablauf-state";
describe("URL parsers — filter chips", () => {
test("parseProceedingFromSearch returns empty string when absent", () => {
expect(parseProceedingFromSearch("")).toBe("");
expect(parseProceedingFromSearch("?side=claimant")).toBe("");
});
test("parseProceedingFromSearch echoes the raw value", () => {
expect(parseProceedingFromSearch("?proceeding=upc.inf.cfi")).toBe("upc.inf.cfi");
expect(parseProceedingFromSearch("?proceeding=upc.apl.unified&side=claimant")).toBe("upc.apl.unified");
});
test("parseSideFromSearch validates the enum", () => {
expect(parseSideFromSearch("?side=claimant")).toBe("claimant");
expect(parseSideFromSearch("?side=defendant")).toBe("defendant");
expect(parseSideFromSearch("?side=neither")).toBe(null);
expect(parseSideFromSearch("")).toBe(null);
});
test("parseAppealTargetFromSearch only accepts canonical slugs", () => {
for (const t of APPEAL_TARGETS) {
expect(parseAppealTargetFromSearch(`?target=${t}`)).toBe(t);
}
expect(parseAppealTargetFromSearch("?target=unknown")).toBe("");
expect(parseAppealTargetFromSearch("")).toBe("");
});
test("parseTriggerDateFromSearch validates the ISO-date shape", () => {
expect(parseTriggerDateFromSearch("?trigger_date=2026-05-26")).toBe("2026-05-26");
expect(parseTriggerDateFromSearch("?trigger_date=2024-02-29")).toBe("2024-02-29"); // leap year
});
test("parseTriggerDateFromSearch rejects malformed and impossible dates", () => {
expect(parseTriggerDateFromSearch("?trigger_date=2026-02-30")).toBe(""); // Feb 30
expect(parseTriggerDateFromSearch("?trigger_date=2026-13-01")).toBe(""); // month 13
expect(parseTriggerDateFromSearch("?trigger_date=tomorrow")).toBe("");
expect(parseTriggerDateFromSearch("?trigger_date=2026-5-26")).toBe(""); // 1-digit month
expect(parseTriggerDateFromSearch("")).toBe("");
});
});
describe("URL encoder — applyFiltersToSearch", () => {
test("empty filters preserve the existing query string", () => {
expect(applyFiltersToSearch("?other=keep", {})).toBe("?other=keep");
});
test("setting a filter writes the canonical key", () => {
expect(applyFiltersToSearch("", { proceeding: "upc.inf.cfi" })).toBe("?proceeding=upc.inf.cfi");
expect(applyFiltersToSearch("", { side: "claimant" })).toBe("?side=claimant");
expect(applyFiltersToSearch("", { target: "endentscheidung" })).toBe("?target=endentscheidung");
expect(applyFiltersToSearch("", { triggerDate: "2026-05-26" })).toBe("?trigger_date=2026-05-26");
});
test("setting null / empty / undefined deletes the key", () => {
expect(applyFiltersToSearch("?side=claimant", { side: null })).toBe("");
expect(applyFiltersToSearch("?proceeding=upc.inf.cfi", { proceeding: "" })).toBe("");
expect(applyFiltersToSearch("?target=endentscheidung", { target: "" })).toBe("");
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "" })).toBe("");
});
test("invalid trigger_date is deleted (never written as-is)", () => {
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "bogus" })).toBe("");
});
test("setting all four filters together emits all four keys", () => {
const out = applyFiltersToSearch("", {
proceeding: "upc.apl.unified",
side: "defendant",
target: "endentscheidung",
triggerDate: "2026-05-26",
});
expect(out).toContain("proceeding=upc.apl.unified");
expect(out).toContain("side=defendant");
expect(out).toContain("target=endentscheidung");
expect(out).toContain("trigger_date=2026-05-26");
});
test("other params (project, view) are preserved", () => {
const out = applyFiltersToSearch("?project=abc&view=timeline", { side: "claimant" });
expect(out).toContain("project=abc");
expect(out).toContain("view=timeline");
expect(out).toContain("side=claimant");
});
test("absent keys in the filter object don't touch existing URL values", () => {
// Only updating side — proceeding should be untouched.
const out = applyFiltersToSearch("?proceeding=upc.inf.cfi&side=defendant", { side: "claimant" });
expect(out).toContain("proceeding=upc.inf.cfi");
expect(out).toContain("side=claimant");
});
});
describe("URL round-trip — encode then parse yields the same value", () => {
test("proceeding", () => {
const enc = applyFiltersToSearch("", { proceeding: "upc.inf.cfi" });
expect(parseProceedingFromSearch(enc)).toBe("upc.inf.cfi");
});
test("side", () => {
const enc = applyFiltersToSearch("", { side: "defendant" });
expect(parseSideFromSearch(enc)).toBe("defendant");
});
test("target", () => {
const enc = applyFiltersToSearch("", { target: "kostenentscheidung" });
expect(parseAppealTargetFromSearch(enc)).toBe("kostenentscheidung");
});
test("trigger_date", () => {
const enc = applyFiltersToSearch("", { triggerDate: "2026-05-26" });
expect(parseTriggerDateFromSearch(enc)).toBe("2026-05-26");
});
});
describe("Scenario localStorage helpers", () => {
test("SCENARIO_PREFIX is paliad.verfahrensablauf.scenario and all keys live under it", () => {
expect(SCENARIO_PREFIX).toBe("paliad.verfahrensablauf.scenario");
for (const key of Object.values(SCENARIO_KEYS)) {
expect(key.startsWith(SCENARIO_PREFIX + ".")).toBe(true);
}
});
test("readEventChoices returns [] on empty storage", () => {
const s = makeMemoryStorage();
expect(readEventChoices(s)).toEqual([]);
});
test("writeEventChoices + readEventChoices round-trip", () => {
const s = makeMemoryStorage();
const choices = [
{ submission_code: "upc.inf.cfi.r12", choice_kind: "appellant" as const, choice_value: "claimant" },
{ submission_code: "upc.inf.cfi.r30", choice_kind: "include_ccr" as const, choice_value: "1" },
];
writeEventChoices(s, choices);
expect(readEventChoices(s)).toEqual(choices);
});
test("writeEventChoices([]) clears the key (removeItem semantic, not empty string)", () => {
const s = makeMemoryStorage();
writeEventChoices(s, [{ submission_code: "r1", choice_kind: "skip", choice_value: "1" }]);
expect(s.getItem(SCENARIO_KEYS.eventChoices)).not.toBe(null);
writeEventChoices(s, []);
expect(s.getItem(SCENARIO_KEYS.eventChoices)).toBe(null);
});
test("readEventChoices ignores unknown choice_kind values", () => {
const s = makeMemoryStorage();
s.setItem(SCENARIO_KEYS.eventChoices, "r1:appellant=claimant,r2:bogus=x,r3:skip=1");
expect(readEventChoices(s)).toEqual([
{ submission_code: "r1", choice_kind: "appellant", choice_value: "claimant" },
{ submission_code: "r3", choice_kind: "skip", choice_value: "1" },
]);
});
test("readCourtId returns '' on empty storage, echoes stored value otherwise", () => {
const s = makeMemoryStorage();
expect(readCourtId(s)).toBe("");
writeCourtId(s, "UPC-LD-MUC");
expect(readCourtId(s)).toBe("UPC-LD-MUC");
});
test("writeCourtId('') removes the key", () => {
const s = makeMemoryStorage();
writeCourtId(s, "UPC-LD-MUC");
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe("UPC-LD-MUC");
writeCourtId(s, "");
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe(null);
});
test("readBoolFlag / writeBoolFlag round-trip with removeItem on false", () => {
const s = makeMemoryStorage();
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(true);
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe("1");
writeBoolFlag(s, SCENARIO_KEYS.ccr, false);
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe(null);
});
test("readScenario returns all fields defaulted on empty storage", () => {
const s = makeMemoryStorage();
expect(readScenario(s)).toEqual({
eventChoices: [],
courtId: "",
ccr: false,
infAmend: false,
revAmend: false,
revCci: false,
showHidden: false,
});
});
});
describe("Hydration order — URL wins, localStorage fills the rest", () => {
test("URL fills filter chips, localStorage fills scenario state", () => {
const s = makeMemoryStorage();
writeCourtId(s, "UPC-LD-MUC");
writeBoolFlag(s, SCENARIO_KEYS.showHidden, true);
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
const out = hydrate(
"?proceeding=upc.inf.cfi&side=defendant&target=endentscheidung&trigger_date=2026-05-26",
s,
);
// URL-sourced
expect(out.proceeding).toBe("upc.inf.cfi");
expect(out.side).toBe("defendant");
expect(out.target).toBe("endentscheidung");
expect(out.triggerDate).toBe("2026-05-26");
// localStorage-sourced
expect(out.courtId).toBe("UPC-LD-MUC");
expect(out.showHidden).toBe(true);
expect(out.ccr).toBe(true);
});
test("absent URL → all filter fields are empty/null, localStorage still hydrates scenario", () => {
const s = makeMemoryStorage();
writeCourtId(s, "UPC-LD-MUC");
const out = hydrate("", s);
expect(out.proceeding).toBe("");
expect(out.side).toBe(null);
expect(out.target).toBe("");
expect(out.triggerDate).toBe("");
expect(out.courtId).toBe("UPC-LD-MUC");
});
test("absent localStorage → URL still fills filter chips, scenario defaults", () => {
const s = makeMemoryStorage();
const out = hydrate(
"?proceeding=upc.apl.unified&side=claimant&target=anordnung&trigger_date=2026-07-01",
s,
);
expect(out.proceeding).toBe("upc.apl.unified");
expect(out.side).toBe("claimant");
expect(out.target).toBe("anordnung");
expect(out.triggerDate).toBe("2026-07-01");
expect(out.courtId).toBe("");
expect(out.eventChoices).toEqual([]);
expect(out.showHidden).toBe(false);
});
test("a shared link doesn't leak the recipient's scenario state in", () => {
// Two storages: m's (loaded with court + flags) and a recipient's
// (empty). The same URL should reproduce filter chips identically
// but leave each user's scenario state untouched.
const mStorage = makeMemoryStorage();
writeCourtId(mStorage, "UPC-LD-MUC");
writeBoolFlag(mStorage, SCENARIO_KEYS.ccr, true);
const recipientStorage = makeMemoryStorage();
const sharedURL = "?proceeding=upc.inf.cfi&side=defendant&trigger_date=2026-05-26";
const mView = hydrate(sharedURL, mStorage);
const recipientView = hydrate(sharedURL, recipientStorage);
// Filter chips identical
expect(mView.proceeding).toBe(recipientView.proceeding);
expect(mView.side).toBe(recipientView.side);
expect(mView.triggerDate).toBe(recipientView.triggerDate);
// Scenario state diverges — recipient sees defaults
expect(mView.courtId).toBe("UPC-LD-MUC");
expect(recipientView.courtId).toBe("");
expect(mView.ccr).toBe(true);
expect(recipientView.ccr).toBe(false);
});
});
describe("URL key constants match the documented contract", () => {
test("URL_KEYS uses the spec'd snake_case names", () => {
expect(URL_KEYS.proceeding).toBe("proceeding");
expect(URL_KEYS.side).toBe("side");
expect(URL_KEYS.target).toBe("target");
expect(URL_KEYS.triggerDate).toBe("trigger_date");
});
});

View File

@@ -0,0 +1,263 @@
// /tools/verfahrensablauf URL + scenario-localStorage state contract
// (t-paliad-308 / m/paliad#137). Splits the page's persisted state into
// two namespaces:
//
// URL params (filter chips — the timeline kind the user is looking
// at; paste-able, shareable, refresh-resistant):
// proceeding, side, target, trigger_date
//
// localStorage `paliad.verfahrensablauf.scenario.*` (per-user
// scenario inputs — the noisy parts that don't belong in a URL):
// event_choices, court_id, ccr, inf_amend, rev_amend, rev_cci,
// show_hidden
//
// Hydration order: URL wins. On page load, URL fills the filter chips;
// localStorage fills the rest. Filter-chip changes write to URL only.
// Scenario changes write to localStorage only. A shared link from a
// colleague reproduces the timeline kind (proceeding + side + target +
// trigger_date) but never leaks the recipient's court / flag /
// event_choices state in.
//
// All helpers in this module are pure: they take a search string (or a
// StorageLike) and return values, no DOM. The wiring in
// ../verfahrensablauf.ts mounts them onto window.location +
// window.localStorage at runtime.
import type { EventChoice, ChoiceKind } from "./event-card-choices";
// ----- URL params (filter chips) ----------------------------------
export type Side = "claimant" | "defendant" | null;
export const APPEAL_TARGETS = [
"endentscheidung",
"kostenentscheidung",
"anordnung",
"schadensbemessung",
"bucheinsicht",
] as const;
export type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
export const URL_KEYS = {
proceeding: "proceeding",
side: "side",
target: "target",
triggerDate: "trigger_date",
} as const;
// parseProceedingFromSearch extracts the proceeding code. Returns ""
// if absent. No validation against the proceeding registry — that's
// the caller's job (an unknown code from a stale link should leave
// the first-tile auto-select fallback running).
export function parseProceedingFromSearch(search: string): string {
const v = new URLSearchParams(search).get(URL_KEYS.proceeding);
return v ?? "";
}
export function parseSideFromSearch(search: string): Side {
const raw = new URLSearchParams(search).get(URL_KEYS.side);
return raw === "claimant" || raw === "defendant" ? raw : null;
}
export function parseAppealTargetFromSearch(search: string): AppealTarget {
const raw = new URLSearchParams(search).get(URL_KEYS.target) || "";
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
return raw as AppealTarget;
}
return "";
}
// parseTriggerDateFromSearch validates the ISO-date shape so a
// malformed link can't poison the date input. Accepts "YYYY-MM-DD"
// only. Round-tripped against Date to reject 2026-02-30 etc.
export function parseTriggerDateFromSearch(search: string): string {
const raw = new URLSearchParams(search).get(URL_KEYS.triggerDate) || "";
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return "";
const d = new Date(raw + "T00:00:00Z");
if (Number.isNaN(d.getTime())) return "";
if (d.toISOString().slice(0, 10) !== raw) return "";
return raw;
}
// applyFiltersToSearch produces the canonical query string for the
// four URL-owned params. Other params (e.g. ?view=, ?project=) are
// preserved verbatim. Empty values are deleted, never written as
// empty string, so the URL stays clean on the default.
export function applyFiltersToSearch(
search: string,
filters: { proceeding?: string; side?: Side; target?: AppealTarget; triggerDate?: string },
): string {
const params = new URLSearchParams(search);
if ("proceeding" in filters) {
if (filters.proceeding && filters.proceeding !== "") {
params.set(URL_KEYS.proceeding, filters.proceeding);
} else {
params.delete(URL_KEYS.proceeding);
}
}
if ("side" in filters) {
if (filters.side === "claimant" || filters.side === "defendant") {
params.set(URL_KEYS.side, filters.side);
} else {
params.delete(URL_KEYS.side);
}
}
if ("target" in filters) {
if (filters.target && filters.target !== "") {
params.set(URL_KEYS.target, filters.target);
} else {
params.delete(URL_KEYS.target);
}
}
if ("triggerDate" in filters) {
if (filters.triggerDate && /^\d{4}-\d{2}-\d{2}$/.test(filters.triggerDate)) {
params.set(URL_KEYS.triggerDate, filters.triggerDate);
} else {
params.delete(URL_KEYS.triggerDate);
}
}
const s = params.toString();
return s ? `?${s}` : "";
}
// ----- localStorage (scenario state) ------------------------------
export const SCENARIO_PREFIX = "paliad.verfahrensablauf.scenario";
export const SCENARIO_KEYS = {
eventChoices: `${SCENARIO_PREFIX}.event_choices`,
courtId: `${SCENARIO_PREFIX}.court_id`,
ccr: `${SCENARIO_PREFIX}.ccr`,
infAmend: `${SCENARIO_PREFIX}.inf_amend`,
revAmend: `${SCENARIO_PREFIX}.rev_amend`,
revCci: `${SCENARIO_PREFIX}.rev_cci`,
showHidden: `${SCENARIO_PREFIX}.show_hidden`,
} as const;
// StorageLike is the tiny subset of the Web Storage API the scenario
// helpers actually use. Lets the tests pass a Map-backed fake without
// pulling in a full localStorage polyfill.
export interface StorageLike {
getItem(key: string): string | null;
setItem(key: string, value: string): void;
removeItem(key: string): void;
}
// readEventChoices is forgiving: malformed tuples or unknown
// choice_kinds are dropped silently. Same shape as the legacy URL
// codec (comma-separated `submission_code:kind=value`).
export function readEventChoices(storage: StorageLike): EventChoice[] {
const raw = storage.getItem(SCENARIO_KEYS.eventChoices);
if (!raw) return [];
const out: EventChoice[] = [];
for (const tuple of raw.split(",")) {
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
if (!m) continue;
const kind = m[2] as ChoiceKind;
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
}
return out;
}
export function writeEventChoices(storage: StorageLike, choices: EventChoice[]): void {
if (choices.length === 0) {
storage.removeItem(SCENARIO_KEYS.eventChoices);
return;
}
const enc = choices
.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`)
.join(",");
storage.setItem(SCENARIO_KEYS.eventChoices, enc);
}
// readCourtId / writeCourtId — empty string == no court picked. The
// "" value is stored as a removed key, not an empty string entry, so
// reading it back yields null rather than "".
export function readCourtId(storage: StorageLike): string {
return storage.getItem(SCENARIO_KEYS.courtId) ?? "";
}
export function writeCourtId(storage: StorageLike, courtId: string): void {
if (courtId === "") {
storage.removeItem(SCENARIO_KEYS.courtId);
return;
}
storage.setItem(SCENARIO_KEYS.courtId, courtId);
}
// Boolean flags — "1" / "0" string encoding, removeItem on default
// (false for flags, also false for show_hidden) so the storage stays
// uncluttered on a fresh page.
export function readBoolFlag(storage: StorageLike, key: string): boolean {
return storage.getItem(key) === "1";
}
export function writeBoolFlag(storage: StorageLike, key: string, on: boolean): void {
if (on) storage.setItem(key, "1");
else storage.removeItem(key);
}
// Read all scenario state in one call — convenience for the page's
// load-time hydration. Caller decides whether to apply each field
// (e.g. court_id is proceeding-specific; the page may discard the
// stored value if the active proceeding doesn't expose a court row).
export interface ScenarioState {
eventChoices: EventChoice[];
courtId: string;
ccr: boolean;
infAmend: boolean;
revAmend: boolean;
revCci: boolean;
showHidden: boolean;
}
export function readScenario(storage: StorageLike): ScenarioState {
return {
eventChoices: readEventChoices(storage),
courtId: readCourtId(storage),
ccr: readBoolFlag(storage, SCENARIO_KEYS.ccr),
infAmend: readBoolFlag(storage, SCENARIO_KEYS.infAmend),
revAmend: readBoolFlag(storage, SCENARIO_KEYS.revAmend),
revCci: readBoolFlag(storage, SCENARIO_KEYS.revCci),
showHidden: readBoolFlag(storage, SCENARIO_KEYS.showHidden),
};
}
// ----- URL → localStorage hydration order -------------------------
// The page's load-time contract: read URL filters, then read
// scenario state from localStorage. URL wins on conflict — but the
// only field that can conflict is none of them today (URL owns
// proceeding/side/target/trigger_date; localStorage owns the rest).
// The order matters for one edge case: if a future field migrates
// from URL → localStorage with overlap, the URL value MUST be honored.
export interface HydratedState extends ScenarioState {
proceeding: string;
side: Side;
target: AppealTarget;
triggerDate: string;
}
export function hydrate(search: string, storage: StorageLike): HydratedState {
const scenario = readScenario(storage);
return {
proceeding: parseProceedingFromSearch(search),
side: parseSideFromSearch(search),
target: parseAppealTargetFromSearch(search),
triggerDate: parseTriggerDateFromSearch(search),
...scenario,
};
}
// makeMemoryStorage — tiny StorageLike for tests / SSR fallback.
// Not used by the runtime page (which mounts real localStorage), but
// kept here so test files have one well-known import.
export function makeMemoryStorage(): StorageLike {
const store = new Map<string, string>();
return {
getItem: (k) => (store.has(k) ? store.get(k)! : null),
setItem: (k, v) => { store.set(k, v); },
removeItem: (k) => { store.delete(k); },
};
}

View File

@@ -0,0 +1,293 @@
import { h } from "../jsx";
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>
);
}
// Slice B1 (m/paliad#124 §18.1): the 3 separate Berufung tiles
// (upc.apl.merits / upc.apl.cost / upc.apl.order) collapse into ONE
// unified "Berufung" tile (upc.apl). After picking it, the user
// selects which decision the appeal is directed AT via the
// .appeal-target-row chip group below — the engine then filters
// rules whose applies_to_target contains the picked slug.
const UPC_TYPES: ProceedingDef[] = [
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
{ code: "upc.apl.unified", i18nKey: "deadlines.upc.apl.unified", name: "Berufung" },
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
];
const DE_INF_TYPES: ProceedingDef[] = [
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
];
const DE_NULL_TYPES: ProceedingDef[] = [
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
];
const EPA_TYPES: ProceedingDef[] = [
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
];
const DPMA_TYPES: ProceedingDef[] = [
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
];
// Shared Verfahrensablauf wizard body. Renders the proceeding picker,
// perspective + date inputs, scenario flag rows, detail-mode toggle,
// view toggle, and the timeline-container that client/verfahrensablauf.ts
// (via initVerfahrensablauf()) wires against. Used by both
// /tools/verfahrensablauf (legacy) and /tools/procedures (unified).
export function VerfahrensablaufBody({ todayIso }: { todayIso: string }): string {
return (
<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-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
<div className="proceeding-btns">
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
<div className="proceeding-btns">
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
</div>
</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.perspective">Perspektive und Datum</span>
</h3>
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
<div className="verfahrensablauf-perspective-row" id="side-row">
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
<div className="side-radio-cluster" id="side-radio-cluster">
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
<label className="fristen-view-option">
<input type="radio" name="side" value="claimant" />
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="defendant" />
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="" checked />
<span data-i18n="deadlines.side.undefined">Nicht festgelegt</span>
</label>
</div>
<span className="side-hint" id="side-hint"
data-i18n="deadlines.side.hint">
W&auml;hlen Sie eine Seite, um die Spalten zu fokussieren.
</span>
</div>
<div className="side-chip" id="side-chip" style="display:none">
<span className="side-chip-tag" data-i18n="deadlines.side.from_project">Aus Akte:</span>
<strong className="side-chip-value" id="side-chip-value">&mdash;</strong>
<button type="button" className="side-chip-override" id="side-chip-override"
data-i18n="deadlines.side.override">
Andere Seite w&auml;hlen
</button>
</div>
</div>
<div className="verfahrensablauf-perspective-row" id="appeal-target-row" style="display:none">
<span className="date-label" data-i18n="deadlines.appeal_target.label">Worauf richtet sich die Berufung?</span>
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appeal target">
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="endentscheidung" checked />
<span data-i18n="deadlines.appeal_target.endentscheidung">Endentscheidung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="kostenentscheidung" />
<span data-i18n="deadlines.appeal_target.kostenentscheidung">Kostenentscheidung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="anordnung" />
<span data-i18n="deadlines.appeal_target.anordnung">Anordnung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="schadensbemessung" />
<span data-i18n="deadlines.appeal_target.schadensbemessung">Schadensbemessung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="bucheinsicht" />
<span data-i18n="deadlines.appeal_target.bucheinsicht">Bucheinsicht</span>
</label>
</div>
</div>
<div className="verfahrensablauf-perspective-row" id="show-hidden-row" style="display:none">
<label className="fristen-view-option">
<input type="checkbox" id="show-hidden-toggle" />
<span data-i18n="choices.show_hidden.label">Ausgeblendete anzeigen</span>
</label>
<span className="show-hidden-count" id="show-hidden-count" aria-live="polite">&nbsp;</span>
</div>
</div>
<div className="verfahrensablauf-step2-divider" aria-hidden="true"></div>
<div className="date-input-group">
<div className="date-field-row">
<span className="date-label" data-i18n="deadlines.trigger.event">Ausl&ouml;sendes Ereignis:</span>
<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={todayIso} />
</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>
<div className="date-field-row" id="ccr-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="ccr-flag" />
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
</label>
</div>
<div className="date-field-row date-field-row--nested" id="inf-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="inf-amend-flag" />
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patent&auml;nderung (R.30)</span>
</label>
</div>
<div className="date-field-row" id="rev-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-amend-flag" />
<span data-i18n="deadlines.flag.rev_amend">Mit Antrag auf Patent&auml;nderung (R.49.2.a)</span>
</label>
</div>
<div className="date-field-row" id="rev-cci-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-cci-flag" />
<span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span>
</label>
</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="verfahrensablauf-detail-toggle" id="verfahrensablauf-detail-toggle"
role="radiogroup" aria-label="Detail">
<span className="fristen-view-label" data-i18n="deadlines.detail.label">Anzeige:</span>
<label className="fristen-view-option">
<input type="radio" name="detail-mode" value="mandatory_only" />
<span data-i18n="deadlines.detail.mandatory_only">Nur Pflicht</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="detail-mode" value="selected" checked />
<span data-i18n="deadlines.detail.selected">Gewählt</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="detail-mode" value="all_options" />
<span data-i18n="deadlines.detail.all_options">Alle Optionen</span>
</label>
</div>
<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>
<label className="fristen-notes-option">
<input type="checkbox" id="fristen-notes-show" />
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
</label>
<label className="fristen-notes-option">
<input type="checkbox" id="verfahrensablauf-durations-show" />
<span data-i18n="deadlines.durations.show">Dauern anzeigen</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>
);
}

View File

@@ -1358,8 +1358,6 @@ export type I18nKey =
| "deadlines.filter.status"
| "deadlines.filter.thisweek"
| "deadlines.filter.today"
| "deadlines.flag.amend"
| "deadlines.flag.cci"
| "deadlines.flag.ccr"
| "deadlines.flag.inf_amend"
| "deadlines.flag.rev_amend"
@@ -2205,50 +2203,19 @@ export type I18nKey =
| "partner_unit.members_label"
| "partner_unit.none"
| "partner_unit.subtitle"
| "procedures.appeal_target.label"
| "procedures.cold_open.hint"
| "procedures.filter.axis.date"
| "procedures.filter.axis.forum"
| "procedures.filter.axis.kind"
| "procedures.filter.axis.party"
| "procedures.filter.axis.proc"
| "procedures.filter.forum.all"
| "procedures.filter.party.all"
| "procedures.filter.search.placeholder"
| "procedures.find.summary.akte"
| "procedures.find.summary.anchor"
| "procedures.find.summary.empty"
| "procedures.find.summary.many"
| "procedures.find.summary.one"
| "procedures.heading"
| "procedures.node.actual.done"
| "procedures.node.actual.open"
| "procedures.node.actual.overdue"
| "procedures.node.cross"
| "procedures.node.cross.short"
| "procedures.node.fokus"
| "procedures.node.here"
| "procedures.node.pin"
| "procedures.panel.akte.placeholder"
| "procedures.proceeding.detail.all"
| "procedures.proceeding.detail.selected"
| "procedures.proceeding.detail.title"
| "procedures.proceeding.hide"
| "procedures.proceeding.show"
| "procedures.proceeding.toggle"
| "procedures.subtitle"
| "procedures.tab.akte"
| "procedures.tab.proceeding"
| "procedures.tab.search"
| "procedures.tab.wizard"
| "procedures.timelines.court_set"
| "procedures.timelines.empty"
| "procedures.timelines.error"
| "procedures.timelines.loading"
| "procedures.timelines.options"
| "procedures.title"
| "procedures.zoom.breadcrumb"
| "procedures.zoom.hidden"
| "project.instance_level.appeal"
| "project.instance_level.cassation"
| "project.instance_level.first"

View File

@@ -4,32 +4,23 @@ import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
import { VerfahrensablaufBody } from "./components/VerfahrensablaufBody";
// /tools/procedures — workflow-tracker shell (m/paliad#152 T1,
// docs/design-procedures-workflow-tracker-2026-05-27.md §9 T1).
// U0 — Skeleton for the unified procedural-events tool
// (m/paliad#151, design docs/design-unified-procedural-events-tool-2026-05-27.md).
//
// Single canonical shape:
// 1. Sticky find header — search input + forum / Verfahren / Partei
// pill rows + global Stichtag. The header narrows the timeline
// set rendered below; it is not itself a tab strip.
// 2. Timeline body — one card per matched proceeding, rendered as a
// chained tree by parent_id with priority-styled bullets. Cold
// open renders the 6 curated default proceedings from
// design §8 / §11.Q4.
// Folds /tools/fristenrechner (Mode A + Mode B + result) and
// /tools/verfahrensablauf into a single page at /tools/procedures. Each
// later slice fills one of the four entry tabs:
//
// Each later slice layers on top of this shell:
// T2 — anchor pin + zoom + multi-proceeding scope (§6.5).
// T3 — Akte landing + actuals overlay.
// T4 — appeal-target chip group + court-set choices + per-proceeding
// "Alle Optionen" toggle.
// T5 — dead-code removal (the old per-tab fristenrechner-mode-*,
// fristenrechner-wizard, fristenrechner-result, verfahrensablauf
// modules + their CSS once nothing imports them).
// U1 — Direkt suchen (Mode A search)
// U2 — Geführt (Mode B wizard)
// U3 — Verfahren (Verfahrensablauf tree + 3-way detail filter)
// U4 — Hard-cut 301 (drop legacy pages, redirect URLs)
//
// No DB dependency — the page itself is static HTML; data flows over
// the existing /api/tools/fristenrechner endpoints. The 4 entry-mode
// tabs the catalog (U0-U4) shipped earlier today are deleted in this
// PR per m's Q7 divergent pick (direct replace, no flag).
// This file ships only the page chrome — sidebar, header, filter strip
// with search box, four entry-mode tabs, and the host containers the
// later slices mount their UI into. No data wiring.
export function renderProcedures(): string {
const today = new Date().toISOString().split("T")[0];
@@ -55,18 +46,20 @@ export function renderProcedures(): string {
<div className="tool-header">
<h1 data-i18n="procedures.heading">Verfahren &amp; Fristen</h1>
<p className="tool-subtitle" data-i18n="procedures.subtitle">
Verfahrensabl&auml;ufe als Zeitstrahl &mdash; suchen, filtern, Verzweigungen w&auml;hlen.
Verfahrensablauf, Fristenrechner und ger&uuml;hrte Suche in einem Tool.
</p>
</div>
{/* Find affordance (design §2). Sticky header — search,
forum + Verfahren + Partei pills, and the global
Stichtag (date input). Pills hydrate from
procedures-tracker on boot; markup carries the host
rows only. */}
<section className="tracker-find" aria-label="Filter" id="tracker-find">
<div className="tracker-find-search">
<svg className="tracker-find-search-icon" width="18" height="18" viewBox="0 0 24 24"
{/* Shared filter strip — search box + four chip groups
(forum / proceeding / event_kind / party). Lives at the
top of the page so every entry tab and output mode reads
the same active filter set (design §4 + m's Q3
divergence: search composes with chip filters). U0
ships the markup only; chip hydration + search wiring
arrive with U1-U3. */}
<section className="procedures-filter-strip" aria-label="Filter">
<div className="procedures-filter-search">
<svg className="procedures-filter-search-icon" width="18" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
@@ -74,53 +67,120 @@ export function renderProcedures(): string {
</svg>
<input
type="search"
id="tracker-search-input"
className="tracker-find-search-input"
id="procedures-search-input"
className="procedures-filter-search-input"
autocomplete="off"
spellcheck="false"
data-i18n-placeholder="procedures.filter.search.placeholder"
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing&hellip;"
/>
</div>
<div className="tracker-find-row" data-axis="forum">
<span className="tracker-find-axis-label" data-i18n="procedures.filter.axis.forum">Forum:</span>
<div className="tracker-find-pills" id="tracker-pills-forum" role="group"></div>
</div>
<div className="tracker-find-row" data-axis="proc">
<span className="tracker-find-axis-label" data-i18n="procedures.filter.axis.proc">Verfahren:</span>
<div className="tracker-find-pills" id="tracker-pills-proc" role="group"></div>
</div>
<div className="tracker-find-row" data-axis="party">
<span className="tracker-find-axis-label" data-i18n="procedures.filter.axis.party">Partei:</span>
<div className="tracker-find-pills" id="tracker-pills-party" role="group"></div>
</div>
<div className="tracker-find-row" data-axis="date">
<label htmlFor="tracker-trigger-date" className="tracker-find-axis-label"
data-i18n="procedures.filter.axis.date">Stichtag:</label>
<input
type="date"
id="tracker-trigger-date"
className="tracker-find-date-input"
value={today}
/>
</div>
<div className="tracker-find-summary" id="tracker-find-summary" aria-live="polite"></div>
</section>
{/* Timeline body — one card per matched proceeding. Cards
are appended by procedures-tracker.ts on boot and
re-rendered when the find header changes. */}
<section className="tracker-timelines" id="tracker-timelines" aria-label="Verfahren">
<div className="tracker-timelines-placeholder" id="tracker-timelines-placeholder"
data-i18n="procedures.timelines.loading">
Verfahren werden geladen&hellip;
<div className="procedures-filter-chips" id="procedures-filter-chips">
<div className="procedures-filter-chip-row" data-axis="forum">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.forum">Forum:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-forum"></div>
</div>
<div className="procedures-filter-chip-row" data-axis="proc">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.proc">Verfahren:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-proc"></div>
</div>
<div className="procedures-filter-chip-row" data-axis="kind">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.kind">Ereignisart:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-kind"></div>
</div>
<div className="procedures-filter-chip-row" data-axis="party">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.party">Partei:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-party"></div>
</div>
</div>
</section>
{/* Entry-mode tab strip — all four tabs visible from boot
(m's Q3 divergence). The active tab is URL-driven
(?mode=proceeding|search|wizard|akte); cold open lands
on "proceeding" per design §11.5.Q3. */}
<nav className="procedures-tabs" role="tablist" aria-label="Einstieg">
<button type="button"
className="procedures-tab is-active"
role="tab"
aria-selected="true"
data-tab="proceeding"
id="procedures-tab-proceeding">
<span className="procedures-tab-icon" aria-hidden="true">&#128218;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.proceeding">Verfahren w&auml;hlen</span>
</button>
<button type="button"
className="procedures-tab"
role="tab"
aria-selected="false"
data-tab="search"
id="procedures-tab-search">
<span className="procedures-tab-icon" aria-hidden="true">&#9889;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.search">Direkt suchen</span>
</button>
<button type="button"
className="procedures-tab"
role="tab"
aria-selected="false"
data-tab="wizard"
id="procedures-tab-wizard">
<span className="procedures-tab-icon" aria-hidden="true">&#129517;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.wizard">Gef&uuml;hrt</span>
</button>
<button type="button"
className="procedures-tab"
role="tab"
aria-selected="false"
data-tab="akte"
id="procedures-tab-akte">
<span className="procedures-tab-icon" aria-hidden="true">&#128193;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.akte">Aus Akte</span>
</button>
</nav>
{/* Per-tab content hosts. Only one is visible at a time —
procedures.ts toggles `hidden` on the inactive ones.
Each later slice fills the corresponding host. */}
<section className="procedures-panel" id="procedures-panel-proceeding" role="tabpanel"
aria-labelledby="procedures-tab-proceeding">
{/* Verfahrensablauf wizard body — shared TSX component
used by /tools/verfahrensablauf (legacy) and the
unified /tools/procedures page. procedures.ts calls
initVerfahrensablauf() on the first activation of
this tab, which wires the .proceeding-btn clicks,
timeline-container, detail-mode toggle, etc. against
the markup. The legacy page's auto-boot is guarded
against the procedures-only #procedures-panel-proceeding
element so it doesn't fire twice. */}
<VerfahrensablaufBody todayIso={today} />
</section>
<section className="procedures-panel" id="procedures-panel-search" role="tabpanel"
aria-labelledby="procedures-tab-search" hidden></section>
<section className="procedures-panel" id="procedures-panel-wizard" role="tabpanel"
aria-labelledby="procedures-tab-wizard" hidden></section>
<section className="procedures-panel" id="procedures-panel-akte" role="tabpanel"
aria-labelledby="procedures-tab-akte" hidden>
<div className="procedures-panel-placeholder" data-i18n="procedures.panel.akte.placeholder">
Akten-Einstieg folgt in einem sp&auml;teren Slice.
</div>
</section>
{/* Tree output host. Slice U3 mounts the Verfahrensablauf
tree here; U0 leaves it empty + hidden so the
tab placeholders are the only thing visible. */}
<section className="procedures-output procedures-output-tree" id="procedures-output-tree"
aria-label="Tree output" hidden></section>
{/* Linear-drawer host. Inline drawer expanding beneath a
tree card (design §8 — desktop) AND the standalone
linear follow-up view that Mode A / Mode B land on
after locking a trigger event (design §3.2). U1
switches it on. */}
<section className="procedures-output procedures-output-linear" id="procedures-output-linear"
aria-label="Linear output" hidden></section>
</div>
</section>
</main>

View File

@@ -19811,594 +19811,3 @@ a.fristen-overhaul-rule-source {
width: 100%;
}
}
/* ============================================================================
* Workflow tracker — /tools/procedures (m/paliad#152 T1+).
*
* Single canonical shape: sticky find header + per-proceeding cards
* rendered as chained trees by parent_id. See
* docs/design-procedures-workflow-tracker-2026-05-27.md.
* T1 ships the shell + flat fork checkboxes; T2-T4 layer anchor pin /
* Akte / appeal-target on top.
* ========================================================================= */
.tracker-find {
position: sticky;
top: 0;
z-index: 5;
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
padding: 0.75rem 0 0.5rem;
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tracker-find-search {
position: relative;
display: flex;
align-items: center;
}
.tracker-find-search-icon {
position: absolute;
left: 0.6rem;
color: var(--color-text-subtle);
pointer-events: none;
}
.tracker-find-search-input {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2.2rem;
border: 1px solid var(--color-border-strong);
border-radius: 6px;
background: var(--color-bg-subtle);
color: var(--color-text);
font-size: 0.95rem;
}
.tracker-find-search-input:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
border-color: var(--color-accent);
}
.tracker-find-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.tracker-find-axis-label {
font-size: 0.85rem;
font-weight: 500;
color: var(--color-text-muted);
min-width: 5rem;
}
.tracker-find-pills {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.tracker-pill {
padding: 0.25rem 0.6rem;
border: 1px solid var(--color-border-strong);
border-radius: 999px;
background: var(--color-bg-subtle);
color: var(--color-text);
font-size: 0.85rem;
cursor: pointer;
transition: background 0.1s, border-color 0.1s;
}
.tracker-pill:hover {
background: var(--color-bg-lime-tint);
border-color: var(--color-accent);
}
.tracker-pill.is-active {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-fg);
font-weight: 500;
}
.tracker-find-date-input {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-border-strong);
border-radius: 4px;
background: var(--color-bg-subtle);
color: var(--color-text);
font-size: 0.9rem;
}
.tracker-find-summary {
font-size: 0.85rem;
color: var(--color-text-muted);
min-height: 1.2rem;
}
.tracker-timelines {
display: flex;
flex-direction: column;
gap: 1rem;
}
.tracker-timelines-placeholder,
.tracker-timelines-empty,
.tracker-cold-open-hint {
padding: 0.75rem;
text-align: center;
font-size: 0.9rem;
color: var(--color-text-muted);
font-style: italic;
}
.tracker-cold-open-hint {
background: var(--color-bg-lime-tint);
border: 1px dashed var(--color-accent);
border-radius: 6px;
font-style: normal;
color: var(--color-text);
}
/* ─── per-proceeding card ───────────────────────────────────────── */
.tracker-proceeding {
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg);
overflow: hidden;
}
.tracker-proceeding-header {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.9rem;
background: var(--color-bg-subtle);
border-bottom: 1px solid var(--color-border);
}
.tracker-proceeding-jur {
display: inline-block;
padding: 0.1rem 0.4rem;
background: var(--color-text);
color: var(--color-bg);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
border-radius: 3px;
}
.tracker-proceeding-name {
margin: 0;
font-size: 1rem;
font-weight: 600;
flex: 1;
}
.tracker-proceeding-code {
font-size: 0.75rem;
color: var(--color-text-subtle);
font-family: monospace;
}
.tracker-proceeding-options {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.9rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
font-size: 0.85rem;
}
.tracker-proceeding-options-label {
font-weight: 500;
color: var(--color-text-muted);
}
.tracker-proceeding-option {
display: inline-flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
}
.tracker-proceeding-option input[type="checkbox"] {
margin: 0;
accent-color: var(--color-accent);
}
.tracker-proceeding-body {
padding: 0.75rem 0.9rem;
}
.tracker-proceeding-loading,
.tracker-proceeding-empty,
.tracker-proceeding-error {
font-size: 0.85rem;
color: var(--color-text-subtle);
font-style: italic;
text-align: center;
padding: 0.5rem;
}
.tracker-proceeding-error {
color: var(--color-text);
background: rgba(245, 101, 101, 0.08);
border-left: 3px solid #ef4444;
font-style: normal;
}
/* ─── tree nodes ────────────────────────────────────────────────── */
.tracker-tree {
list-style: none;
margin: 0;
padding: 0;
}
.tracker-tree-root {
position: relative;
}
.tracker-tree .tracker-tree {
padding-left: 1.4rem;
position: relative;
border-left: 1px dashed var(--color-border-strong);
margin-left: 0.5rem;
}
.tracker-node {
position: relative;
padding: 0.3rem 0;
}
.tracker-node-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.25rem 0.4rem;
border-radius: 4px;
font-size: 0.9rem;
line-height: 1.3;
transition: background 0.1s;
}
.tracker-node:hover > .tracker-node-line {
background: var(--color-bg-lime-tint);
}
.tracker-node-bullet {
display: inline-block;
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
background: var(--color-text);
flex-shrink: 0;
}
.tracker-node--mandatory > .tracker-node-line .tracker-node-bullet {
background: var(--color-text);
}
.tracker-node--recommended > .tracker-node-line .tracker-node-bullet {
background: var(--color-text);
opacity: 0.6;
}
.tracker-node--optional > .tracker-node-line .tracker-node-bullet {
background: transparent;
border: 1.5px dotted var(--color-text-muted);
width: 0.65rem;
height: 0.65rem;
}
.tracker-node--informational > .tracker-node-line .tracker-node-bullet {
background: var(--color-text-subtle);
opacity: 0.5;
}
.tracker-node--court > .tracker-node-line .tracker-node-bullet {
background: #5b8def;
}
.tracker-node-name {
flex: 1;
font-weight: 500;
min-width: 8rem;
}
.tracker-node-ref {
font-size: 0.75rem;
color: var(--color-text-muted);
font-family: monospace;
}
.tracker-node-date {
font-size: 0.8rem;
color: var(--color-text-muted);
white-space: nowrap;
margin-left: auto;
}
.tracker-node-party {
display: inline-block;
min-width: 1.4rem;
text-align: center;
padding: 0.05rem 0.35rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
background: var(--color-bg-subtle);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.tracker-node-party--court {
background: rgba(91, 141, 239, 0.12);
color: #2c5fb8;
border-color: rgba(91, 141, 239, 0.3);
}
.tracker-node-party--claimant {
background: rgba(34, 197, 94, 0.10);
color: #1f7a4a;
border-color: rgba(34, 197, 94, 0.25);
}
.tracker-node-party--defendant {
background: rgba(245, 158, 11, 0.10);
color: #92500a;
border-color: rgba(245, 158, 11, 0.25);
}
.tracker-node--anchored > .tracker-node-line {
background: var(--color-bg-lime-tint);
border-left: 4px solid var(--color-accent);
padding-left: 0.5rem;
}
.tracker-node-here {
display: block;
margin: 0.2rem 0 0.2rem 0.4rem;
padding: 0.15rem 0.4rem;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
color: var(--color-accent-dark);
background: var(--color-accent);
border-radius: 3px;
width: max-content;
}
.tracker-node-pin,
.tracker-node-fokus {
border: none;
background: transparent;
cursor: pointer;
font-size: 0.85rem;
padding: 0.1rem 0.3rem;
border-radius: 3px;
opacity: 0.35;
transition: opacity 0.1s, background 0.1s;
}
.tracker-node:hover > .tracker-node-line .tracker-node-pin,
.tracker-node--anchored > .tracker-node-line .tracker-node-pin,
.tracker-node-fokus {
opacity: 1;
}
.tracker-node-pin:hover,
.tracker-node-fokus:hover {
background: var(--color-bg-lime-tint);
}
.tracker-proceeding--anchored {
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
}
.tracker-proceeding-toggle {
border: 1px solid var(--color-border-strong);
background: var(--color-bg);
color: var(--color-text-muted);
font-size: 0.8rem;
padding: 0.15rem 0.5rem;
border-radius: 4px;
cursor: pointer;
}
.tracker-proceeding-toggle:hover {
background: var(--color-bg-lime-tint);
border-color: var(--color-accent);
color: var(--color-text);
}
/* Collapsed proceeding card — header-only render (§6.5). The toggle
* shows "zeigen"; clicking expands. Body stays absent. */
.tracker-proceeding--collapsed .tracker-proceeding-header {
border-bottom: none;
}
/* Zoom breadcrumb (§6.2). One-line ancestors trail above the focused
* subtree. The anchor crumb at the right is highlighted lime so the
* user sees their current depth. */
.tracker-zoom-breadcrumb {
font-size: 0.85rem;
color: var(--color-text-muted);
padding: 0.3rem 0.5rem;
margin-bottom: 0.4rem;
border-bottom: 1px dashed var(--color-border);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem;
}
.tracker-zoom-crumb {
padding: 0.05rem 0.3rem;
}
.tracker-zoom-crumb--anchor {
background: var(--color-accent);
color: var(--color-accent-fg);
border-radius: 3px;
font-weight: 500;
}
.tracker-zoom-crumb-sep {
color: var(--color-text-subtle);
}
.tracker-zoom-hidden {
font-size: 0.8rem;
color: var(--color-text-muted);
font-style: italic;
text-align: center;
margin-top: 0.5rem;
padding-top: 0.3rem;
border-top: 1px dashed var(--color-border);
}
/* ─── Akte actuals overlay (§6.4 / T3) ─────────────────────────── */
.tracker-node-actual {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.2rem;
height: 1.2rem;
font-size: 0.85rem;
font-weight: 700;
border-radius: 3px;
padding: 0 0.2rem;
}
.tracker-node-actual--done {
background: rgba(34, 197, 94, 0.15);
color: #1f7a4a;
}
.tracker-node-actual--overdue {
background: rgba(239, 68, 68, 0.15);
color: #b91c1c;
}
.tracker-node-actual--open {
background: var(--color-bg-subtle);
color: var(--color-text-muted);
}
.tracker-node--actual-done > .tracker-node-line .tracker-node-name {
text-decoration: line-through;
text-decoration-color: rgba(31, 122, 74, 0.5);
text-decoration-thickness: 1.5px;
color: var(--color-text-muted);
}
.tracker-node--actual-overdue > .tracker-node-line {
background: rgba(239, 68, 68, 0.05);
}
/* ─── T4 polish ────────────────────────────────────────────────── */
/* Per-proceeding detail-mode toggle in the card header. Compact pill
* that flips between "· Gewählt ·" and "Alle Optionen". */
.tracker-proceeding-detail {
border: 1px solid var(--color-border-strong);
background: var(--color-bg);
color: var(--color-text-muted);
font-size: 0.75rem;
padding: 0.15rem 0.6rem;
border-radius: 4px;
cursor: pointer;
}
.tracker-proceeding-detail[aria-pressed="true"] {
background: var(--color-bg-lime-tint);
border-color: var(--color-accent);
color: var(--color-text);
}
.tracker-proceeding-detail:hover {
border-color: var(--color-accent);
color: var(--color-text);
}
/* Appeal-target chip strip on upc.apl.unified. Mirrors the per-card
* "Optionen" strip but lives below the header, separated by a thin
* border so it reads as a sub-row of the proceeding card. */
.tracker-proceeding-targets {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.9rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
font-size: 0.8rem;
}
.tracker-pill--sm {
padding: 0.1rem 0.5rem;
font-size: 0.78rem;
}
/* Cross-party row treatment (§3.6). Rows where the user is not the
* primary actor get a "Gegen." badge + muted text. The user can still
* read what the opposing side files, but it doesn't compete with
* their own actionable items. */
.tracker-node-cross {
display: inline-block;
padding: 0.05rem 0.35rem;
background: var(--color-bg-subtle);
color: var(--color-text-subtle);
font-size: 0.7rem;
border-radius: 3px;
font-weight: 500;
}
.tracker-node--cross > .tracker-node-line .tracker-node-name,
.tracker-node--cross > .tracker-node-line .tracker-node-date,
.tracker-node--cross > .tracker-node-line .tracker-node-ref {
color: var(--color-text-subtle);
}
/* Unselected rows in "all_options" view (dotted muted treatment per
* design §3.4). Same affordance for hidden-by-user rows. */
.tracker-node--unselected > .tracker-node-line,
.tracker-node--hidden > .tracker-node-line {
opacity: 0.55;
}
.tracker-node--unselected > .tracker-node-line .tracker-node-name {
font-style: italic;
}
.tracker-node--highlight > .tracker-node-line {
animation: tracker-node-flash 3s ease-out;
}
@keyframes tracker-node-flash {
0% { background: var(--color-accent); }
100% { background: transparent; }
}
@media (max-width: 600px) {
.tracker-find-axis-label {
min-width: 4rem;
}
.tracker-node-date {
margin-left: 0;
}
}

View File

@@ -0,0 +1,94 @@
-- 157_scenario_builder_foundation — down
--
-- Rolls back mig 157 in reverse order. Down files are reference material
-- (not auto-applied); operator recovery path is:
--
-- psql ... < 157_scenario_builder_foundation.down.sql
-- DELETE FROM paliad.applied_migrations WHERE version = 157;
--
-- This restores the legacy paliad.scenarios shape from mig 145 — the
-- builder columns and the three sibling tables are dropped wholesale.
-- Any builder data in the dropped tables is lost (the tables CASCADE to
-- their children, and DROP TABLE doesn't keep a backup).
BEGIN;
SELECT set_config(
'paliad.audit_reason',
'mig 157 rollback: tear down Scenario builder foundation (t-paliad-340)',
true
);
-- 8. updated_at triggers
DROP TRIGGER IF EXISTS scenario_events_touch_updated_at_trg ON paliad.scenario_events;
DROP TRIGGER IF EXISTS scenario_proceedings_touch_updated_at_trg ON paliad.scenario_proceedings;
-- 7. RLS — drop new policies + restore legacy four
DROP POLICY IF EXISTS scenario_shares_mutate ON paliad.scenario_shares;
DROP POLICY IF EXISTS scenario_shares_select ON paliad.scenario_shares;
DROP POLICY IF EXISTS scenario_events_mutate ON paliad.scenario_events;
DROP POLICY IF EXISTS scenario_events_select ON paliad.scenario_events;
DROP POLICY IF EXISTS scenario_proceedings_mutate ON paliad.scenario_proceedings;
DROP POLICY IF EXISTS scenario_proceedings_select ON paliad.scenario_proceedings;
DROP POLICY IF EXISTS scenarios_owner_mutate ON paliad.scenarios;
DROP POLICY IF EXISTS scenarios_select ON paliad.scenarios;
-- Restore the four mig-145 policies verbatim.
CREATE POLICY scenarios_project_select ON paliad.scenarios
FOR SELECT
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id));
CREATE POLICY scenarios_project_mutate ON paliad.scenarios
FOR ALL
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id))
WITH CHECK (project_id IS NOT NULL AND paliad.can_see_project(project_id));
CREATE POLICY scenarios_abstract_select ON paliad.scenarios
FOR SELECT
USING (project_id IS NULL AND created_by = auth.uid());
CREATE POLICY scenarios_abstract_mutate ON paliad.scenarios
FOR ALL
USING (project_id IS NULL AND created_by = auth.uid())
WITH CHECK (project_id IS NULL AND created_by = auth.uid());
-- 6. helper function
DROP FUNCTION IF EXISTS paliad.can_see_scenario(uuid);
-- 5. paliad.projects.origin_scenario_id
DROP INDEX IF EXISTS paliad.projects_origin_scenario_idx;
ALTER TABLE paliad.projects DROP COLUMN IF EXISTS origin_scenario_id;
-- 4. paliad.scenario_shares
DROP TABLE IF EXISTS paliad.scenario_shares;
-- 3. paliad.scenario_events
DROP TABLE IF EXISTS paliad.scenario_events;
-- 2. paliad.scenario_proceedings
DROP TABLE IF EXISTS paliad.scenario_proceedings;
-- 1. paliad.scenarios — restore mig-145 shape
DROP INDEX IF EXISTS paliad.scenarios_updated_idx;
DROP INDEX IF EXISTS paliad.scenarios_owner_status_idx;
-- Restore the unique constraint mig 145 had.
ALTER TABLE paliad.scenarios
ADD CONSTRAINT scenarios_unique_per_scope
UNIQUE NULLS NOT DISTINCT (project_id, created_by, name);
-- spec was NOT NULL in mig 145. Restore that — but only after backfilling
-- any NULL specs the builder might have created (none in legacy paths;
-- only builder rows have NULL spec, and those are dropped together with
-- the builder schema if a real rollback is needed).
UPDATE paliad.scenarios SET spec = '{}'::jsonb WHERE spec IS NULL;
ALTER TABLE paliad.scenarios ALTER COLUMN spec SET NOT NULL;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS notes;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS stichtag;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS promoted_project_id;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS origin_project_id;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS status;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS owner_id;
COMMIT;

View File

@@ -0,0 +1,500 @@
-- 157_scenario_builder_foundation — t-paliad-340 / m/paliad#153 B0
--
-- Schema foundation for the Litigation Builder (PRD
-- docs/plans/prd-procedures-litigation-planner-2026-05-27.md §5.1 + §5.2).
-- Phase B0 of the 7-slice train described in PRD §7.1. DB-only — no UI
-- depends on these tables yet; B1 wires the builder shell on top.
--
-- What this migration adds:
--
-- 1. Six new columns on paliad.scenarios for the builder shape:
-- owner_id, status, origin_project_id, promoted_project_id,
-- stichtag, notes.
-- Two relaxations on existing columns:
-- - spec NOT NULL → NULL (the builder normalises spec contents
-- into scenario_proceedings / scenario_events; new rows skip
-- spec entirely. Legacy callers from mig 145 still provide it
-- explicitly, so they keep inserting valid rows.)
-- - DROP CONSTRAINT scenarios_unique_per_scope (the builder
-- allows multiple "Unbenanntes Szenario" + multiple scratch
-- scenarios per user — uniqueness on (project_id, created_by,
-- name) blocks that. The legacy service treated the constraint
-- as UX collision avoidance, not correctness.)
--
-- 2. Three new tables for the normalised builder shape:
-- - paliad.scenario_proceedings (one row per proceeding in a
-- scenario; multi-proceeding constellations + spawned children)
-- - paliad.scenario_events (one row per event card on the
-- canvas; planned / filed / skipped state + actual_date + notes
-- + per-card optional horizon)
-- - paliad.scenario_shares (read-only team shares; owner is
-- the sole editor)
--
-- 3. One new column on paliad.projects:
-- - origin_scenario_id — audit trail for promote-to-project
-- (B5; the column lands now so the FK is in place when the
-- wizard arrives).
--
-- 4. New helper function paliad.can_see_scenario(_scenario_id) that
-- mirrors paliad.can_see_project's STABLE SECURITY DEFINER shape.
-- Visibility logic:
-- - global_admin sees everything,
-- - owner_id = auth.uid() (builder-owned scenarios),
-- - scenario_shares.shared_with_user_id = auth.uid()
-- (read-only shared scenarios),
-- - legacy project-scoped scenarios (owner_id IS NULL AND
-- project_id IS NOT NULL) follow can_see_project(project_id),
-- - legacy abstract scenarios (owner_id IS NULL AND project_id
-- IS NULL) follow created_by = auth.uid().
--
-- 5. Replacement RLS policies on paliad.scenarios that fold builder
-- visibility together with the legacy shape. The legacy
-- project_* / abstract_* policies are dropped (they covered only
-- legacy paths) and rewritten as a single pair of policies that
-- treats owner_id, scenario_shares, and the legacy paths uniformly.
--
-- Builder-only RLS for the three new tables: read = scenario
-- visibility; write = scenario owner (or legacy editor) only.
--
-- PRD §5.1 deviations called out for the reader:
--
-- - PRD specs `proceeding_type_id uuid REFERENCES paliad.proceeding_types(id)`.
-- The live column is `integer` (see paliad.proceeding_types.id);
-- scenario_proceedings.proceeding_type_id is integer here to match
-- the real FK target. PRD authors did not check the column type;
-- this migration uses the truth on disk.
--
-- - PRD references `auth.users(id)` for owner_id and share columns;
-- the established paliad convention (see paliad.projects.created_by,
-- paliad.scenarios.created_by) uses `paliad.users(id)`. Same UUIDs
-- either way (paliad.users.id == auth.users.id), but the FK targets
-- paliad.users to stay consistent with project tables.
--
-- Audit-first: all DDL ran clean against a BEGIN/ROLLBACK probe on the
-- live DB before this file was committed. paliad.scenarios has 0 rows
-- (verified pre-mig), so the column additions and constraint relaxations
-- have no data impact.
BEGIN;
SELECT set_config(
'paliad.audit_reason',
'mig 157: Scenario builder foundation (t-paliad-340 / m/paliad#153 B0)',
true
);
-- ----------------------------------------------------------------
-- 1. paliad.scenarios — additive columns + constraint relaxations
-- ----------------------------------------------------------------
ALTER TABLE paliad.scenarios
ADD COLUMN owner_id uuid NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
ADD COLUMN status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','archived','promoted')),
ADD COLUMN origin_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
ADD COLUMN promoted_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
ADD COLUMN stichtag date NULL,
ADD COLUMN notes text NULL;
ALTER TABLE paliad.scenarios ALTER COLUMN spec DROP NOT NULL;
ALTER TABLE paliad.scenarios DROP CONSTRAINT IF EXISTS scenarios_unique_per_scope;
CREATE INDEX scenarios_owner_status_idx
ON paliad.scenarios(owner_id, status)
WHERE owner_id IS NOT NULL;
CREATE INDEX scenarios_updated_idx
ON paliad.scenarios(owner_id, updated_at DESC)
WHERE owner_id IS NOT NULL;
COMMENT ON COLUMN paliad.scenarios.owner_id IS
'Litigation Builder owner (PRD §5.1). NULL = legacy composition-spec '
'scenario from m/paliad#124 Slice D (mig 145). Builder rows MUST have '
'owner_id set; the application enforces it via ScenarioBuilderService.';
COMMENT ON COLUMN paliad.scenarios.status IS
'Lifecycle: active (default; user-editable) / archived (soft-deleted, '
'still visible in side panel) / promoted (converted to project via '
'B5 wizard; read-only). Legacy mig-145 rows default to active.';
COMMENT ON COLUMN paliad.scenarios.origin_project_id IS
'Set when the scenario was exported from an existing project '
'("Im Builder öffnen" — Akte mode, PRD §2.3).';
COMMENT ON COLUMN paliad.scenarios.promoted_project_id IS
'Set after the scenario was promoted to a real project via the 3-step '
'wizard (PRD §5.4). Together with paliad.projects.origin_scenario_id, '
'forms the bidirectional audit link.';
COMMENT ON COLUMN paliad.scenarios.stichtag IS
'Scenario-level default Stichtag; per-proceeding overrides in '
'paliad.scenario_proceedings.stichtag take precedence.';
-- ----------------------------------------------------------------
-- 2. paliad.scenario_proceedings — one proceeding per scenario row
-- ----------------------------------------------------------------
CREATE TABLE paliad.scenario_proceedings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_id uuid NOT NULL
REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
proceeding_type_id integer NOT NULL
REFERENCES paliad.proceeding_types(id),
primary_party text NULL
CHECK (primary_party IN ('claimant','defendant')),
scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb
CHECK (jsonb_typeof(scenario_flags) = 'object'),
parent_scenario_proceeding_id uuid NULL
REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
spawn_anchor_event_id uuid NULL
REFERENCES paliad.sequencing_rules(id),
ordinal int NOT NULL DEFAULT 0,
stichtag date NULL,
detailgrad text NOT NULL DEFAULT 'selected'
CHECK (detailgrad IN ('selected','all_options')),
appeal_target text NULL,
collapsed boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX scenario_proceedings_scenario_idx
ON paliad.scenario_proceedings(scenario_id, ordinal);
CREATE INDEX scenario_proceedings_parent_idx
ON paliad.scenario_proceedings(parent_scenario_proceeding_id)
WHERE parent_scenario_proceeding_id IS NOT NULL;
COMMENT ON TABLE paliad.scenario_proceedings IS
'One proceeding inside a Litigation Builder scenario. Multiple rows '
'per scenario for multi-proceeding constellations. '
'parent_scenario_proceeding_id self-refs for spawned children '
'(e.g. upc.ccr.cfi spawned by with_ccr on upc.inf.cfi). '
'PRD §5.1, m/paliad#153 B0.';
COMMENT ON COLUMN paliad.scenario_proceedings.primary_party IS
'Per-proceeding perspective ("our side"). NULL = no perspective '
'picked yet (both party columns render with natural labels). '
'Per-proceeding so multi-jurisdiction constellations can flip side '
'independently (PRD §3.3).';
COMMENT ON COLUMN paliad.scenario_proceedings.scenario_flags IS
'Per-proceeding flags (e.g. {"with_ccr": true, "with_amend": false}). '
'Mirrors paliad.projects.scenario_flags shape but lives per-proceeding-'
'per-scenario. Validated by the application against '
'paliad.scenario_flag_catalog at write time.';
COMMENT ON COLUMN paliad.scenario_proceedings.spawn_anchor_event_id IS
'Which sequencing_rule of the parent proceeding caused this spawn. '
'NULL for root proceedings. Used by the UI to place the spawned child '
'triplet directly below the parent at the spawn node (PRD §3.6).';
COMMENT ON COLUMN paliad.scenario_proceedings.ordinal IS
'Stack order on canvas (top to bottom). Siblings under the same '
'parent (or top-level) are ordered by ordinal asc, then created_at.';
COMMENT ON COLUMN paliad.scenario_proceedings.detailgrad IS
'Per-proceeding optional-detail toggle: selected (only explicitly '
'chosen optionals + mandatories) or all_options (every optional '
'sequencing_rule surfaces). Matches today''s Verfahrensablauf pattern.';
-- ----------------------------------------------------------------
-- 3. paliad.scenario_events — one event card on the canvas
-- ----------------------------------------------------------------
CREATE TABLE paliad.scenario_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_proceeding_id uuid NOT NULL
REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
sequencing_rule_id uuid NULL
REFERENCES paliad.sequencing_rules(id),
procedural_event_id uuid NULL
REFERENCES paliad.procedural_events(id),
custom_label text NULL,
state text NOT NULL DEFAULT 'planned'
CHECK (state IN ('planned','filed','skipped')),
actual_date date NULL,
skip_reason text NULL,
notes text NULL,
horizon_optional int NOT NULL DEFAULT 0
CHECK (horizon_optional >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT scenario_events_one_anchor CHECK (
(sequencing_rule_id IS NOT NULL)::int +
(procedural_event_id IS NOT NULL)::int +
(custom_label IS NOT NULL)::int >= 1
)
);
CREATE INDEX scenario_events_proceeding_idx
ON paliad.scenario_events(scenario_proceeding_id);
-- A single proceeding can't carry two cards for the same sequencing rule
-- (each rule maps to one card). Free-form / procedural_event-only cards
-- skip this uniqueness — multiple custom cards per proceeding are OK.
CREATE UNIQUE INDEX scenario_events_rule_uniq_idx
ON paliad.scenario_events(scenario_proceeding_id, sequencing_rule_id)
WHERE sequencing_rule_id IS NOT NULL;
COMMENT ON TABLE paliad.scenario_events IS
'One event card on the Litigation Builder canvas. Captures state '
'(planned/filed/skipped), actual_date, notes, skip_reason, and the '
'per-card optional-horizon setting. At least one of '
'(sequencing_rule_id, procedural_event_id, custom_label) must be '
'set — sequencing-rule-backed cards are the common case; free-form '
'cards exist for events the catalog doesn''t cover yet. '
'PRD §3.4 / §5.1.';
COMMENT ON COLUMN paliad.scenario_events.state IS
'3-state machine: planned (default, future event with computed date) '
'/ filed (past event, actual_date set) / skipped (user chose not to '
'file; optional skip_reason). No "overdue" enum — that''s derived '
'(date < today AND state=planned), not stored. PRD Q10 / §3.4.';
COMMENT ON COLUMN paliad.scenario_events.actual_date IS
'Set when state=filed (real-world filing date) OR when state=planned '
'and the user overrode the computed date (court-set events, manual '
'tweaks). NULL when the computed date is canonical.';
COMMENT ON COLUMN paliad.scenario_events.horizon_optional IS
'Per-card "show N more optional follow-ups" affordance. Default 0 '
'(hidden). PRD Q4 / §3.4.';
-- ----------------------------------------------------------------
-- 4. paliad.scenario_shares — read-only team shares
-- ----------------------------------------------------------------
CREATE TABLE paliad.scenario_shares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_id uuid NOT NULL
REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
shared_with_user_id uuid NOT NULL
REFERENCES paliad.users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid NOT NULL REFERENCES paliad.users(id),
UNIQUE (scenario_id, shared_with_user_id)
);
CREATE INDEX scenario_shares_user_idx
ON paliad.scenario_shares(shared_with_user_id);
COMMENT ON TABLE paliad.scenario_shares IS
'Read-only team shares for Litigation Builder scenarios. Owner '
'(paliad.scenarios.owner_id) is the sole editor; rows here grant '
'view-only access to other paliad users. PRD Q12 / §5.1.';
-- ----------------------------------------------------------------
-- 5. paliad.projects.origin_scenario_id — promote-to-project trail
-- ----------------------------------------------------------------
ALTER TABLE paliad.projects
ADD COLUMN origin_scenario_id uuid NULL
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
CREATE INDEX projects_origin_scenario_idx
ON paliad.projects(origin_scenario_id)
WHERE origin_scenario_id IS NOT NULL;
COMMENT ON COLUMN paliad.projects.origin_scenario_id IS
'FK to the scenario this project was promoted from (B5 wizard). '
'NULL = project was created directly, not via Builder. Together with '
'paliad.scenarios.promoted_project_id, forms the bidirectional audit '
'link. PRD §5.2.';
-- ----------------------------------------------------------------
-- 6. paliad.can_see_scenario — visibility helper
-- ----------------------------------------------------------------
CREATE OR REPLACE FUNCTION paliad.can_see_scenario(_scenario_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'paliad', 'public'
AS $func$
SELECT EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
OR EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = _scenario_id AND s.owner_id = auth.uid()
)
OR EXISTS (
SELECT 1 FROM paliad.scenario_shares sh
WHERE sh.scenario_id = _scenario_id
AND sh.shared_with_user_id = auth.uid()
)
-- Legacy project-scoped scenarios (mig 145) — visible via project
-- team membership.
OR EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = _scenario_id
AND s.owner_id IS NULL
AND s.project_id IS NOT NULL
AND paliad.can_see_project(s.project_id)
)
-- Legacy abstract scenarios (mig 145) — owner-only via created_by.
OR EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = _scenario_id
AND s.owner_id IS NULL
AND s.project_id IS NULL
AND s.created_by = auth.uid()
);
$func$;
COMMENT ON FUNCTION paliad.can_see_scenario(uuid) IS
'Returns true if the caller (auth.uid()) can see the given scenario. '
'Mirrors paliad.can_see_project. Covers builder-owned scenarios '
'(owner_id), read-only shares (scenario_shares), and the two legacy '
'paths from mig 145 (project-scoped via can_see_project, abstract '
'via created_by). Used by RLS on all four scenario_* tables.';
-- ----------------------------------------------------------------
-- 7. RLS — replace legacy scenarios policies + new tables
-- ----------------------------------------------------------------
-- Replace mig-145's four policies with a single pair that handles
-- builder + legacy shapes together.
DROP POLICY IF EXISTS scenarios_project_select ON paliad.scenarios;
DROP POLICY IF EXISTS scenarios_project_mutate ON paliad.scenarios;
DROP POLICY IF EXISTS scenarios_abstract_select ON paliad.scenarios;
DROP POLICY IF EXISTS scenarios_abstract_mutate ON paliad.scenarios;
CREATE POLICY scenarios_select ON paliad.scenarios
FOR SELECT USING (paliad.can_see_scenario(id));
-- Write rule: builder owner, legacy project team member (if no owner),
-- or legacy abstract creator (if no owner + no project). Shares are
-- read-only — they don't grant mutate.
CREATE POLICY scenarios_owner_mutate ON paliad.scenarios
FOR ALL
USING (
owner_id = auth.uid()
OR (owner_id IS NULL AND project_id IS NOT NULL AND paliad.can_see_project(project_id))
OR (owner_id IS NULL AND project_id IS NULL AND created_by = auth.uid())
)
WITH CHECK (
owner_id = auth.uid()
OR (owner_id IS NULL AND project_id IS NOT NULL AND paliad.can_see_project(project_id))
OR (owner_id IS NULL AND project_id IS NULL AND created_by = auth.uid())
);
-- scenario_proceedings — visibility piggybacks on the parent scenario.
ALTER TABLE paliad.scenario_proceedings ENABLE ROW LEVEL SECURITY;
CREATE POLICY scenario_proceedings_select ON paliad.scenario_proceedings
FOR SELECT USING (paliad.can_see_scenario(scenario_id));
CREATE POLICY scenario_proceedings_mutate ON paliad.scenario_proceedings
FOR ALL
USING (EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
))
WITH CHECK (EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
));
-- scenario_events — visibility piggybacks on the parent scenario via
-- the proceeding row.
ALTER TABLE paliad.scenario_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY scenario_events_select ON paliad.scenario_events
FOR SELECT
USING (EXISTS (
SELECT 1 FROM paliad.scenario_proceedings sp
WHERE sp.id = scenario_proceeding_id
AND paliad.can_see_scenario(sp.scenario_id)
));
CREATE POLICY scenario_events_mutate ON paliad.scenario_events
FOR ALL
USING (EXISTS (
SELECT 1 FROM paliad.scenario_proceedings sp
JOIN paliad.scenarios s ON s.id = sp.scenario_id
WHERE sp.id = scenario_proceeding_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
))
WITH CHECK (EXISTS (
SELECT 1 FROM paliad.scenario_proceedings sp
JOIN paliad.scenarios s ON s.id = sp.scenario_id
WHERE sp.id = scenario_proceeding_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
));
-- scenario_shares — recipient can see their share rows; the scenario
-- owner (or legacy editor) can manage them.
ALTER TABLE paliad.scenario_shares ENABLE ROW LEVEL SECURITY;
CREATE POLICY scenario_shares_select ON paliad.scenario_shares
FOR SELECT
USING (
shared_with_user_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
)
);
CREATE POLICY scenario_shares_mutate ON paliad.scenario_shares
FOR ALL
USING (EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
))
WITH CHECK (EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
));
-- ----------------------------------------------------------------
-- 8. updated_at triggers on the new tables (reuse the function mig 145
-- already created for paliad.scenarios).
-- ----------------------------------------------------------------
CREATE TRIGGER scenario_proceedings_touch_updated_at_trg
BEFORE UPDATE ON paliad.scenario_proceedings
FOR EACH ROW
EXECUTE FUNCTION paliad.scenarios_touch_updated_at();
CREATE TRIGGER scenario_events_touch_updated_at_trg
BEFORE UPDATE ON paliad.scenario_events
FOR EACH ROW
EXECUTE FUNCTION paliad.scenarios_touch_updated_at();
-- ----------------------------------------------------------------
-- 9. Informational NOTICE.
-- ----------------------------------------------------------------
DO $$
BEGIN
RAISE NOTICE '[mig 157] paliad.scenarios extended with builder columns (0 legacy rows affected)';
RAISE NOTICE '[mig 157] paliad.scenario_proceedings created';
RAISE NOTICE '[mig 157] paliad.scenario_events created';
RAISE NOTICE '[mig 157] paliad.scenario_shares created';
RAISE NOTICE '[mig 157] paliad.projects.origin_scenario_id added';
RAISE NOTICE '[mig 157] paliad.can_see_scenario(uuid) created';
END $$;
COMMIT;

View File

@@ -142,6 +142,12 @@ type Services struct {
// and per-rule selection state (`rule:<uuid>` keys).
ScenarioFlags *services.ScenarioFlagsService
// t-paliad-340 / m/paliad#153 B0 — Litigation Builder. CRUD over the
// new normalised scenario shape (paliad.scenarios with owner_id +
// scenario_proceedings + scenario_events + scenario_shares, mig 157).
// Nil when DATABASE_URL is unset — /api/builder/scenarios* routes 503.
ScenarioBuilder *services.ScenarioBuilderService
// Paliadin is wired when DATABASE_URL is set. The concrete backend
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
// (remote → mRiver via SSH) or local tmux availability. Stays nil
@@ -212,6 +218,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
eventChoice: svc.EventChoice,
scenario: svc.Scenario,
scenarioFlags: svc.ScenarioFlags,
scenarioBuilder: svc.ScenarioBuilder,
}
}
@@ -514,6 +521,25 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("DELETE /api/scenarios/{id}", handleScenarioDelete)
protected.HandleFunc("PUT /api/projects/{id}/active-scenario", handleSetActiveScenario)
// t-paliad-340 / m/paliad#153 B0 — Litigation Builder API over the
// new normalised scenario shape (mig 157). Coexists with the legacy
// /api/scenarios surface during the B0→B6 migration; B6 cleanup
// retires the legacy routes.
protected.HandleFunc("GET /api/builder/scenarios", handleBuilderScenariosList)
protected.HandleFunc("POST /api/builder/scenarios", handleBuilderScenarioCreate)
protected.HandleFunc("GET /api/builder/scenarios/{id}", handleBuilderScenarioGet)
protected.HandleFunc("PATCH /api/builder/scenarios/{id}", handleBuilderScenarioPatch)
protected.HandleFunc("POST /api/builder/scenarios/{id}/proceedings", handleBuilderProceedingCreate)
protected.HandleFunc("PATCH /api/builder/scenarios/{id}/proceedings/{pid}", handleBuilderProceedingPatch)
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/proceedings/{pid}", handleBuilderProceedingDelete)
protected.HandleFunc("POST /api/builder/scenarios/{id}/proceedings/{pid}/events", handleBuilderEventCreate)
protected.HandleFunc("PATCH /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventPatch)
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventDelete)
protected.HandleFunc("POST /api/builder/scenarios/{id}/shares", handleBuilderShareCreate)
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/shares/{sid}", handleBuilderShareDelete)
// Dev-only test route — gated to PaliadinOwnerEmail (m).
protected.HandleFunc("GET /dev/scenario-builder", handleBuilderDevTestPage)
// Partner units (structural partner-led units; legacy "Dezernate").
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit)

View File

@@ -85,6 +85,11 @@ type dbServices struct {
// m/paliad#149 Phase 2 P0 — per-project scenario_flags SSoT (mig 154).
scenarioFlags *services.ScenarioFlagsService
// t-paliad-340 / m/paliad#153 B0 — Litigation Builder over the new
// normalised scenario shape (paliad.scenarios with owner_id +
// scenario_proceedings + scenario_events + scenario_shares, mig 157).
scenarioBuilder *services.ScenarioBuilderService
}
var dbSvc *dbServices

View File

@@ -0,0 +1,589 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// t-paliad-340 / m/paliad#153 B0 — REST endpoints over the new normalised
// scenario builder shape (paliad.scenarios with owner_id, +
// paliad.scenario_proceedings / scenario_events / scenario_shares).
//
// Endpoints live under /api/builder/scenarios/* to avoid clashing with
// the legacy /api/scenarios/* endpoints from m/paliad#124 Slice D. The
// B6 cleanup slice retires the legacy surface; until then both shapes
// coexist on the same paliad.scenarios table (the legacy paths require
// project_id IS NOT NULL OR an abstract created_by = caller; the builder
// paths require owner_id = caller).
//
// All handlers gate by requireScenarioBuilderService — 503 when the
// service is nil (DATABASE_URL unset). Auth is checked via requireUser;
// per-row visibility is enforced inside the service.
func requireScenarioBuilderService(w http.ResponseWriter) bool {
if dbSvc == nil || dbSvc.scenarioBuilder == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Litigation-Builder ist vorübergehend nicht verfügbar (keine Datenbank).",
})
return false
}
return true
}
// scenarioBuilderErrorToStatus maps service errors to HTTP statuses.
func scenarioBuilderErrorToStatus(err error) (int, string) {
switch {
case errors.Is(err, services.ErrScenarioBuilderNotVisible),
errors.Is(err, services.ErrNotVisible):
return http.StatusNotFound, "Szenario nicht gefunden"
case errors.Is(err, services.ErrInvalidInput):
return http.StatusBadRequest, err.Error()
}
return http.StatusInternalServerError, err.Error()
}
func writeBuilderError(w http.ResponseWriter, err error) {
status, msg := scenarioBuilderErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
}
// ---------------------------------------------------------------------------
// Scenario CRUD
// ---------------------------------------------------------------------------
// handleBuilderScenariosList — GET /api/builder/scenarios?status=<active|archived|promoted|all>
func handleBuilderScenariosList(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
status := r.URL.Query().Get("status")
out, err := dbSvc.scenarioBuilder.ListMyScenarios(r.Context(), uid, status)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderScenarioCreate — POST /api/builder/scenarios
func handleBuilderScenarioCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.CreateBuilderScenarioInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.CreateScenario(r.Context(), uid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderScenarioGet — GET /api/builder/scenarios/{id}
func handleBuilderScenarioGet(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
out, err := dbSvc.scenarioBuilder.GetScenarioDeep(r.Context(), uid, id)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderScenarioPatch — PATCH /api/builder/scenarios/{id}
func handleBuilderScenarioPatch(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
var input services.PatchBuilderScenarioInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.PatchScenario(r.Context(), uid, id, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// ---------------------------------------------------------------------------
// Proceedings
// ---------------------------------------------------------------------------
// handleBuilderProceedingCreate — POST /api/builder/scenarios/{id}/proceedings
func handleBuilderProceedingCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
var input services.AddProceedingInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.AddProceeding(r.Context(), uid, sid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderProceedingPatch — PATCH /api/builder/scenarios/{id}/proceedings/{pid}
func handleBuilderProceedingPatch(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
pid, err := uuid.Parse(r.PathValue("pid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
return
}
var input services.PatchProceedingInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.PatchProceeding(r.Context(), uid, sid, pid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderProceedingDelete — DELETE /api/builder/scenarios/{id}/proceedings/{pid}
func handleBuilderProceedingDelete(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
pid, err := uuid.Parse(r.PathValue("pid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
return
}
if err := dbSvc.scenarioBuilder.DeleteProceeding(r.Context(), uid, sid, pid); err != nil {
writeBuilderError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Events
// ---------------------------------------------------------------------------
// handleBuilderEventCreate — POST /api/builder/scenarios/{id}/proceedings/{pid}/events
func handleBuilderEventCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
pid, err := uuid.Parse(r.PathValue("pid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
return
}
var input services.AddEventInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.AddEvent(r.Context(), uid, sid, pid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderEventPatch — PATCH /api/builder/scenarios/{id}/events/{eid}
func handleBuilderEventPatch(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
eid, err := uuid.Parse(r.PathValue("eid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Event-ID"})
return
}
var input services.PatchEventInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.PatchEvent(r.Context(), uid, sid, eid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderEventDelete — DELETE /api/builder/scenarios/{id}/events/{eid}
func handleBuilderEventDelete(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
eid, err := uuid.Parse(r.PathValue("eid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Event-ID"})
return
}
if err := dbSvc.scenarioBuilder.DeleteEvent(r.Context(), uid, sid, eid); err != nil {
writeBuilderError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Shares
// ---------------------------------------------------------------------------
// handleBuilderShareCreate — POST /api/builder/scenarios/{id}/shares
// Body: {"shared_with_user_id": "<uuid>"}
func handleBuilderShareCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
var body struct {
SharedWithUserID uuid.UUID `json:"shared_with_user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.AddShare(r.Context(), uid, sid, body.SharedWithUserID)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderShareDelete — DELETE /api/builder/scenarios/{id}/shares/{sid}
func handleBuilderShareDelete(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
scid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
shid, err := uuid.Parse(r.PathValue("sid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Share-ID"})
return
}
if err := dbSvc.scenarioBuilder.DeleteShare(r.Context(), uid, scid, shid); err != nil {
writeBuilderError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Dev-only test route
// ---------------------------------------------------------------------------
// handleBuilderDevTestPage — GET /dev/scenario-builder
//
// Gated to services.PaliadinOwnerEmail (the same single-owner gate the
// /paliadin route uses). Every other authenticated user gets 404. Pure
// HTML — no JS bundle — so the page works even before B1 wires the real
// builder shell. Renders curl-equivalent forms for the B0 surface so the
// schema can be exercised end-to-end without Postman / shell scripts.
//
// This is the "dev-only test route" the head's task spec asked for. It
// disappears in B6 cleanup once the production builder UI ships at
// /tools/procedures.
func handleBuilderDevTestPage(w http.ResponseWriter, r *http.Request) {
if !requirePaliadinOwner(w, r) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(builderDevTestHTML))
}
const builderDevTestHTML = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Scenario Builder — Dev Test (B0)</title>
<style>
body { font-family: ui-monospace, Menlo, monospace; max-width: 880px; margin: 2em auto;
padding: 0 1em; color: #222; background: #fafaf7; }
h1, h2 { font-family: ui-sans-serif, system-ui, sans-serif; }
h1 { border-bottom: 4px solid #c6f41c; padding-bottom: .2em; }
section { background: #fff; border: 1px solid #ddd; border-radius: 4px;
padding: 1em 1.2em; margin: 1em 0; }
label { display: block; margin: .4em 0 .15em; font-size: .85em; color: #555; }
input, textarea, select, button { font: inherit; padding: .35em .5em; box-sizing: border-box; }
input[type="text"], input[type="number"], textarea { width: 100%; }
button { background: #c6f41c; border: 1px solid #9ec61f; cursor: pointer;
padding: .4em 1em; border-radius: 3px; margin: .2em 0; }
button.secondary { background: #eee; border-color: #ccc; }
pre.out { background: #1e1e1e; color: #e6e6e6; padding: .8em 1em; border-radius: 4px;
overflow: auto; max-height: 30em; font-size: .85em; }
.note { color: #777; font-size: .9em; }
.row { display: flex; gap: .5em; }
.row > * { flex: 1; }
</style>
</head>
<body>
<h1>Scenario Builder — Dev Test (B0)</h1>
<p class="note">t-paliad-340 / m/paliad#153 — DB-only slice. Exercises
paliad.scenarios (builder rows), scenario_proceedings, scenario_events,
scenario_shares via /api/builder/scenarios/*. Gated to PaliadinOwnerEmail.</p>
<section>
<h2>1. Liste meine Szenarien</h2>
<label>Status filter</label>
<select id="list-status">
<option value="">(default: alle)</option>
<option value="active">active</option>
<option value="archived">archived</option>
<option value="promoted">promoted</option>
<option value="all">all (explicit)</option>
</select>
<button onclick="listScenarios()">GET /api/builder/scenarios</button>
<pre class="out" id="list-out"></pre>
</section>
<section>
<h2>2. Szenario anlegen</h2>
<label>Name</label>
<input type="text" id="create-name" placeholder="(leer = Unbenanntes Szenario)">
<label>Notes (optional)</label>
<textarea id="create-notes" rows="2"></textarea>
<button onclick="createScenario()">POST /api/builder/scenarios</button>
<pre class="out" id="create-out"></pre>
</section>
<section>
<h2>3. Szenario abrufen (deep)</h2>
<label>Scenario ID</label>
<input type="text" id="get-id">
<button onclick="getScenario()">GET /api/builder/scenarios/{id}</button>
<pre class="out" id="get-out"></pre>
</section>
<section>
<h2>4. Verfahren hinzufügen</h2>
<label>Scenario ID</label>
<input type="text" id="proc-sid">
<label>proceeding_type_id (integer)</label>
<input type="number" id="proc-pt-id" placeholder="z.B. 7 für upc.inf.cfi">
<label>primary_party</label>
<select id="proc-party">
<option value="">(none)</option>
<option value="claimant">claimant</option>
<option value="defendant">defendant</option>
</select>
<button onclick="addProceeding()">POST .../proceedings</button>
<pre class="out" id="proc-out"></pre>
</section>
<section>
<h2>5. Event-Karte hinzufügen</h2>
<label>Scenario ID</label>
<input type="text" id="ev-sid">
<label>Proceeding ID</label>
<input type="text" id="ev-pid">
<label>custom_label (oder sequencing_rule_id / procedural_event_id)</label>
<input type="text" id="ev-label" placeholder="freitext-Karte">
<label>state</label>
<select id="ev-state">
<option value="planned">planned</option>
<option value="filed">filed</option>
<option value="skipped">skipped</option>
</select>
<button onclick="addEvent()">POST .../proceedings/{pid}/events</button>
<pre class="out" id="ev-out"></pre>
</section>
<section>
<h2>6. Status patchen (archive / restore)</h2>
<label>Scenario ID</label>
<input type="text" id="patch-sid">
<label>new status</label>
<select id="patch-status">
<option value="active">active</option>
<option value="archived">archived</option>
</select>
<button onclick="patchStatus()">PATCH /api/builder/scenarios/{id}</button>
<pre class="out" id="patch-out"></pre>
</section>
<script>
const j = (id, payload) =>
document.getElementById(id).textContent = JSON.stringify(payload, null, 2);
async function call(method, url, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
const text = await r.text();
let parsed = text;
try { parsed = JSON.parse(text); } catch (_) {}
return { status: r.status, body: parsed };
}
async function listScenarios() {
const status = document.getElementById('list-status').value;
const q = status ? '?status=' + encodeURIComponent(status) : '';
j('list-out', await call('GET', '/api/builder/scenarios' + q));
}
async function createScenario() {
const name = document.getElementById('create-name').value;
const notes = document.getElementById('create-notes').value;
const body = {};
if (name) body.name = name;
if (notes) body.notes = notes;
j('create-out', await call('POST', '/api/builder/scenarios', body));
}
async function getScenario() {
const id = document.getElementById('get-id').value.trim();
if (!id) return j('get-out', { error: 'ID erforderlich' });
j('get-out', await call('GET', '/api/builder/scenarios/' + id));
}
async function addProceeding() {
const sid = document.getElementById('proc-sid').value.trim();
const ptID = parseInt(document.getElementById('proc-pt-id').value, 10);
const party = document.getElementById('proc-party').value;
if (!sid || !ptID) return j('proc-out', { error: 'sid + proceeding_type_id erforderlich' });
const body = { proceeding_type_id: ptID };
if (party) body.primary_party = party;
j('proc-out', await call('POST', '/api/builder/scenarios/' + sid + '/proceedings', body));
}
async function addEvent() {
const sid = document.getElementById('ev-sid').value.trim();
const pid = document.getElementById('ev-pid').value.trim();
const label = document.getElementById('ev-label').value.trim();
const state = document.getElementById('ev-state').value;
if (!sid || !pid || !label) return j('ev-out', { error: 'sid + pid + custom_label erforderlich' });
j('ev-out', await call('POST',
'/api/builder/scenarios/' + sid + '/proceedings/' + pid + '/events',
{ custom_label: label, state }));
}
async function patchStatus() {
const sid = document.getElementById('patch-sid').value.trim();
const status = document.getElementById('patch-status').value;
if (!sid) return j('patch-out', { error: 'sid erforderlich' });
j('patch-out', await call('PATCH', '/api/builder/scenarios/' + sid, { status }));
}
</script>
</body>
</html>`

View File

@@ -0,0 +1,936 @@
package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
)
// ScenarioBuilderService owns the t-paliad-340 / m/paliad#153 B0 surface
// — CRUD over the new normalised builder shape (paliad.scenarios with
// owner_id + status, paliad.scenario_proceedings, paliad.scenario_events,
// paliad.scenario_shares). The legacy spec-jsonb service
// (ScenarioService) keeps serving m/paliad#124 Slice D callers; this
// service strictly handles builder-owned rows (owner_id IS NOT NULL).
//
// Visibility is enforced both in code (the owner / share / can_see_project
// fall-through) and at the row level via the migration-157 RLS policies.
// The application-level check is the load-bearing one — the service
// connects with the service-role credential, which bypasses RLS.
type ScenarioBuilderService struct {
db *sqlx.DB
}
// NewScenarioBuilderService wires the service to the shared pool.
func NewScenarioBuilderService(db *sqlx.DB) *ScenarioBuilderService {
return &ScenarioBuilderService{db: db}
}
// ErrScenarioBuilderNotVisible is returned when the caller is neither
// owner, an accepted share recipient, nor a global_admin / legacy
// editor for the scenario.
var ErrScenarioBuilderNotVisible = errors.New("scenario not visible to caller")
// -----------------------------------------------------------------------------
// Row types — flat shapes matching the table columns. Deep tree (scenario +
// proceedings + events) is composed at the GET-by-id endpoint.
// -----------------------------------------------------------------------------
// BuilderScenario is one paliad.scenarios row from the builder's perspective.
// Legacy columns (project_id, description, spec, created_by) are still
// returned so a UI can detect a legacy row and refuse to mutate it.
type BuilderScenario struct {
ID uuid.UUID `db:"id" json:"id"`
OwnerID *uuid.UUID `db:"owner_id" json:"owner_id,omitempty"`
Name string `db:"name" json:"name"`
Status string `db:"status" json:"status"`
OriginProjectID *uuid.UUID `db:"origin_project_id" json:"origin_project_id,omitempty"`
PromotedProjectID *uuid.UUID `db:"promoted_project_id" json:"promoted_project_id,omitempty"`
Stichtag *time.Time `db:"stichtag" json:"stichtag,omitempty"`
Notes *string `db:"notes" json:"notes,omitempty"`
LegacyProjectID *uuid.UUID `db:"project_id" json:"legacy_project_id,omitempty"`
LegacyDescription *string `db:"description" json:"legacy_description,omitempty"`
LegacyCreatedBy *uuid.UUID `db:"created_by" json:"legacy_created_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// BuilderProceeding is one paliad.scenario_proceedings row.
type BuilderProceeding struct {
ID uuid.UUID `db:"id" json:"id"`
ScenarioID uuid.UUID `db:"scenario_id" json:"scenario_id"`
ProceedingTypeID int `db:"proceeding_type_id" json:"proceeding_type_id"`
PrimaryParty *string `db:"primary_party" json:"primary_party,omitempty"`
ScenarioFlags json.RawMessage `db:"scenario_flags" json:"scenario_flags"`
ParentScenarioProceedingID *uuid.UUID `db:"parent_scenario_proceeding_id" json:"parent_scenario_proceeding_id,omitempty"`
SpawnAnchorEventID *uuid.UUID `db:"spawn_anchor_event_id" json:"spawn_anchor_event_id,omitempty"`
Ordinal int `db:"ordinal" json:"ordinal"`
Stichtag *time.Time `db:"stichtag" json:"stichtag,omitempty"`
Detailgrad string `db:"detailgrad" json:"detailgrad"`
AppealTarget *string `db:"appeal_target" json:"appeal_target,omitempty"`
Collapsed bool `db:"collapsed" json:"collapsed"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// BuilderEvent is one paliad.scenario_events row.
type BuilderEvent struct {
ID uuid.UUID `db:"id" json:"id"`
ScenarioProceedingID uuid.UUID `db:"scenario_proceeding_id" json:"scenario_proceeding_id"`
SequencingRuleID *uuid.UUID `db:"sequencing_rule_id" json:"sequencing_rule_id,omitempty"`
ProceduralEventID *uuid.UUID `db:"procedural_event_id" json:"procedural_event_id,omitempty"`
CustomLabel *string `db:"custom_label" json:"custom_label,omitempty"`
State string `db:"state" json:"state"`
ActualDate *time.Time `db:"actual_date" json:"actual_date,omitempty"`
SkipReason *string `db:"skip_reason" json:"skip_reason,omitempty"`
Notes *string `db:"notes" json:"notes,omitempty"`
HorizonOptional int `db:"horizon_optional" json:"horizon_optional"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// BuilderShare is one paliad.scenario_shares row.
type BuilderShare struct {
ID uuid.UUID `db:"id" json:"id"`
ScenarioID uuid.UUID `db:"scenario_id" json:"scenario_id"`
SharedWithUserID uuid.UUID `db:"shared_with_user_id" json:"shared_with_user_id"`
CreatedBy uuid.UUID `db:"created_by" json:"created_by"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
}
// BuilderScenarioDeep bundles a scenario with its proceedings + events
// for the GET /api/builder/scenarios/{id} response. Proceedings sort by
// ordinal asc; events sort by created_at asc within a proceeding.
type BuilderScenarioDeep struct {
BuilderScenario
Proceedings []BuilderProceeding `json:"proceedings"`
Events []BuilderEvent `json:"events"`
Shares []BuilderShare `json:"shares"`
}
// -----------------------------------------------------------------------------
// Scenario CRUD
// -----------------------------------------------------------------------------
// CreateBuilderScenarioInput is the POST /api/builder/scenarios body.
// Name defaults to "Unbenanntes Szenario" when blank (PRD §5.1).
type CreateBuilderScenarioInput struct {
Name string `json:"name,omitempty"`
Stichtag *time.Time `json:"stichtag,omitempty"`
Notes *string `json:"notes,omitempty"`
OriginProjectID *uuid.UUID `json:"origin_project_id,omitempty"`
}
// CreateScenario inserts a new builder-owned scenario. owner_id is set to
// the caller; status defaults to 'active'. Audit reason is set inside the
// write tx so any future audit trigger picks it up.
func (s *ScenarioBuilderService) CreateScenario(ctx context.Context, userID uuid.UUID, input CreateBuilderScenarioInput) (*BuilderScenario, error) {
name := strings.TrimSpace(input.Name)
if name == "" {
name = "Unbenanntes Szenario"
}
var out BuilderScenario
err := s.withAuditTx(ctx, "scenario_builder: create scenario", func(tx *sqlx.Tx) error {
return tx.GetContext(ctx, &out,
`INSERT INTO paliad.scenarios
(owner_id, name, status, stichtag, notes, origin_project_id)
VALUES ($1, $2, 'active', $3, $4, $5)
RETURNING id, owner_id, name, status, origin_project_id, promoted_project_id,
stichtag, notes,
project_id, description, created_by,
created_at, updated_at`,
userID, name, input.Stichtag, input.Notes, input.OriginProjectID)
})
if err != nil {
return nil, fmt.Errorf("create builder scenario: %w", err)
}
return &out, nil
}
// ListMyScenarios returns the caller's owned scenarios filtered by status.
// Status "" (or "all") returns every status; otherwise filters by the
// given enum value. Sorted by updated_at desc.
func (s *ScenarioBuilderService) ListMyScenarios(ctx context.Context, userID uuid.UUID, status string) ([]BuilderScenario, error) {
switch status {
case "", "all":
// no filter
case "active", "archived", "promoted":
// ok
default:
return nil, fmt.Errorf("%w: status %q must be one of {active,archived,promoted,all}",
ErrInvalidInput, status)
}
q := `SELECT id, owner_id, name, status, origin_project_id, promoted_project_id,
stichtag, notes,
project_id, description, created_by,
created_at, updated_at
FROM paliad.scenarios
WHERE owner_id = $1`
args := []any{userID}
if status != "" && status != "all" {
q += ` AND status = $2`
args = append(args, status)
}
q += ` ORDER BY updated_at DESC`
out := []BuilderScenario{}
if err := s.db.SelectContext(ctx, &out, q, args...); err != nil {
return nil, fmt.Errorf("list builder scenarios: %w", err)
}
return out, nil
}
// GetScenarioDeep returns the scenario + proceedings + events + shares.
// Visibility: owner, share recipient, global_admin, or legacy editor.
func (s *ScenarioBuilderService) GetScenarioDeep(ctx context.Context, userID, scenarioID uuid.UUID) (*BuilderScenarioDeep, error) {
sc, err := s.getScenarioRow(ctx, scenarioID)
if err != nil {
return nil, err
}
visible, err := s.canSeeScenario(ctx, userID, sc)
if err != nil {
return nil, err
}
if !visible {
return nil, ErrScenarioBuilderNotVisible
}
deep := &BuilderScenarioDeep{BuilderScenario: *sc}
if err := s.db.SelectContext(ctx, &deep.Proceedings, `
SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
stichtag, detailgrad, appeal_target, collapsed,
created_at, updated_at
FROM paliad.scenario_proceedings
WHERE scenario_id = $1
ORDER BY ordinal ASC, created_at ASC`, scenarioID); err != nil {
return nil, fmt.Errorf("load proceedings: %w", err)
}
if err := s.db.SelectContext(ctx, &deep.Events, `
SELECT e.id, e.scenario_proceeding_id, e.sequencing_rule_id,
e.procedural_event_id, e.custom_label, e.state, e.actual_date,
e.skip_reason, e.notes, e.horizon_optional,
e.created_at, e.updated_at
FROM paliad.scenario_events e
JOIN paliad.scenario_proceedings sp ON sp.id = e.scenario_proceeding_id
WHERE sp.scenario_id = $1
ORDER BY e.created_at ASC`, scenarioID); err != nil {
return nil, fmt.Errorf("load events: %w", err)
}
if err := s.db.SelectContext(ctx, &deep.Shares, `
SELECT id, scenario_id, shared_with_user_id, created_by, created_at
FROM paliad.scenario_shares
WHERE scenario_id = $1
ORDER BY created_at ASC`, scenarioID); err != nil {
return nil, fmt.Errorf("load shares: %w", err)
}
return deep, nil
}
// PatchBuilderScenarioInput is the PATCH /api/builder/scenarios/{id} body.
// Any nil field means "don't change".
type PatchBuilderScenarioInput struct {
Name *string `json:"name,omitempty"`
Status *string `json:"status,omitempty"`
Stichtag *time.Time `json:"stichtag,omitempty"`
Notes *string `json:"notes,omitempty"`
}
// PatchScenario updates one or more fields. Status flips to 'promoted'
// are reserved for the B5 wizard (we accept only active⇄archived here).
func (s *ScenarioBuilderService) PatchScenario(ctx context.Context, userID, scenarioID uuid.UUID, input PatchBuilderScenarioInput) (*BuilderScenario, error) {
sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
if err != nil {
return nil, err
}
if sc.Status == "promoted" {
return nil, fmt.Errorf("%w: scenario is promoted; mutations are blocked", ErrInvalidInput)
}
if input.Status != nil {
switch *input.Status {
case "active", "archived":
// ok
case "promoted":
return nil, fmt.Errorf("%w: status='promoted' is set by the promote-to-project wizard, not PATCH",
ErrInvalidInput)
default:
return nil, fmt.Errorf("%w: status %q must be one of {active,archived}",
ErrInvalidInput, *input.Status)
}
}
sets := []string{}
args := []any{}
add := func(clause string, val any) {
args = append(args, val)
sets = append(sets, fmt.Sprintf(clause, len(args)))
}
if input.Name != nil {
n := strings.TrimSpace(*input.Name)
if n == "" {
return nil, fmt.Errorf("%w: name cannot be blank", ErrInvalidInput)
}
add("name = $%d", n)
}
if input.Status != nil {
add("status = $%d", *input.Status)
}
if input.Stichtag != nil {
add("stichtag = $%d", *input.Stichtag)
}
if input.Notes != nil {
add("notes = $%d", *input.Notes)
}
if len(sets) == 0 {
return sc, nil
}
args = append(args, scenarioID)
q := fmt.Sprintf(`UPDATE paliad.scenarios SET %s
WHERE id = $%d
RETURNING id, owner_id, name, status, origin_project_id, promoted_project_id,
stichtag, notes,
project_id, description, created_by,
created_at, updated_at`,
strings.Join(sets, ", "), len(args))
var out BuilderScenario
err = s.withAuditTx(ctx, "scenario_builder: patch scenario", func(tx *sqlx.Tx) error {
return tx.GetContext(ctx, &out, q, args...)
})
if err != nil {
return nil, fmt.Errorf("patch builder scenario: %w", err)
}
return &out, nil
}
// -----------------------------------------------------------------------------
// Proceedings
// -----------------------------------------------------------------------------
// AddProceedingInput is the POST /api/builder/scenarios/{id}/proceedings body.
type AddProceedingInput struct {
ProceedingTypeID int `json:"proceeding_type_id"`
PrimaryParty *string `json:"primary_party,omitempty"`
ScenarioFlags json.RawMessage `json:"scenario_flags,omitempty"`
ParentScenarioProceedingID *uuid.UUID `json:"parent_scenario_proceeding_id,omitempty"`
SpawnAnchorEventID *uuid.UUID `json:"spawn_anchor_event_id,omitempty"`
Ordinal *int `json:"ordinal,omitempty"`
Stichtag *time.Time `json:"stichtag,omitempty"`
Detailgrad *string `json:"detailgrad,omitempty"`
AppealTarget *string `json:"appeal_target,omitempty"`
}
// AddProceeding appends a proceeding row to the scenario. The caller must
// own the scenario (or be a legacy editor). Ordinal defaults to max+1.
func (s *ScenarioBuilderService) AddProceeding(ctx context.Context, userID, scenarioID uuid.UUID, input AddProceedingInput) (*BuilderProceeding, error) {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
return nil, err
}
if input.ProceedingTypeID == 0 {
return nil, fmt.Errorf("%w: proceeding_type_id is required", ErrInvalidInput)
}
if input.PrimaryParty != nil {
switch *input.PrimaryParty {
case "claimant", "defendant":
default:
return nil, fmt.Errorf("%w: primary_party %q must be claimant or defendant",
ErrInvalidInput, *input.PrimaryParty)
}
}
detailgrad := "selected"
if input.Detailgrad != nil {
switch *input.Detailgrad {
case "selected", "all_options":
detailgrad = *input.Detailgrad
default:
return nil, fmt.Errorf("%w: detailgrad %q must be selected or all_options",
ErrInvalidInput, *input.Detailgrad)
}
}
flags := input.ScenarioFlags
if len(flags) == 0 {
flags = json.RawMessage(`{}`)
}
// Resolve ordinal: caller's value or max+1 within the same scenario.
var ordinal int
if input.Ordinal != nil {
ordinal = *input.Ordinal
} else {
if err := s.db.GetContext(ctx, &ordinal,
`SELECT COALESCE(MAX(ordinal), -1) + 1
FROM paliad.scenario_proceedings
WHERE scenario_id = $1`, scenarioID); err != nil {
return nil, fmt.Errorf("compute ordinal: %w", err)
}
}
var out BuilderProceeding
err := s.withAuditTx(ctx, "scenario_builder: add proceeding", func(tx *sqlx.Tx) error {
if err := tx.GetContext(ctx, &out,
`INSERT INTO paliad.scenario_proceedings
(scenario_id, proceeding_type_id, primary_party, scenario_flags,
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
stichtag, detailgrad, appeal_target)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
RETURNING id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
stichtag, detailgrad, appeal_target, collapsed,
created_at, updated_at`,
scenarioID, input.ProceedingTypeID, input.PrimaryParty, []byte(flags),
input.ParentScenarioProceedingID, input.SpawnAnchorEventID, ordinal,
input.Stichtag, detailgrad, input.AppealTarget); err != nil {
return err
}
// touch the scenario's updated_at so the side panel re-orders correctly.
_, err := tx.ExecContext(ctx,
`UPDATE paliad.scenarios SET updated_at = now() WHERE id = $1`, scenarioID)
return err
})
if err != nil {
return nil, fmt.Errorf("add proceeding: %w", err)
}
return &out, nil
}
// PatchProceedingInput accepts a subset of mutable proceeding fields.
type PatchProceedingInput struct {
PrimaryParty *string `json:"primary_party,omitempty"`
ScenarioFlags json.RawMessage `json:"scenario_flags,omitempty"`
Ordinal *int `json:"ordinal,omitempty"`
Stichtag *time.Time `json:"stichtag,omitempty"`
Detailgrad *string `json:"detailgrad,omitempty"`
AppealTarget *string `json:"appeal_target,omitempty"`
Collapsed *bool `json:"collapsed,omitempty"`
}
// PatchProceeding updates fields on one proceeding row.
func (s *ScenarioBuilderService) PatchProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID, input PatchProceedingInput) (*BuilderProceeding, error) {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
return nil, err
}
sets := []string{}
args := []any{}
add := func(clause string, val any) {
args = append(args, val)
sets = append(sets, fmt.Sprintf(clause, len(args)))
}
if input.PrimaryParty != nil {
switch *input.PrimaryParty {
case "claimant", "defendant", "":
default:
return nil, fmt.Errorf("%w: primary_party %q invalid", ErrInvalidInput, *input.PrimaryParty)
}
if *input.PrimaryParty == "" {
add("primary_party = $%d", nil)
} else {
add("primary_party = $%d", *input.PrimaryParty)
}
}
if len(input.ScenarioFlags) > 0 {
add("scenario_flags = $%d", []byte(input.ScenarioFlags))
}
if input.Ordinal != nil {
add("ordinal = $%d", *input.Ordinal)
}
if input.Stichtag != nil {
add("stichtag = $%d", *input.Stichtag)
}
if input.Detailgrad != nil {
switch *input.Detailgrad {
case "selected", "all_options":
default:
return nil, fmt.Errorf("%w: detailgrad %q invalid", ErrInvalidInput, *input.Detailgrad)
}
add("detailgrad = $%d", *input.Detailgrad)
}
if input.AppealTarget != nil {
if *input.AppealTarget == "" {
add("appeal_target = $%d", nil)
} else {
add("appeal_target = $%d", *input.AppealTarget)
}
}
if input.Collapsed != nil {
add("collapsed = $%d", *input.Collapsed)
}
if len(sets) == 0 {
// nothing to do — re-fetch and return.
return s.getProceedingRow(ctx, scenarioID, proceedingID)
}
args = append(args, proceedingID, scenarioID)
q := fmt.Sprintf(`UPDATE paliad.scenario_proceedings SET %s
WHERE id = $%d AND scenario_id = $%d
RETURNING id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
stichtag, detailgrad, appeal_target, collapsed,
created_at, updated_at`,
strings.Join(sets, ", "), len(args)-1, len(args))
var out BuilderProceeding
err := s.withAuditTx(ctx, "scenario_builder: patch proceeding", func(tx *sqlx.Tx) error {
return tx.GetContext(ctx, &out, q, args...)
})
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: proceeding %s not in scenario %s",
ErrNotVisible, proceedingID, scenarioID)
}
return nil, fmt.Errorf("patch proceeding: %w", err)
}
return &out, nil
}
// DeleteProceeding removes a proceeding (and cascades to events + children).
func (s *ScenarioBuilderService) DeleteProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID) error {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
return err
}
var n int64
err := s.withAuditTx(ctx, "scenario_builder: delete proceeding", func(tx *sqlx.Tx) error {
res, err := tx.ExecContext(ctx,
`DELETE FROM paliad.scenario_proceedings
WHERE id = $1 AND scenario_id = $2`,
proceedingID, scenarioID)
if err != nil {
return err
}
n, _ = res.RowsAffected()
return nil
})
if err != nil {
return fmt.Errorf("delete proceeding: %w", err)
}
if n == 0 {
return fmt.Errorf("%w: proceeding %s not in scenario %s",
ErrNotVisible, proceedingID, scenarioID)
}
return nil
}
// -----------------------------------------------------------------------------
// Events
// -----------------------------------------------------------------------------
// AddEventInput is the POST .../proceedings/{pid}/events body. At least
// one of {SequencingRuleID, ProceduralEventID, CustomLabel} must be set,
// matching the scenario_events_one_anchor CHECK constraint.
type AddEventInput struct {
SequencingRuleID *uuid.UUID `json:"sequencing_rule_id,omitempty"`
ProceduralEventID *uuid.UUID `json:"procedural_event_id,omitempty"`
CustomLabel *string `json:"custom_label,omitempty"`
State *string `json:"state,omitempty"`
ActualDate *time.Time `json:"actual_date,omitempty"`
SkipReason *string `json:"skip_reason,omitempty"`
Notes *string `json:"notes,omitempty"`
HorizonOptional *int `json:"horizon_optional,omitempty"`
}
// AddEvent inserts an event card under the given proceeding. The
// proceeding must belong to the addressed scenario.
func (s *ScenarioBuilderService) AddEvent(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID, input AddEventInput) (*BuilderEvent, error) {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
return nil, err
}
if input.SequencingRuleID == nil && input.ProceduralEventID == nil &&
(input.CustomLabel == nil || strings.TrimSpace(*input.CustomLabel) == "") {
return nil, fmt.Errorf("%w: at least one of sequencing_rule_id, procedural_event_id, custom_label must be set",
ErrInvalidInput)
}
if err := s.assertProceedingInScenario(ctx, scenarioID, proceedingID); err != nil {
return nil, err
}
state := "planned"
if input.State != nil {
switch *input.State {
case "planned", "filed", "skipped":
state = *input.State
default:
return nil, fmt.Errorf("%w: state %q must be one of {planned,filed,skipped}",
ErrInvalidInput, *input.State)
}
}
horizon := 0
if input.HorizonOptional != nil {
if *input.HorizonOptional < 0 {
return nil, fmt.Errorf("%w: horizon_optional must be >= 0", ErrInvalidInput)
}
horizon = *input.HorizonOptional
}
var out BuilderEvent
err := s.withAuditTx(ctx, "scenario_builder: add event", func(tx *sqlx.Tx) error {
if err := tx.GetContext(ctx, &out,
`INSERT INTO paliad.scenario_events
(scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
custom_label, state, actual_date, skip_reason, notes, horizon_optional)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
custom_label, state, actual_date, skip_reason, notes,
horizon_optional, created_at, updated_at`,
proceedingID, input.SequencingRuleID, input.ProceduralEventID,
input.CustomLabel, state, input.ActualDate, input.SkipReason,
input.Notes, horizon); err != nil {
return err
}
_, err := tx.ExecContext(ctx,
`UPDATE paliad.scenarios SET updated_at = now() WHERE id = $1`, scenarioID)
return err
})
if err != nil {
return nil, fmt.Errorf("add event: %w", err)
}
return &out, nil
}
// PatchEventInput is the PATCH body for an event card.
type PatchEventInput struct {
State *string `json:"state,omitempty"`
ActualDate *time.Time `json:"actual_date,omitempty"`
SkipReason *string `json:"skip_reason,omitempty"`
Notes *string `json:"notes,omitempty"`
HorizonOptional *int `json:"horizon_optional,omitempty"`
}
// PatchEvent updates fields on one event card. The card's parent
// proceeding must belong to the addressed scenario.
func (s *ScenarioBuilderService) PatchEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID, input PatchEventInput) (*BuilderEvent, error) {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
return nil, err
}
if err := s.assertEventInScenario(ctx, scenarioID, eventID); err != nil {
return nil, err
}
sets := []string{}
args := []any{}
add := func(clause string, val any) {
args = append(args, val)
sets = append(sets, fmt.Sprintf(clause, len(args)))
}
if input.State != nil {
switch *input.State {
case "planned", "filed", "skipped":
default:
return nil, fmt.Errorf("%w: state %q invalid", ErrInvalidInput, *input.State)
}
add("state = $%d", *input.State)
}
if input.ActualDate != nil {
add("actual_date = $%d", *input.ActualDate)
}
if input.SkipReason != nil {
add("skip_reason = $%d", *input.SkipReason)
}
if input.Notes != nil {
add("notes = $%d", *input.Notes)
}
if input.HorizonOptional != nil {
if *input.HorizonOptional < 0 {
return nil, fmt.Errorf("%w: horizon_optional must be >= 0", ErrInvalidInput)
}
add("horizon_optional = $%d", *input.HorizonOptional)
}
if len(sets) == 0 {
return s.getEventRow(ctx, eventID)
}
args = append(args, eventID)
q := fmt.Sprintf(`UPDATE paliad.scenario_events SET %s
WHERE id = $%d
RETURNING id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
custom_label, state, actual_date, skip_reason, notes,
horizon_optional, created_at, updated_at`,
strings.Join(sets, ", "), len(args))
var out BuilderEvent
err := s.withAuditTx(ctx, "scenario_builder: patch event", func(tx *sqlx.Tx) error {
return tx.GetContext(ctx, &out, q, args...)
})
if err != nil {
return nil, fmt.Errorf("patch event: %w", err)
}
return &out, nil
}
// DeleteEvent removes one event card.
func (s *ScenarioBuilderService) DeleteEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID) error {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
return err
}
if err := s.assertEventInScenario(ctx, scenarioID, eventID); err != nil {
return err
}
err := s.withAuditTx(ctx, "scenario_builder: delete event", func(tx *sqlx.Tx) error {
_, err := tx.ExecContext(ctx,
`DELETE FROM paliad.scenario_events WHERE id = $1`, eventID)
return err
})
if err != nil {
return fmt.Errorf("delete event: %w", err)
}
return nil
}
// -----------------------------------------------------------------------------
// Shares
// -----------------------------------------------------------------------------
// AddShare grants read-only access to another paliad user.
func (s *ScenarioBuilderService) AddShare(ctx context.Context, userID, scenarioID, recipientID uuid.UUID) (*BuilderShare, error) {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
return nil, err
}
if recipientID == uuid.Nil {
return nil, fmt.Errorf("%w: shared_with_user_id is required", ErrInvalidInput)
}
if recipientID == userID {
return nil, fmt.Errorf("%w: cannot share a scenario with yourself", ErrInvalidInput)
}
var out BuilderShare
err := s.withAuditTx(ctx, "scenario_builder: add share", func(tx *sqlx.Tx) error {
return tx.GetContext(ctx, &out,
`INSERT INTO paliad.scenario_shares (scenario_id, shared_with_user_id, created_by)
VALUES ($1, $2, $3)
ON CONFLICT (scenario_id, shared_with_user_id) DO UPDATE
SET created_at = paliad.scenario_shares.created_at
RETURNING id, scenario_id, shared_with_user_id, created_by, created_at`,
scenarioID, recipientID, userID)
})
if err != nil {
return nil, fmt.Errorf("add share: %w", err)
}
return &out, nil
}
// DeleteShare revokes a share row.
func (s *ScenarioBuilderService) DeleteShare(ctx context.Context, userID, scenarioID, shareID uuid.UUID) error {
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
return err
}
var n int64
err := s.withAuditTx(ctx, "scenario_builder: delete share", func(tx *sqlx.Tx) error {
res, err := tx.ExecContext(ctx,
`DELETE FROM paliad.scenario_shares
WHERE id = $1 AND scenario_id = $2`, shareID, scenarioID)
if err != nil {
return err
}
n, _ = res.RowsAffected()
return nil
})
if err != nil {
return fmt.Errorf("delete share: %w", err)
}
if n == 0 {
return fmt.Errorf("%w: share %s not in scenario %s", ErrNotVisible, shareID, scenarioID)
}
return nil
}
// -----------------------------------------------------------------------------
// Internal helpers
// -----------------------------------------------------------------------------
func (s *ScenarioBuilderService) getScenarioRow(ctx context.Context, scenarioID uuid.UUID) (*BuilderScenario, error) {
var out BuilderScenario
err := s.db.GetContext(ctx, &out,
`SELECT id, owner_id, name, status, origin_project_id, promoted_project_id,
stichtag, notes,
project_id, description, created_by,
created_at, updated_at
FROM paliad.scenarios
WHERE id = $1`, scenarioID)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: scenario %s not found", ErrNotVisible, scenarioID)
}
if err != nil {
return nil, fmt.Errorf("get scenario: %w", err)
}
return &out, nil
}
func (s *ScenarioBuilderService) getProceedingRow(ctx context.Context, scenarioID, proceedingID uuid.UUID) (*BuilderProceeding, error) {
var out BuilderProceeding
err := s.db.GetContext(ctx, &out,
`SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
stichtag, detailgrad, appeal_target, collapsed,
created_at, updated_at
FROM paliad.scenario_proceedings
WHERE id = $1 AND scenario_id = $2`, proceedingID, scenarioID)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: proceeding %s not in scenario %s",
ErrNotVisible, proceedingID, scenarioID)
}
if err != nil {
return nil, fmt.Errorf("get proceeding: %w", err)
}
return &out, nil
}
func (s *ScenarioBuilderService) getEventRow(ctx context.Context, eventID uuid.UUID) (*BuilderEvent, error) {
var out BuilderEvent
err := s.db.GetContext(ctx, &out,
`SELECT id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
custom_label, state, actual_date, skip_reason, notes,
horizon_optional, created_at, updated_at
FROM paliad.scenario_events
WHERE id = $1`, eventID)
if errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("%w: event %s not found", ErrNotVisible, eventID)
}
if err != nil {
return nil, fmt.Errorf("get event: %w", err)
}
return &out, nil
}
func (s *ScenarioBuilderService) assertProceedingInScenario(ctx context.Context, scenarioID, proceedingID uuid.UUID) error {
var exists bool
if err := s.db.GetContext(ctx, &exists,
`SELECT EXISTS (SELECT 1 FROM paliad.scenario_proceedings
WHERE id = $1 AND scenario_id = $2)`,
proceedingID, scenarioID); err != nil {
return fmt.Errorf("check proceeding membership: %w", err)
}
if !exists {
return fmt.Errorf("%w: proceeding %s not in scenario %s",
ErrNotVisible, proceedingID, scenarioID)
}
return nil
}
func (s *ScenarioBuilderService) assertEventInScenario(ctx context.Context, scenarioID, eventID uuid.UUID) error {
var exists bool
if err := s.db.GetContext(ctx, &exists,
`SELECT EXISTS (
SELECT 1 FROM paliad.scenario_events e
JOIN paliad.scenario_proceedings sp ON sp.id = e.scenario_proceeding_id
WHERE e.id = $1 AND sp.scenario_id = $2
)`,
eventID, scenarioID); err != nil {
return fmt.Errorf("check event membership: %w", err)
}
if !exists {
return fmt.Errorf("%w: event %s not in scenario %s",
ErrNotVisible, eventID, scenarioID)
}
return nil
}
// canSeeScenario mirrors the SQL paliad.can_see_scenario(...) function in
// Go. The service connection bypasses RLS, so this check is the
// authoritative gate.
func (s *ScenarioBuilderService) canSeeScenario(ctx context.Context, userID uuid.UUID, sc *BuilderScenario) (bool, error) {
// owner — fast path
if sc.OwnerID != nil && *sc.OwnerID == userID {
return true, nil
}
// global_admin
var isAdmin bool
if err := s.db.GetContext(ctx, &isAdmin,
`SELECT EXISTS (SELECT 1 FROM paliad.users WHERE id = $1 AND global_role = 'global_admin')`,
userID); err != nil {
return false, fmt.Errorf("check global_admin: %w", err)
}
if isAdmin {
return true, nil
}
// share recipient
var shared bool
if err := s.db.GetContext(ctx, &shared,
`SELECT EXISTS (SELECT 1 FROM paliad.scenario_shares
WHERE scenario_id = $1 AND shared_with_user_id = $2)`,
sc.ID, userID); err != nil {
return false, fmt.Errorf("check share: %w", err)
}
if shared {
return true, nil
}
// legacy project-scoped — visible via project team membership
if sc.OwnerID == nil && sc.LegacyProjectID != nil {
var ok bool
if err := s.db.GetContext(ctx, &ok,
`SELECT paliad.can_see_project($1::uuid)`,
*sc.LegacyProjectID); err == nil && ok {
return true, nil
}
}
// legacy abstract — owner-only via created_by
if sc.OwnerID == nil && sc.LegacyProjectID == nil && sc.LegacyCreatedBy != nil &&
*sc.LegacyCreatedBy == userID {
return true, nil
}
return false, nil
}
// requireOwnerOrLegacyEditor fetches the scenario and validates that the
// caller has write rights. Returns the loaded row for downstream use.
func (s *ScenarioBuilderService) requireOwnerOrLegacyEditor(ctx context.Context, userID, scenarioID uuid.UUID) (*BuilderScenario, error) {
sc, err := s.getScenarioRow(ctx, scenarioID)
if err != nil {
return nil, err
}
// owner
if sc.OwnerID != nil && *sc.OwnerID == userID {
return sc, nil
}
// legacy project-scoped editor
if sc.OwnerID == nil && sc.LegacyProjectID != nil {
var ok bool
if err := s.db.GetContext(ctx, &ok,
`SELECT paliad.can_see_project($1::uuid)`,
*sc.LegacyProjectID); err == nil && ok {
return sc, nil
}
}
// legacy abstract creator
if sc.OwnerID == nil && sc.LegacyProjectID == nil && sc.LegacyCreatedBy != nil &&
*sc.LegacyCreatedBy == userID {
return sc, nil
}
return nil, ErrScenarioBuilderNotVisible
}
// withAuditTx opens a transaction, stamps paliad.audit_reason via
// set_config(..., true) so the reason persists for the duration of the
// tx (matching the mig-079 audit-trigger pattern used by event_choice_
// service.go), invokes fn, and commits. Any error returned by fn rolls
// back. The audit reason is appended with the task slug so audit-log
// readers can trace writes back to t-paliad-340.
func (s *ScenarioBuilderService) withAuditTx(ctx context.Context, reason string, fn func(tx *sqlx.Tx) error) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`SELECT set_config('paliad.audit_reason', $1, true)`,
fmt.Sprintf("%s (t-paliad-340)", reason)); err != nil {
return fmt.Errorf("set audit_reason: %w", err)
}
if err := fn(tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit: %w", err)
}
return nil
}

View File

@@ -0,0 +1,220 @@
package services
import (
"context"
"encoding/json"
"errors"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// TestScenarioBuilderService exercises the t-paliad-340 / m/paliad#153 B0
// surface end-to-end against a live DB: create + list + deep-get + patch
// + add-proceeding + add-event + add/delete-share, plus the visibility
// negative case (a non-owner can't see the scenario unless shared).
//
// Skipped without TEST_DATABASE_URL — matches the pattern in
// project_service_test.go / event_choice_service_test.go.
func TestScenarioBuilderService(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
owner := uuid.New()
other := uuid.New()
cleanup := func() {
// Cascade order: delete from scenarios → CASCADE clears
// proceedings, events, shares. Then the two users.
pool.ExecContext(ctx,
`DELETE FROM paliad.scenarios WHERE owner_id IN ($1, $2)`, owner, other)
pool.ExecContext(ctx,
`DELETE FROM paliad.users WHERE id IN ($1, $2)`, owner, other)
pool.ExecContext(ctx,
`DELETE FROM auth.users WHERE id IN ($1, $2)`, owner, other)
}
cleanup()
defer cleanup()
for _, seed := range []struct {
id uuid.UUID
email string
name string
}{
{owner, "builder-owner-test@hlc.com", "Builder Owner"},
{other, "builder-other-test@hlc.com", "Builder Other"},
} {
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, $2)`,
seed.id, seed.email); err != nil {
t.Fatalf("seed auth.users %s: %v", seed.email, err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, lang)
VALUES ($1, $2, $3, 'munich', 'de')`,
seed.id, seed.email, seed.name); err != nil {
t.Fatalf("seed paliad.users %s: %v", seed.email, err)
}
}
// Pick a real proceeding_type_id so the FK insert succeeds.
var ptID int
if err := pool.GetContext(ctx, &ptID,
`SELECT id FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true
LIMIT 1`, CodeUPCInfringement); err != nil {
t.Fatalf("look up upc.inf id: %v", err)
}
svc := NewScenarioBuilderService(pool)
// 1. Create a scenario for the owner. Empty name should default.
sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
if err != nil {
t.Fatalf("CreateScenario: %v", err)
}
if sc.Name != "Unbenanntes Szenario" {
t.Errorf("default name = %q, want %q", sc.Name, "Unbenanntes Szenario")
}
if sc.Status != "active" {
t.Errorf("default status = %q, want active", sc.Status)
}
if sc.OwnerID == nil || *sc.OwnerID != owner {
t.Errorf("owner_id = %v, want %v", sc.OwnerID, owner)
}
// 2. List — should return the one row.
list, err := svc.ListMyScenarios(ctx, owner, "active")
if err != nil {
t.Fatalf("ListMyScenarios: %v", err)
}
if len(list) != 1 || list[0].ID != sc.ID {
t.Errorf("ListMyScenarios returned %d rows; want 1 with id %s", len(list), sc.ID)
}
// 3. Other user can NOT see the scenario.
if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); !errors.Is(err, ErrScenarioBuilderNotVisible) {
t.Errorf("GetScenarioDeep by non-owner = %v, want ErrScenarioBuilderNotVisible", err)
}
// 4. Add a proceeding.
pr, err := svc.AddProceeding(ctx, owner, sc.ID, AddProceedingInput{
ProceedingTypeID: ptID,
PrimaryParty: ptrString("defendant"),
ScenarioFlags: json.RawMessage(`{"with_ccr": true}`),
})
if err != nil {
t.Fatalf("AddProceeding: %v", err)
}
if pr.ProceedingTypeID != ptID {
t.Errorf("ProceedingTypeID = %d, want %d", pr.ProceedingTypeID, ptID)
}
if pr.PrimaryParty == nil || *pr.PrimaryParty != "defendant" {
t.Errorf("PrimaryParty = %v, want defendant", pr.PrimaryParty)
}
// 5. Add a custom-label event card.
ev, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
CustomLabel: ptrString("Klageerwiderung"),
State: ptrString("planned"),
})
if err != nil {
t.Fatalf("AddEvent: %v", err)
}
if ev.State != "planned" {
t.Errorf("event state = %q, want planned", ev.State)
}
// 5b. Add-event with NO anchor fields fails.
if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{}); !errors.Is(err, ErrInvalidInput) {
t.Errorf("AddEvent without anchor = %v, want ErrInvalidInput", err)
}
// 6. Deep get — should bundle the scenario + 1 proceeding + 1 event + 0 shares.
deep, err := svc.GetScenarioDeep(ctx, owner, sc.ID)
if err != nil {
t.Fatalf("GetScenarioDeep: %v", err)
}
if len(deep.Proceedings) != 1 || deep.Proceedings[0].ID != pr.ID {
t.Errorf("deep proceedings count=%d want 1; ids: %+v", len(deep.Proceedings), deep.Proceedings)
}
if len(deep.Events) != 1 || deep.Events[0].ID != ev.ID {
t.Errorf("deep events count=%d want 1; ids: %+v", len(deep.Events), deep.Events)
}
if len(deep.Shares) != 0 {
t.Errorf("deep shares count=%d want 0", len(deep.Shares))
}
// 7. Share with `other`. Recipient should now see the scenario.
sh, err := svc.AddShare(ctx, owner, sc.ID, other)
if err != nil {
t.Fatalf("AddShare: %v", err)
}
if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); err != nil {
t.Errorf("GetScenarioDeep by share recipient: %v", err)
}
// But the recipient can NOT add proceedings.
if _, err := svc.AddProceeding(ctx, other, sc.ID, AddProceedingInput{
ProceedingTypeID: ptID,
}); !errors.Is(err, ErrScenarioBuilderNotVisible) {
t.Errorf("AddProceeding by share recipient = %v, want ErrScenarioBuilderNotVisible", err)
}
// 7b. Self-share should be rejected.
if _, err := svc.AddShare(ctx, owner, sc.ID, owner); !errors.Is(err, ErrInvalidInput) {
t.Errorf("self-share = %v, want ErrInvalidInput", err)
}
// 8. Patch — archive then re-activate.
patched, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
Status: ptrString("archived"),
})
if err != nil {
t.Fatalf("PatchScenario archive: %v", err)
}
if patched.Status != "archived" {
t.Errorf("after archive, status = %q, want archived", patched.Status)
}
// PATCH to 'promoted' is rejected — that's the wizard's job.
if _, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
Status: ptrString("promoted"),
}); !errors.Is(err, ErrInvalidInput) {
t.Errorf("PATCH status=promoted = %v, want ErrInvalidInput", err)
}
patched, err = svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
Status: ptrString("active"),
})
if err != nil {
t.Fatalf("PatchScenario re-activate: %v", err)
}
if patched.Status != "active" {
t.Errorf("after re-activate, status = %q, want active", patched.Status)
}
// 9. Revoke the share. Recipient loses visibility.
if err := svc.DeleteShare(ctx, owner, sc.ID, sh.ID); err != nil {
t.Fatalf("DeleteShare: %v", err)
}
if _, err := svc.GetScenarioDeep(ctx, other, sc.ID); !errors.Is(err, ErrScenarioBuilderNotVisible) {
t.Errorf("after revoke, recipient GetScenarioDeep = %v, want ErrScenarioBuilderNotVisible", err)
}
}
// (Note: ptrString lives in rule_editor_service_test.go in this package
// and is reused here. No second declaration needed.)