diff --git a/cmd/server/main.go b/cmd/server/main.go
index 31f5d1e..5f2225c 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -143,6 +143,7 @@ func main() {
Fristenrechner: services.NewFristenrechnerService(rules, holidays),
EventDeadline: services.NewEventDeadlineService(pool, services.NewDeadlineCalculator(holidays), holidays),
DeadlineSearch: services.NewDeadlineSearchService(pool),
+ EventCategory: nil, // wired below; cross-link order matters
EventType: eventTypeSvc,
Dashboard: services.NewDashboardService(pool, users),
Note: services.NewNoteService(pool, projectSvc, appointmentSvc),
@@ -155,6 +156,12 @@ func main() {
Link: services.NewLinkService(pool),
Event: services.NewEventService(pool, deadlineSvc, appointmentSvc),
}
+ // v3 (t-paliad-133): wire EventCategoryService and cross-link
+ // it into DeadlineSearchService so ?event_category_slug= can
+ // resolve to a concept-id allow-list during search.
+ eventCategorySvc := services.NewEventCategoryService(pool)
+ svcBundle.EventCategory = eventCategorySvc
+ svcBundle.DeadlineSearch.SetEventCategoryService(eventCategorySvc)
log.Println("Phase B services initialised")
// Spawn background goroutines: CalDAV sync (one per enabled user)
diff --git a/docs/plans/unified-fristenrechner-v3.md b/docs/plans/unified-fristenrechner-v3.md
new file mode 100644
index 0000000..974c513
--- /dev/null
+++ b/docs/plans/unified-fristenrechner-v3.md
@@ -0,0 +1,1048 @@
+# Unified Fristenrechner v3 — Pathway A (Browse) vs Pathway B (Event → Frist)
+
+**Author:** cronus (inventor)
+**Date:** 2026-05-05
+**Task:** t-paliad-133
+**Mode:** design (LOCKED 2026-05-05 10:33) — implementation now in progress on the same branch.
+**Branch:** `mai/cronus/fristenrechner-v3-pathways` (worktree)
+**Status:** v3 LOCKED — m approved 2026-05-05 10:33 with answers to all 12 open questions; Maria's two scope additions folded in (forum granularity at court-system level + party-perspective selector absorbing t-paliad-132). Single-branch / sequential-commits / one-final-merge per m's instruction.
+**Reads with:** `docs/plans/unified-fristenrechner.md` (v2, shipped) for the concept-layer / search-backend / coverage details that v3 inherits unchanged.
+
+> *m, 2026-05-05 10:06:* "Aber jetzt wurde das beim Fristenrechner Etwas vermischt. Ich wollte die aktuelle 'Schnellübersicht' im Zusammenhang mit 'Was kommt nach…?'. Etwas, wo wir Filter kombinieren können. (…) Es gibt hauptsächlich zwei Ausgangspunkte: Ich möchte mich über den allgemeinen Verfahrensablauf informieren ODER ich möchte wissen, ob ich eine und was für eine Frist ich eintragen muss aufgrund eines Ereignisses. Letzteres sollten wir in zwei teilen: einen Entscheidungsbaum (Button nach Button) (…) Und dann zeigen wir eben die Resultate als Cards unten an, narrowing it down one by one. Einmal über die fixe Abfolge, einmal über Filter."
+
+---
+
+## 0. Why v3
+
+v2 shipped (16 migrations 037-046, then PR-Phase-C/D 047 + handler + frontend). The Fristenrechner page now stacks **three** entry points on `/tools/fristenrechner`:
+
+1. The **Phase D concept-card search bar** at the top — typed query → cards with proceeding pills inside, drill-in pill click jumps into one of the modes below.
+2. The **Verfahrensablauf tile grid** (UPC / DE / EPA / DPMA proceeding tiles → date input → timeline / columns view) — original entry, "I know which proceeding I'm in, calculate me the whole tree".
+3. The **"Was kommt nach…" tab** — trigger-event picker → date input → flat result list — original second entry.
+
+The borders between (1), (2), (3) are blurred:
+
+- (1) does **what (3) does** for some queries (search "Versäumnisurteil" → trigger-event card pill leads back into (3); search "Berufung" → proceeding-rule pills lead into (2)).
+- (1) bypasses (2) for most concept queries — the user types and drills directly, never seeing the proceeding tile grid.
+- (3) carries 100+ youpc-imported triggers most of which are unlinked to concepts — they don't surface in (1) and don't have a friendly entry from (2).
+
+Two goals get tangled: **"learn how a proceeding works"** and **"figure out the Frist I need to enter on a real matter"**. m's read: separate them. **v3 forks the page surface** so each mental model has its own entrypoint, and the second one (Frist-eintragen) gets two complementary navigation styles — a guided decision tree (B1) and a free filter+search (B2).
+
+v3 does **not** change the calculator math, the deadline_concepts data, the migrations 037-047 already shipped, or the search-backend mat-view. It restructures the **entry UX** above all of that.
+
+---
+
+## 1. Executive summary
+
+The Fristenrechner landing fork:
+
+```
+ ┌──────────────────────────────────────┐
+ │ Fristenrechner │
+ │ Was möchten Sie tun? │
+ ├──────────────────┬───────────────────┤
+ │ 📖 Pathway A │ 📅 Pathway B │
+ │ Verfahrensablauf│ Frist eintragen │
+ │ informieren │ aufgrund Ereignis│
+ └──────────────────┴───────────────────┘
+ │
+ ┌─────────────────┴─────────────────┐
+ ▼ ▼
+ ┌──────────────────────┐ ┌─────────────────────────┐
+ │ Pathway A (Browse) │ │ Pathway B (Event→Frist) │
+ │ │ │ ┌──────┐ ┌────────────┐ │
+ │ • UPC tile grid │ │ │ B1 │ │ B2 │ │
+ │ • DE tile grid │ │ │ Tree │ │ Filter+ │ │
+ │ • EPA tile grid │ │ │ │ │ Suche │ │
+ │ • DPMA tile grid │ │ └──────┘ └────────────┘ │
+ │ │ │ │
+ │ → existing wizard │ │ → narrowing concept │
+ │ (date input, │ │ cards (same shape │
+ │ flags, timeline, │ │ as v2 Phase D) │
+ │ columns view) │ │ │
+ └──────────────────────┘ └─────────────────────────┘
+```
+
+**Pathway A — Verfahrensablauf informieren (Browse / Learn).** Identical to today's tile-grid → date-input → timeline flow. The user picks a proceeding type because they know what it is and want to see the whole tree. No event-driven entry; no search bar.
+
+**Pathway B — Frist eintragen aufgrund Ereignis (Event → Deadline).** For users who just had something happen and need to know which Frist(en) to enter on a matter. Subdivides into:
+
+- **B1 — Entscheidungsbaum.** Sequential 3-5-step button cascade. Each step asks one question, offers 2-6 buttons, narrows to a candidate set of Fristen as concept cards below.
+- **B2 — Filter / Suche.** Free-text search bar + filter chips (Verfahrensart · Partei · Rechtsquelle · **Gericht/System** · plus the existing Phase D pills). Filters AND together; adding a filter reduces the result set.
+
+Both B1 and B2 produce the **same concept-card UI** that v2 Phase D shipped — one card per concept, proceeding pills inside. Drill-in pill click hands off to Pathway A (proceeding-tree calculator) or to the trigger-event calculator. **B1 and B2 share the same underlying filter state** under the hood — the decision tree is a guided way to build a filter combination. The user can switch B1↔B2 mid-flow without losing context.
+
+**Data addition:** new `paliad.event_categories` table (recursive tree) + `paliad.event_category_concepts` junction. Defines the decision-tree taxonomy as data, not as a hard-coded UI tree. Purely additive; no changes to existing tables.
+
+**Forum filter (Q8 reversal).** v2 dropped forum-filter on the rationale "rules are shared per court system within a jurisdiction". m has now reversed that: even though the *legal source* of a deadline is jurisdiction-wide, the *user* often knows which **forum** they're working at and wants a filter. v3 brings back a **Gericht/System** filter on B2 — multi-select chips, AND-each, default all selected, narrows the concept-card result set.
+
+**Legacy tabs.** v2's two tabs (Verfahrensablauf / Was kommt nach…) are functionally absorbed by Pathway A and Pathway B. **Inventor recommends retire** — keep only the new fork. Defer retirement to Phase E pending m's go.
+
+**Phasing:**
+
+- **Phase A** — `paliad.event_categories` schema + seed (data only, no UX change).
+- **Phase B** — landing fork UI; route to existing tile grid (A) or new Pathway-B shell (B). URL state persists choice.
+- **Phase C** — B1 decision-tree component, data-driven; result-card list narrows step-by-step.
+- **Phase D** — B2 filter expansion; bring back forum chip filter; ensure progressive narrowing semantics.
+- **Phase E** — retire legacy tabs (`mode-procedure-tab` / `mode-event-tab`) IF m approves; otherwise keep as "Klassische Ansicht" link in the page footer.
+
+---
+
+## 2. Verified current state
+
+| Area | What's there today (post-t-paliad-131) | Reused in v3 |
+|---|---|---|
+| `paliad.deadline_concepts` | 57 concepts seeded | Yes (no change) |
+| `paliad.deadline_rules` (gained `concept_id`, `legal_source`, `condition_flag text[]`) | 137 rules across 19 fristenrechner trees | Yes (no change) |
+| `paliad.trigger_events` | 100 youpc-imported (UPC) + 7 paliad-native at id ≥200 (DE/EPA cross-cutting) | Yes (no change) |
+| `paliad.deadline_search` matview | UNION of rule + trigger rows, GIN-indexed | Yes — v3 read path stays identical |
+| `GET /api/tools/fristenrechner/search` handler | Phase D concept-card endpoint | Yes — extended with new `forum=` query param (§4) |
+| `frontend/src/fristenrechner.tsx` | search bar + chips + 2 tabs (Verfahrensablauf / Was kommt nach…) | Reshape: split into landing fork → Pathway A page-fragment, Pathway B shell with B1/B2 toggle |
+| `frontend/src/client/fristenrechner.ts` (≈1500 lines) | wizard logic for both modes | Pathway A keeps existing flow verbatim; Pathway B reorganises search-result rendering into the new shell |
+| `frontend/src/client/fristenrechner-search.ts` (Phase D, ≈600 lines) | search → cards → drill-in | Reused as the engine of B2; the DOM scaffolding moves into the Pathway B shell |
+
+**No migrations are reverted.** v3 builds on top of the data layer that v2 shipped. Phase A adds two new tables; nothing in 037-047 changes.
+
+---
+
+## 3. UX surface
+
+### 3.1 Landing fork
+
+```
+┌──────────────────────────────────────────────────────────────────┐
+│ Fristenrechner │
+│ Berechnung von Verfahrensfristen — UPC, DE, EPA, DPMA │
+│ │
+│ ┌────────────────────────┐ ┌─────────────────────────────────┐ │
+│ │ 📖 │ │ 📅 │ │
+│ │ Verfahrensablauf │ │ Frist eintragen │ │
+│ │ informieren │ │ aufgrund Ereignis │ │
+│ │ │ │ │ │
+│ │ Verfahrenstyp wählen │ │ Ein Ereignis ist eingetreten — │ │
+│ │ und alle dazugehörigen │ │ ich brauche die richtige Frist │ │
+│ │ Fristen auf einer Zeit-│ │ für meine Akte. │ │
+│ │ leiste sehen. │ │ │ │
+│ │ │ │ → Schritt-für-Schritt │ │
+│ │ → UPC / DE / EPA / DPMA│ │ → Filter / Suche │ │
+│ └────────────────────────┘ └─────────────────────────────────┘ │
+│ │
+│ ───────── oder direkt zu einer Frist springen ────────── │
+│ │
+│ [ Klageerwiderung ] [ Berufung ] [ Einspruch ] [ Replik ] [ … ] │
+│ (chips → Pathway B + B2 prefilled) │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+**Default landing:** the fork itself. No automatic forwarding into either pathway — both choices are first-class.
+
+**URL state (sharable, bookmarkable):**
+
+| URL | Surface |
+|---|---|
+| `/tools/fristenrechner` | Landing fork |
+| `/tools/fristenrechner?path=a` | Pathway A (tile grid) |
+| `/tools/fristenrechner?path=a&proc=UPC_INF` | Pathway A, proceeding pre-selected (existing behaviour) |
+| `/tools/fristenrechner?path=b` | Pathway B, default mode (B1 if user hasn't chosen yet, otherwise the last mode) |
+| `/tools/fristenrechner?path=b&mode=tree` | Pathway B, B1 decision tree |
+| `/tools/fristenrechner?path=b&mode=tree&b1=cms-eingang.gericht.hinweisbeschluss` | B1 with the path pre-walked to that node |
+| `/tools/fristenrechner?path=b&mode=filter` | Pathway B, B2 filter+search |
+| `/tools/fristenrechner?path=b&mode=filter&q=klageerwiderung&forum=upc,de_lg` | B2 with query + forum filter pre-applied |
+
+The Phase D quick-pick chips relocate from "above tabs" to "below the fork" with the new framing **"oder direkt zu einer Frist springen"**. Chip click navigates to `?path=b&mode=filter&q=`. They remain a discovery affordance for users who already know what they're looking for and want to skip the choose-a-pathway step.
+
+A persistent **"Klassische Ansicht"** link sits in the page footer for v3.0 — points back to today's tab layout via `?legacy=1`. Removed in Phase E (gate-gated by m).
+
+**Mobile:** stacked single column. Two cards become two full-width buttons; chips wrap.
+
+### 3.2 Pathway A — Verfahrensablauf informieren
+
+Identical to today's wizard:
+
+```
+[ ← zurück zur Auswahl ] ← Pathway A back-link to landing fork
+
+┌──────────────────────────────────────────────────────────────────┐
+│ 📖 Verfahrensablauf │
+│ │
+│ 1. Verfahrensart wählen │
+│ UPC: [VLG] [Nicht] [PI] [Ber.] [Sch.] [Bu.] [Ber-K] [An.] │
+│ DE: [VLG] [BPatG] [OLG] [BGH-NZB] [BGH-N] │
+│ EPA: [Einspr.] [Beschw.] [Erteil.] │
+│ DPMA:[Einspr.] [BPatG-Beschw.] [BGH-RB] │
+│ │
+│ 2. Datum + Flags │
+│ 3. Ergebnis (Timeline / Spalten) │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+Functionally unchanged from today — same component, same calculator, same flag checkboxes, same timeline / columns view, same click-to-edit dates, same anchor overrides.
+
+The only delta: the legacy "Was kommt nach…" tab is **not** rendered in Pathway A. Trigger-event entry moved entirely into Pathway B.
+
+### 3.3 Pathway B — Frist eintragen aufgrund Ereignis
+
+```
+[ ← zurück zur Auswahl ]
+
+┌──────────────────────────────────────────────────────────────────┐
+│ 📅 Frist eintragen │
+│ │
+│ Modus: ◉ Schritt-für-Schritt (Entscheidungsbaum) │
+│ ◯ Filter / Suche │
+│ │
+│ ───────────────────────────────────────────── │
+│ │
+│ [ B1 panel ] OR [ B2 panel ] │
+│ │
+│ ───────────────────────────────────────────── │
+│ │
+│ Treffer ({count}) │
+│ ┌───────── Concept Card ─────────┐ │
+│ │ Klageerwiderung · Statement of │ │
+│ │ Defence │ │
+│ │ [UPC R.23.1] [DE_INF §276.1] │ │
+│ │ [BPatG §82.1] [EPA R.79.1] │ │
+│ │ [DPMA §59.3] │ │
+│ └────────────────────────────────┘ │
+│ … │
+└──────────────────────────────────────────────────────────────────┘
+```
+
+**Mode toggle** at the top is a 2-radio control, not tabs (tabs imply equal weight; modes imply same-data different-presentation). State persists via `?mode=tree|filter`. Switching modes preserves the underlying filter set when possible (B1's path translates 1:1 to B2 chips; B2 chips that don't correspond to a B1 step show a hint "Filter aktiv: " above the B1 cascade so the user knows context is preserved).
+
+**Result-card area (below the mode panel)** is **shared between B1 and B2** and uses the existing `attachFristenSearch` rendering (Phase D). The cards narrow as the user adds path-steps (B1) or filters (B2).
+
+### 3.4 B1 — Decision Tree (button cascade)
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ 1. Was ist passiert? [ ↺ Reset ] │
+│ │
+│ ┌────────────┐ ┌──────────────┐ ┌──────────┐ ┌──────────┐ │
+│ │ 📥 │ │ 🎤 │ │ 📊 │ │ 🚫 │ │
+│ │ CMS- │ │ Mündliche │ │ Beschluss│ │ Frist │ │
+│ │ Eingang │ │ Verhandlung │ │ / │ │ verpasst │ │
+│ │ │ │ │ │ Entsch. │ │ │ │
+│ └────────────┘ └──────────────┘ └──────────┘ └──────────┘ │
+│ ┌────────────┐ │
+│ │ ❓ Anderes │ │
+│ └────────────┘ │
+└─────────────────────────────────────────────────────────────┘
+
+(after click 📥 CMS-Eingang)
+
+┌─────────────────────────────────────────────────────────────┐
+│ Pfad: 📥 CMS-Eingang › [ ← zurück ] │
+│ │
+│ 2. Von wem ist das Schriftstück? │
+│ │
+│ ┌────────────────┐ ┌─────────────────────────┐ │
+│ │ ⚖ Vom Gericht │ │ 👥 Von der Gegenseite │ │
+│ └────────────────┘ └─────────────────────────┘ │
+└─────────────────────────────────────────────────────────────┘
+
+(after click 👥 Von der Gegenseite)
+
+┌─────────────────────────────────────────────────────────────┐
+│ Pfad: 📥 CMS-Eingang › 👥 Gegenseite › [ ← zurück ] │
+│ │
+│ 3. In welchem Verfahrenstyp? │
+│ │
+│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
+│ │ ⚖ UPC INF│ │ ⚖ UPC REV│ │ 🏛 DE LG │ │ 🏛 BPatG │ │
+│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
+│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
+│ │ 🏛 EPA Op │ │ 🏛 EPA App│ │ 🏛 DPMA Op│ │ … weitere │ │
+│ └──────────┘ └──────────┘ └──────────┘ └──────────┘ │
+└─────────────────────────────────────────────────────────────┘
+
+(after click ⚖ UPC INF)
+
+┌─────────────────────────────────────────────────────────────┐
+│ Pfad: 📥 CMS-Eingang › 👥 Gegenseite › ⚖ UPC INF │
+│ [ ← zurück ] │
+│ │
+│ 4. Welcher Schriftsatz wurde eingereicht? (optional) │
+│ │
+│ ┌──────────────┐ ┌──────────────┐ ┌────────────────┐ │
+│ │ Klageschrift │ │ Klageerwid. │ │ Replik / Duplik│ │
+│ └──────────────┘ └──────────────┘ └────────────────┘ │
+│ ┌────────────────────────┐ ┌──────────────────────┐ │
+│ │ Nichtigkeitswiderklage │ │ Antrag Patentänd. │ │
+│ └────────────────────────┘ └──────────────────────┘ │
+│ │
+│ ► Alle UPC-INF-Fristen anzeigen (Skip step 4) │
+└─────────────────────────────────────────────────────────────┘
+
+Below the tree at every step:
+
+┌─────────────────────────────────────────────────────────────┐
+│ Treffer ({count varies as path narrows}) │
+│ │
+│ Step 1 (Was ist passiert? → CMS-Eingang) │
+│ → ~25 concept cards (everything that follows from a CMS │
+│ receipt: defences, replies, appeals, opposition replies…) │
+│ │
+│ Step 3 (… → UPC INF) │
+│ → ~12 concept cards (UPC_INF deadline tree) │
+│ │
+│ Step 4 (… → Klageschrift) │
+│ → 2-3 concept cards: Klageerwiderung (mit/ohne CCR), │
+│ Anzeige Verteidigungsbereitschaft │
+└─────────────────────────────────────────────────────────────┘
+```
+
+**Decision-tree rules (LOCKED):**
+
+- **Depth → unlimited** (m's call). The cascade can grow as the taxonomy reveals deeper structures. Frontend renders one step at a time; depth is a property of `paliad.event_categories` data, never hard-coded in the UI. Most paths terminate at step 3-4 today; future taxonomy edits can extend without code changes.
+- **Clickable breadcrumb on every step** — visible when path-depth ≥ 1; each segment jumps back to that level. Optional inline back-arrow per segment.
+- **Reset button** on step 1 — restarts the cascade.
+- **Cards always visible below the cascade** — empty-set path shows "Keine Treffer für diesen Pfad" with a **"Schritt zurück"** link (one breadcrumb level back) and a secondary "Filter / Suche verwenden →" link to switch to B2.
+- **Skip-step link** ("Alle UPC-INF-Fristen anzeigen") at every non-leaf step lets the user finalise early without picking a sub-category. That click selects all sibling buttons' outcomes.
+- **URL bookmark** carries the path: `?path=b&mode=tree&b1=cms-eingang.gericht.hinweisbeschluss`. Refresh restores the cascade at that step.
+- **Direct deep-link** from the Phase D quick-pick chips: a chip click goes to B2 (filter mode), not B1.
+
+**Cards-at-each-step:** show only the candidates whose concept_id is reachable under the current path. Showing all candidates would defeat the narrowing affordance. Empty-result path → "Schritt zurück" link.
+
+### 3.5 B2 — Filter / Suche (Phase D continued)
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ 🔍 [ Klageerwiderung, RoP 23, § 82 … ] [ × ] │
+│ │
+│ Filter: │
+│ Verfahrensart ▾ Partei ▾ Rechtsquelle ▾ │
+│ Gericht/System ▾ │
+│ │
+│ Aktiv: [✕ Klageerwiderung] [✕ UPC] [✕ DE LG] [Alle löschen]│
+└─────────────────────────────────────────────────────────────┘
+
+(below: concept-card list, narrowing as filters AND together)
+```
+
+**Filter dimensions:**
+
+| Filter | Multi-select? | Source |
+|---|---|---|
+| Free-text query | n/a | matview pg_trgm |
+| Verfahrensart | yes | `paliad.proceeding_types.code` (8 UPC + 5 DE + 3 EPA + 3 DPMA) |
+| Partei | yes | `effective_party` (claimant / defendant / both / court) |
+| Rechtsquelle | yes | `legal_source` LIKE prefix (UPC.RoP / EU.EPÜ / EU.EPC-R / DE.ZPO / DE.PatG / EU.RPBA) |
+| **Gericht/System** | yes (NEW) | derived: maps proceeding_type → forum bucket |
+
+**Forum buckets — 10 (LOCKED, m + Maria 2026-05-05):**
+
+| # | Forum | Maps to proceeding_type_codes | When user picks |
+|---|---|---|---|
+| 1 | ⚖ UPC CFI | UPC_INF, UPC_REV, UPC_PI, UPC_DAMAGES, UPC_DISCOVERY, UPC_APP_ORDERS | "I'm at the UPC Court of First Instance (LD or CD — rules identical)" |
+| 2 | ⚖ UPC CoA | UPC_APP, UPC_COST_APPEAL | "Berufung beim UPC Berufungsgericht" |
+| 3 | 🏛 DE LG | DE_INF | "Verletzungsklage am Landgericht (1. Instanz)" |
+| 4 | 🏛 DE OLG | DE_INF_OLG | "Berufung am OLG" |
+| 5 | 🏛 DE BGH | DE_INF_BGH, DE_NULL_BGH, DPMA_BGH_RB | "Revision/NZB / Patentnichtigkeit-Berufung / DPMA-Rechtsbeschwerde am BGH" |
+| 6 | 🏛 DE BPatG | DE_NULL, DPMA_BPATG_BESCHWERDE | "Nichtigkeit am BPatG / Beschwerde gegen DPMA-Entscheidung" |
+| 7 | 🏛 EPA Erteilung | EP_GRANT | "EPA-Erteilungsverfahren (Prüfungsabteilung)" |
+| 8 | 🏛 EPA Einspruchsabt. | EPA_OPP | "EPA-Einspruchsverfahren" |
+| 9 | 🏛 EPA Beschwerdek. | EPA_APP | "EPA-Beschwerdeverfahren (Beschwerdekammer)" |
+| 10 | 🏛 DPMA | DPMA_OPP | "DPMA-Einspruchsverfahren" |
+
+**Cross-cutting concepts** (Wiedereinsetzung, Versäumnisurteil-Einspruch, Schriftsatznachreichung, Weiterbehandlung) — these surface as trigger-event pills regardless of forum filter (they apply across forums by design). Forum filter narrows only the rule pills inside each card.
+
+**Forum filter implementation:**
+
+- Backend: `GET /api/tools/fristenrechner/search` gains a `?forum=` query param (comma-separated forum slugs). Service translates slugs to proceeding_type codes via a hard-coded forum→proceeding_codes map.
+- Matview itself doesn't need a forum column — proceeding_type_code is sufficient and the map lives in Go.
+- Forum filter applies as `proceeding_code = ANY($forum_codes)` in the query. Trigger pills (`kind='trigger'`, `proceeding_code IS NULL`) bypass the forum filter (they always show).
+
+**AND-narrowing semantics (m's primary ask):**
+
+```
+ALL filters AND together. Adding a filter never INCREASES results.
+Removing a filter never DECREASES results.
+```
+
+Concrete: query "Klageerwiderung" + Verfahrensart=[UPC_INF] + Forum=[UPC] returns the UPC_INF Klageerwiderung pill only. Adding Forum=[DE LG] (multi-select) UNIONS within forum but keeps the AND with Verfahrensart, which then **becomes empty** (UPC_INF is not in [DE LG]). The UI shows this clearly: "0 Treffer für aktive Filter — letzten Filter entfernen" with a one-click undo.
+
+**Filter chip pills** display above the result list, X-removable, follow the existing `.akten-event-type-pill` shape from t-paliad-088.
+
+**Default state:** no filters selected. As soon as a user types or clicks a chip / dropdown, the filters apply. Empty filter state on B2 = the full unified concept-card list (paginated; show first 20).
+
+### 3.6 B1 ↔ B2 state sharing
+
+The decision tree is **a guided way to construct a filter combination**. Each B1 button click ≈ adds a filter chip in B2 land:
+
+| B1 step click | B2 filter equivalent |
+|---|---|
+| 📥 CMS-Eingang | (event_category filter set; no direct B2 chip) |
+| › Vom Gericht | (event_category narrowing) |
+| › Hinweisbeschluss | (event_category narrowing → 1 concept) |
+| › UPC INF | Verfahrensart=[UPC_INF] |
+| › Klageschrift | (event_category narrowing → maps to concept slug `statement-of-defence`) |
+
+When the user toggles from B1 → B2: any equivalent B2 filters are pre-set. `event_category` filters that don't have a direct B2 equivalent (e.g. "Vom Gericht" is a navigation node, not a filter property) get baked into the result-card prefilter so the cards remain narrowed; the chip representation shows them as `Pfad: 📥 CMS-Eingang › 👥 Gegenseite ✕`.
+
+When the user toggles from B2 → B1: B2 filters that DO map onto B1 steps (e.g. Verfahrensart=[UPC_INF] = step-3 = ⚖ UPC INF) automatically advance the cascade to that step. Any B2 filters that don't map (free-text query, party filter) display as a banner above the cascade ("Aktive Filter: 'klageerwiderung', Partei=Beklagte").
+
+The two views are **two presentations of one filter state**.
+
+---
+
+## 4. Data model
+
+### 4.1 New tables (Phase A migration)
+
+```sql
+-- The decision-tree taxonomy.
+CREATE TABLE paliad.event_categories (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ parent_id uuid REFERENCES paliad.event_categories(id) ON DELETE CASCADE,
+ slug text NOT NULL UNIQUE,
+ -- e.g. 'cms-eingang', 'cms-eingang.gericht',
+ -- 'cms-eingang.gericht.hinweisbeschluss'
+ -- Slug is the materialised path-with-dots.
+ label_de text NOT NULL,
+ label_en text NOT NULL,
+ description_de text,
+ -- Optional 1-line gloss shown under the button.
+ description_en text,
+ step_question_de text,
+ -- The question this node's CHILDREN answer. Rendered above the
+ -- button row at this level. NULL on leaves.
+ step_question_en text,
+ icon text,
+ -- Single emoji or icon slug for the button face. Optional.
+ sort_order int NOT NULL DEFAULT 100,
+ is_leaf bool NOT NULL DEFAULT false,
+ -- Leaf nodes get their concept outcomes via the junction table
+ -- below. Non-leaves are pure navigation.
+ is_active bool NOT NULL DEFAULT true,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX event_categories_parent_id ON paliad.event_categories (parent_id);
+CREATE INDEX event_categories_slug ON paliad.event_categories (slug);
+
+-- Leaf → concept(s) mapping. Many-to-many.
+-- A leaf can produce N candidate concepts. A concept can be reached via
+-- multiple leaves (e.g. Klageerwiderung is reachable from "CMS-Eingang
+-- › Gegenseite › Klageschrift" AND from a hypothetical "Was muss ich
+-- nach Klage einreichen?" path).
+CREATE TABLE paliad.event_category_concepts (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ event_category_id uuid NOT NULL REFERENCES paliad.event_categories(id) ON DELETE CASCADE,
+ concept_id uuid NOT NULL REFERENCES paliad.deadline_concepts(id) ON DELETE CASCADE,
+ proceeding_type_code text,
+ -- NULL = all contexts of the concept apply (concept-card shows
+ -- all its pills).
+ -- Set = limit the card's pill set to this proceeding (e.g.
+ -- leaf 'cms-eingang.gericht.hinweisbeschluss' → concept
+ -- 'response-to-preliminary-opinion' with proceeding_code
+ -- = 'DE_NULL' shows only the BPatG pill, not all
+ -- court-systems where Hinweisbeschluss exists).
+ sort_order int NOT NULL DEFAULT 100,
+ UNIQUE (event_category_id, concept_id, proceeding_type_code)
+);
+
+CREATE INDEX event_category_concepts_category ON paliad.event_category_concepts (event_category_id);
+CREATE INDEX event_category_concepts_concept ON paliad.event_category_concepts (concept_id);
+```
+
+**Why a separate junction with `proceeding_type_code`?** A leaf like "Beschluss/Entscheidung › Urteil DE_INF (LG)" → `notice-of-appeal` concept should narrow to the **DE_INF context** of that concept, not show the UPC and EPA pills. The junction's nullable `proceeding_type_code` carries that filter at the leaf-outcome level without polluting the deadline_concepts table.
+
+### 4.2 Why not category_path text[] on existing tables?
+
+Considered alternative: drop the new tables, add `category_path text[]` on `paliad.deadline_concepts` (and on `paliad.trigger_events.concept_id`-linked rows). Each concept declares which decision-tree breadcrumbs lead to it.
+
+**Rejected because:**
+
+1. **Internal navigation nodes have no concept.** "Vom Gericht" / "Von der Gegenseite" are pure step labels with no Frist outcome. A category_path on concepts can't represent them as first-class nodes; the UI would have to derive the tree shape from concept paths' shared prefixes, which is fragile when one prefix has zero concepts (e.g. step "Was war es?" with one button "Verlegt" leading to no Frist).
+
+2. **Step questions are tree properties, not concept properties.** "Was ist passiert?" / "Von wem?" / "In welchem Verfahrenstyp?" naturally live as `step_question_de` on the parent node. Storing them on concepts means duplicating across all leaves under that node.
+
+3. **Context-conditional outcomes.** A junction with nullable `proceeding_type_code` lets the same concept produce different pill sets at different leaves. category_path on concepts can't express that without a parallel junction anyway.
+
+4. **Taxonomy evolves independently of concepts.** The concept layer reflects what the law says (Klageerwiderung exists in ZPO/PatG/RoP/EPC). The taxonomy reflects how lawyers think about events — it can be refined without touching concept data.
+
+The two-table approach is +2 tables of additive schema; the category_path approach is 1 column but pushes complexity into the application layer. Two tables wins.
+
+### 4.3 Seed taxonomy (Phase A)
+
+The taxonomy data lives in migration 048_event_categories.up.sql. Inventor's draft seed (each block = one parent + its children + leaf→concept links). Numbers in `()` show approximate concept reach at that step.
+
+```
+ROOT — "Was ist passiert?" (~57 concepts visible if no narrowing)
+│
+├── 📥 cms-eingang "CMS-Eingang (Schriftstück erhalten)"
+│ step_question: "Von wem ist das Schriftstück?"
+│ │
+│ ├── ⚖ cms-eingang.gericht "Vom Gericht"
+│ │ step_question: "Welcher Charakter hat das Schriftstück?"
+│ │ │
+│ │ ├── 📋 cms-eingang.gericht.hinweisbeschluss [LEAF]
+│ │ │ "Hinweisbeschluss / Vorläufige Würdigung"
+│ │ │ → response-to-preliminary-opinion (DE_NULL)
+│ │ │ → response-to-preliminary-opinion (DE_INF)
+│ │ │
+│ │ ├── 📅 cms-eingang.gericht.ladung [LEAF]
+│ │ │ "Ladung zur mündlichen Verhandlung"
+│ │ │ → r116-final-submissions (EPA_OPP, EPA_APP)
+│ │ │ → schriftsatznachreichung (deferred — actual Frist nach Verhandlung)
+│ │ │ (no Frist set by Ladung itself; surfaces hint to user)
+│ │ │
+│ │ ├── 📨 cms-eingang.gericht.bescheid-mit-frist [LEAF]
+│ │ │ "Bescheid mit explizit gesetzter Frist"
+│ │ │ step continues: "Frist eintragen — kein Berechnungsbedarf"
+│ │ │ → meta-card: "Frist direkt eintragen — Datum aus Bescheid"
+│ │ │
+│ │ ├── 📨 cms-eingang.gericht.maengelbescheid [LEAF]
+│ │ │ "Mitteilung der Mängelbeseitigung"
+│ │ │ → cross-cutting trigger_events 71/74/80 (Mängelbeseitigung)
+│ │ │
+│ │ ├── 🏛 cms-eingang.gericht.endentscheidung "Endentscheidung / Urteil"
+│ │ │ step_question: "Welche Instanz / welches Verfahren?"
+│ │ │ │
+│ │ │ ├── ⚖ …urteil-de-inf "Urteil LG (Verletzung)" [LEAF]
+│ │ │ │ → notice-of-appeal (DE_INF_OLG)
+│ │ │ │ → statement-of-grounds-of-appeal (DE_INF_OLG)
+│ │ │ │
+│ │ │ ├── ⚖ …urteil-de-inf-olg "Urteil OLG (Verletzung)" [LEAF]
+│ │ │ │ → nichtzulassungsbeschwerde (DE_INF_BGH)
+│ │ │ │ → revisionsfrist (DE_INF_BGH)
+│ │ │ │ → revisionsbegruendung (DE_INF_BGH)
+│ │ │ │
+│ │ │ ├── ⚖ …urteil-de-null "Urteil BPatG (Nichtigkeit)" [LEAF]
+│ │ │ │ → notice-of-appeal (DE_NULL_BGH)
+│ │ │ │ → statement-of-grounds-of-appeal (DE_NULL_BGH)
+│ │ │ │
+│ │ │ ├── ⚖ …urteil-upc-cfi "Sachentscheidung UPC (CFI)" [LEAF]
+│ │ │ │ → notice-of-appeal (UPC_APP)
+│ │ │ │ → statement-of-grounds-of-appeal (UPC_APP)
+│ │ │ │
+│ │ │ ├── ⚖ …urteil-upc-coa "Sachentscheidung UPC (CoA)" [LEAF]
+│ │ │ │ → petition-for-review (UPC) [if exists]
+│ │ │ │
+│ │ │ ├── ⚖ …entscheidung-epa-opp "Einspruchsentscheidung EPA" [LEAF]
+│ │ │ │ → notice-of-appeal (EPA_APP)
+│ │ │ │ → statement-of-grounds-of-appeal (EPA_APP)
+│ │ │ │
+│ │ │ ├── ⚖ …entscheidung-epa-boa "Beschwerdeentscheidung EPA" [LEAF]
+│ │ │ │ → petition-for-review (EPA Art.112a)
+│ │ │ │
+│ │ │ ├── ⚖ …entscheidung-dpma "DPMA-Entscheidung" [LEAF]
+│ │ │ │ → notice-of-appeal (DPMA_BPATG_BESCHWERDE)
+│ │ │ │
+│ │ │ ├── ⚖ …beschluss-bpatg-beschwerde "Beschluss BPatG (DPMA-Beschwerde)" [LEAF]
+│ │ │ │ → rechtsbeschwerde (DPMA_BGH_RB)
+│ │ │ │
+│ │ │ └── 📊 …versaeumnisurteil "Versäumnisurteil (DE)" [LEAF]
+│ │ │ → versaeumnisurteil-einspruch (cross-cutting trigger)
+│ │ │
+│ │ ├── 💰 cms-eingang.gericht.kostenfestsetzung [LEAF]
+│ │ │ "Kostenfestsetzungsbeschluss"
+│ │ │ → notice-of-appeal (UPC_COST_APPEAL) — UPC R.157
+│ │ │
+│ │ ├── 🚫 cms-eingang.gericht.rechtsverlust-epa [LEAF]
+│ │ │ "Mitteilung über Rechtsverlust (EPA)"
+│ │ │ → weiterbehandlung (cross-cutting)
+│ │ │ → wiedereinsetzung (cross-cutting EU.EPÜ.122)
+│ │ │
+│ │ └── 📌 cms-eingang.gericht.anordnung [LEAF]
+│ │ "Anordnung (Order, z.B. PI, Beweissicherung)"
+│ │ → application-for-the-review-of-a-case-management-order
+│ │
+│ └── 👥 cms-eingang.gegenseite "Von der Gegenseite (Schriftsatz)"
+│ step_question: "In welchem Verfahrenstyp?"
+│ │
+│ ├── ⚖ cms-eingang.gegenseite.upc-inf
+│ │ step_question: "Welcher Schriftsatz wurde eingereicht?"
+│ │ │
+│ │ ├── 📜 …klageschrift [LEAF]
+│ │ │ "Klageschrift"
+│ │ │ → notice-of-defence-intention (UPC_INF) [does not apply at UPC]
+│ │ │ → statement-of-defence (UPC_INF) ← R.23
+│ │ │ → statement-of-defence (UPC_INF) — variants with CCR flag visible on the card
+│ │ │
+│ │ ├── 🔄 …klageerwiderung-mit-ccr [LEAF]
+│ │ │ "Klageerwiderung MIT Nichtigkeitswiderklage"
+│ │ │ → defence-to-counterclaim-for-revocation (UPC_INF)
+│ │ │ → application-to-amend (UPC_INF) [optional]
+│ │ │ → reply-to-defence (UPC_INF)
+│ │ │
+│ │ ├── 🔄 …klageerwiderung-ohne-ccr [LEAF]
+│ │ │ "Klageerwiderung OHNE Nichtigkeitswiderklage"
+│ │ │ → reply-to-defence (UPC_INF)
+│ │ │
+│ │ ├── ↩ …replik [LEAF]
+│ │ │ "Replik des Gegners"
+│ │ │ → rejoinder (UPC_INF)
+│ │ │
+│ │ ├── 🔁 …antrag-patentaenderung [LEAF]
+│ │ │ "Antrag auf Patentänderung (R.30)"
+│ │ │ → defence-to-application-to-amend (UPC_INF)
+│ │ │ → reply-to-defence-to-application-to-amend (UPC_INF)
+│ │ │
+│ │ ├── 🔁 …nichtigkeitswiderklage [LEAF]
+│ │ │ "Nichtigkeitswiderklage (R.25)"
+│ │ │ → defence-to-counterclaim-for-revocation (UPC_INF)
+│ │ │
+│ │ └── 📈 …berufungsschrift [LEAF]
+│ │ "Berufungsschrift Gegner"
+│ │ → response-to-appeal (UPC_APP)
+│ │ → cross-appeal (UPC_APP)
+│ │
+│ ├── ⚖ cms-eingang.gegenseite.upc-rev
+│ │ step_question: "Welcher Schriftsatz?"
+│ │ ├── 📜 …nichtigkeitsklage [LEAF]
+│ │ │ "Statement for Revocation"
+│ │ │ → defence-to-revocation (UPC_REV)
+│ │ ├── 🔄 …defence-to-revocation [LEAF]
+│ │ │ "Defence to revocation (mit/ohne Amend, mit/ohne CCI)"
+│ │ │ → reply-to-defence (UPC_REV)
+│ │ │ → defence-to-application-to-amend (UPC_REV) [if amend]
+│ │ │ → defence-to-counterclaim-for-infringement (UPC_REV) [if CCI]
+│ │ └── …
+│ │
+│ ├── 🏛 cms-eingang.gegenseite.de-inf "DE Verletzungsklage (LG)"
+│ │ step_question: "Welcher Schriftsatz?"
+│ │ ├── 📜 …klageschrift-de [LEAF]
+│ │ │ → notice-of-defence-intention (DE_INF) — ZPO §276(1)
+│ │ │ → statement-of-defence (DE_INF) — ZPO §276(1)
+│ │ ├── 🔄 …klageerwiderung-de [LEAF]
+│ │ │ → reply-to-defence (DE_INF) — ZPO §282
+│ │ ├── 📈 …berufungsschrift-de [LEAF]
+│ │ │ → response-to-appeal (DE_INF_OLG) — ZPO §521
+│ │ └── …
+│ │
+│ ├── 🏛 cms-eingang.gegenseite.de-null "DE Nichtigkeitsklage (BPatG)"
+│ │ step_question: "Welcher Schriftsatz?"
+│ │ ├── 📜 …nichtigkeitsklage-bpatg [LEAF]
+│ │ │ → statement-of-defence (DE_NULL) — PatG §82
+│ │ ├── 🔄 …klageerwiderung-bpatg [LEAF]
+│ │ │ → reply-to-defence (DE_NULL) — PatG §83
+│ │ └── …
+│ │
+│ ├── 🏛 cms-eingang.gegenseite.epa-opp "EPA Einspruch"
+│ │ ├── 📜 …einspruchsschrift [LEAF]
+│ │ │ → statement-of-defence (EPA_OPP) — EPC R.79
+│ │ └── …
+│ │
+│ ├── 🏛 cms-eingang.gegenseite.epa-app "EPA Beschwerde"
+│ │ ├── 📜 …beschwerdeschrift [LEAF]
+│ │ │ → response-to-appeal (EPA_APP) — RPBA Art.12
+│ │ └── …
+│ │
+│ └── 🏛 cms-eingang.gegenseite.dpma-opp "DPMA Einspruch"
+│ └── 📜 …einspruchsschrift-dpma [LEAF]
+│ → statement-of-defence (DPMA_OPP) — PatG §59(3)
+│
+├── 🎤 muendl-verhandlung "Mündliche Verhandlung"
+│ step_question: "Was ist mit der Verhandlung passiert?"
+│ ├── 📅 …geladen [LEAF]
+│ │ "Geladen — wann findet sie statt?"
+│ │ → r116-final-submissions (EPA_OPP) — EPC R.116
+│ │ → r116-final-submissions (EPA_APP)
+│ │ → schriftsatznachreichung (DE_INF) — ZPO §296a [tentative]
+│ ├── ⌛ …gehalten [LEAF]
+│ │ "Soeben gehalten / heute"
+│ │ → schriftsatznachreichung (DE_INF) — ZPO §296a (3 Wochen)
+│ └── 🔁 …verlegt [LEAF]
+│ "Verlegt"
+│ (no Frist — show informational card)
+│
+├── 📊 beschluss-entscheidung "Beschluss / Entscheidung erhalten"
+│ step_question: "Welche Art von Entscheidung?"
+│ │
+│ (Same children as cms-eingang.gericht.endentscheidung — explicit
+│ redundancy because users may mentally classify as "Entscheidung"
+│ rather than "CMS-Eingang vom Gericht". The leaves point at the
+│ same concept outcomes via the junction; no data duplication beyond
+│ the navigation node.)
+│
+├── 🚫 frist-verpasst "Frist verpasst"
+│ step_question: "In welchem System?"
+│ ├── 🏛 …de-patg [LEAF]
+│ │ "DE Patentverfahren (PatG §123)"
+│ │ → wiedereinsetzung (cross-cutting, trigger 200)
+│ ├── 🏛 …de-zpo [LEAF]
+│ │ "DE Zivilverfahren (ZPO §233 — 2 Wochen!)"
+│ │ → wiedereinsetzung (cross-cutting, trigger 201)
+│ ├── 🏛 …epa [LEAF]
+│ │ "EPA (Art. 122 EPÜ)"
+│ │ → wiedereinsetzung (cross-cutting, trigger 202)
+│ │ → weiterbehandlung (cross-cutting, trigger 206)
+│ └── 🏛 …dpma [LEAF]
+│ "DPMA (PatG §123)"
+│ → wiedereinsetzung (cross-cutting, trigger 203)
+│
+└── ❓ sonstiges "Anderes / Sonstiges"
+ "Filter / Suche verwenden →" (link to switch to B2)
+ (no children; or thin leaf set for high-frequency oddities like
+ Schutzschrift-Erneuerung if telemetry shows people getting stuck
+ here)
+```
+
+**Numbers:** ≈ 6 root buckets × 2-5 mid-level × 1-10 leaves ≈ 50-70 leaf nodes. Each leaf has 1-4 concept outcomes via the junction. Total: ≈ 50-70 leaves × ~2 outcomes = ≈ 120 junction rows. Manageable seed; spot-checkable on a single PR review.
+
+**Coverage check** vs concept count: 57 concepts in `paliad.deadline_concepts`. The taxonomy reaches every "submission" and "decision" concept. Pure-administrative concepts that are unlikely B-pathway candidates (`grant`, `publication`, `search-report`, `request-for-examination`, `filing`, `approval-and-translation`) are intentionally not surfaced from the decision tree — they're more naturally discovered via Pathway A (browse `EP_GRANT` proceeding type).
+
+### 4.4 Coverage check: every concept reachable somehow?
+
+Either via decision-tree leaf (B1), or via Pathway A tile (proceeding tree contains the rule), or via B2 free-text search. Inventor commitment: every concept in `paliad.deadline_concepts` is reachable from at least one of the three. The seed migration includes a CHECK query at the end:
+
+```sql
+-- After event_category_concepts is seeded, every concept that's
+-- "user-actionable" (category in {submission, order, decision, hearing})
+-- should be reachable from at least one event_categories leaf.
+-- Pure-administrative concepts (filing, grant, publication, search-report,
+-- request-for-examination, approval-and-translation) are exempt — they
+-- live on Pathway A only.
+DO $$
+DECLARE
+ unreachable_count int;
+BEGIN
+ SELECT count(*) INTO unreachable_count
+ FROM paliad.deadline_concepts dc
+ WHERE dc.is_active
+ AND dc.category IN ('submission', 'order', 'decision', 'hearing')
+ AND dc.slug NOT IN (
+ 'filing', 'grant', 'publication', 'search-report',
+ 'request-for-examination', 'approval-and-translation',
+ 'communication-r71-3'
+ )
+ AND NOT EXISTS (
+ SELECT 1 FROM paliad.event_category_concepts ecc
+ WHERE ecc.concept_id = dc.id
+ );
+ IF unreachable_count > 0 THEN
+ RAISE EXCEPTION 'Phase A seed: % concepts have no event_categories leaf', unreachable_count;
+ END IF;
+END $$;
+```
+
+This is the seed-correctness gate. Migration fails to apply if a user-actionable concept is orphaned from the taxonomy.
+
+### 4.5 No matview rebuild
+
+The existing `paliad.deadline_search` matview stays untouched — it indexes concept × rule and concept × trigger. The decision tree adds another **filter** dimension; it doesn't change what's searchable.
+
+The B1 backend reads:
+
+```sql
+-- Given a path like 'cms-eingang.gericht.hinweisbeschluss', return concepts.
+SELECT DISTINCT ecc.concept_id, ecc.proceeding_type_code
+FROM paliad.event_categories ec
+JOIN paliad.event_category_concepts ecc
+ ON ecc.event_category_id = ec.id
+WHERE ec.slug = $1
+ OR ec.slug LIKE $1 || '.%' -- include descendants
+ AND ec.is_active;
+```
+
+Then re-filters the existing matview:
+
+```sql
+SELECT * FROM paliad.deadline_search ds
+WHERE ds.concept_id = ANY($concept_ids)
+ AND (
+ $proceeding_type_codes IS NULL
+ OR ds.proceeding_code = ANY($proceeding_type_codes)
+ OR ds.kind = 'trigger' -- triggers always pass forum filter
+ );
+```
+
+Two queries, both indexed, total < 50 ms.
+
+---
+
+## 5. Backend changes
+
+### 5.1 New endpoint: GET /api/tools/fristenrechner/event-categories
+
+Returns the entire event_categories tree (small — ≈ 80 rows). Cached in Go service for the process lifetime, refreshed only on migration apply (same pattern as `paliad.deadline_search` mat-view refresh).
+
+```json
+GET /api/tools/fristenrechner/event-categories
+
+200:
+{
+ "tree": [
+ {
+ "id": "...",
+ "slug": "cms-eingang",
+ "label_de": "CMS-Eingang",
+ "label_en": "CMS Receipt",
+ "icon": "📥",
+ "step_question_de": "Von wem ist das Schriftstück?",
+ "is_leaf": false,
+ "sort_order": 100,
+ "children": [
+ {
+ "id": "...",
+ "slug": "cms-eingang.gericht",
+ "label_de": "Vom Gericht",
+ "icon": "⚖",
+ "step_question_de": "Welcher Charakter hat das Schriftstück?",
+ "is_leaf": false,
+ "children": [...]
+ },
+ ...
+ ]
+ },
+ ...
+ ]
+}
+```
+
+Frontend caches via `localStorage` (versioned by `ETag` from response header). Tree static enough that one fetch per user-month is sufficient.
+
+### 5.2 Extended endpoint: GET /api/tools/fristenrechner/search
+
+Existing endpoint (Phase D) gains two new query params:
+
+| Param | Type | Effect |
+|---|---|---|
+| `event_category_slug` | string | Restricts results to concepts reachable from that node (or any descendant). Implements B1 narrowing. |
+| `forum` | comma-separated string | Restricts pill set to proceeding_type_codes that map to the listed forums. Implements B2 forum filter. |
+
+Existing params (`q`, `proc`, `party`, `source`, `limit`) work identically.
+
+```
+GET /api/tools/fristenrechner/search
+ ?event_category_slug=cms-eingang.gericht.hinweisbeschluss
+ &forum=de_lg,de_bpatg
+ &q=Stellungnahme
+ &party=defendant
+```
+
+Back-compat: missing params behave as today's Phase D. New params layer on top.
+
+### 5.3 Forum-bucket map (Go-side, 10 buckets — LOCKED)
+
+```go
+// internal/services/fristen_search.go (extend existing)
+var forumToProceedingCodes = map[string][]string{
+ "upc_cfi": {"UPC_INF", "UPC_REV", "UPC_PI", "UPC_DAMAGES",
+ "UPC_DISCOVERY", "UPC_APP_ORDERS"},
+ "upc_coa": {"UPC_APP", "UPC_COST_APPEAL"},
+ "de_lg": {"DE_INF"},
+ "de_olg": {"DE_INF_OLG"},
+ "de_bgh": {"DE_INF_BGH", "DE_NULL_BGH", "DPMA_BGH_RB"},
+ "de_bpatg": {"DE_NULL", "DPMA_BPATG_BESCHWERDE"},
+ "epa_grant": {"EP_GRANT"},
+ "epa_opp": {"EPA_OPP"},
+ "epa_appeal": {"EPA_APP"},
+ "dpma": {"DPMA_OPP"},
+}
+```
+
+Map lives in Go (not in DB) because the buckets are a presentation choice, not a data property. Adding a forum bucket = code change, not migration.
+
+If telemetry later shows users want finer or coarser buckets, the map updates. The matview and seed don't change.
+
+---
+
+## 6. Frontend layout
+
+### 6.1 File-level changes
+
+| File | Change |
+|---|---|
+| `frontend/src/fristenrechner.tsx` | Reshape: top of page becomes the landing-fork component; Pathway A and Pathway B render as conditional sub-trees of the same page. The two existing tabs (`mode-procedure-tab` / `mode-event-tab`) MOVE — Verfahrensablauf becomes Pathway A's content; "Was kommt nach…" gets folded into Pathway B's B1+B2 (the trigger-event picker resurfaces as a special leaf in the B1 taxonomy: any leaf that points to a trigger_event id surfaces "Was kommt nach…" calculator inline). |
+| `frontend/src/client/fristenrechner.ts` | Add landing-fork toggle handler; URL-state parse for `?path=` + `?mode=`. Existing wizard logic stays. |
+| `frontend/src/client/fristenrechner-search.ts` (Phase D) | Reused as B2 engine. Move from "page-level top" to "Pathway B / B2 panel". |
+| `frontend/src/client/fristenrechner-tree.ts` (NEW, ~400 lines) | B1 decision-tree component. Renders the cascade from `/api/tools/fristenrechner/event-categories`, manages step state, dispatches narrow-events that the shared concept-card list listens to. |
+| `frontend/src/styles/global.css` | New rules: `.fristen-pathway-fork`, `.fristen-pathway-card`, `.fristen-tree-cascade`, `.fristen-tree-step`, `.fristen-tree-button`, `.fristen-tree-breadcrumb`, `.fristen-mode-toggle`, `.fristen-forum-filter`, `.fristen-forum-chip`, `.fristen-forum-chip--active`. |
+| `frontend/src/client/i18n.ts` | ~25 new keys: `deadlines.pathway.*` (a-fork, b-fork, mode toggle), `deadlines.tree.*` (step labels, back, reset, breadcrumb), `deadlines.filter.forum.*` (chip labels). |
+
+### 6.2 No new bundle
+
+The existing `frontend/build.ts` already produces `fristenrechner.js` from `frontend/src/client/fristenrechner.ts` — adding new modules grows that bundle (~600 lines for `fristenrechner-tree.ts`). No new entry point.
+
+### 6.3 Drill-in semantics from concept-card pills (unchanged from Phase D)
+
+Pill click on a result card → navigates to:
+- `?path=a&proc=UPC_INF&focus=inf.sod` for proceeding rule pills (Pathway A)
+- (special) trigger-event pills currently land on the legacy "Was kommt nach…" tab. v3 removes that tab. New behaviour: trigger-event pills open a small inline calculator panel within the result card itself ("Trigger eingetreten am: ___ → Folgefristen"). No page navigation; the user stays in B1/B2 context.
+
+The inline trigger-event calculator panel is a Phase C deliverable. Until shipped, the trigger-event pill click temporarily routes to `?path=a&trigger=` — Pathway A grows a thin "trigger-event mode" alongside the proceeding-tree mode, equivalent to what the legacy "Was kommt nach…" tab did.
+
+---
+
+## 7. Phasing
+
+### Phase A — Data: event_categories + seed taxonomy (1 PR)
+
+- Migration 048: `paliad.event_categories` + `paliad.event_category_concepts` schemas.
+- Migration 049: seed taxonomy (≈ 70 leaves, ≈ 120 junction rows). Inline `DO $$` block enforces every user-actionable concept is reachable.
+- Service layer: `EventCategoryService` (List, GetTree, GetConceptIDsForSlug-with-descendants).
+- Handler: `GET /api/tools/fristenrechner/event-categories` (returns nested JSON; cached).
+- Tests: golden tree-shape snapshot; concept-coverage assertion.
+
+No UX change. Backend extension only. Spot-checkable as a single PR.
+
+### Phase B — Landing fork UI (1 PR)
+
+- Reshape `frontend/src/fristenrechner.tsx` to render the landing fork at the top, conditional Pathway A / Pathway B sub-trees below.
+- `?path=` URL state, default landing = fork.
+- Pathway A wraps the existing Verfahrensablauf wizard verbatim (no logic change).
+- Pathway B shell with mode toggle (B1 / B2 radio) but B1/B2 panels are stubs.
+- Quick-pick chips relocate from above-tabs to below-fork; chip click → `?path=b&mode=filter&q=`.
+- Legacy tabs hidden behind `?legacy=1` for parity testing during transition.
+
+### Phase C — B1 decision tree (1 PR)
+
+- `frontend/src/client/fristenrechner-tree.ts`: cascade renderer + step state machine + back/reset.
+- Wire to `GET /api/tools/fristenrechner/search?event_category_slug=…` (extends Phase D handler).
+- Result-card area shared with B2 — tree-narrow events update the same card list.
+- URL state: `?b1=cms-eingang.gericht.hinweisbeschluss` round-trips.
+- Empty-path leaf shows "Frist eintragen — kein Berechnungsbedarf" meta-card.
+- Trigger-event leaves (cross-cutting Wiedereinsetzung etc.) open inline calculator panel.
+
+### Phase D — B2 filter expansion (1 PR)
+
+- Add `?forum=` query param + Go-side bucket map.
+- Frontend forum-chip filter row (multi-select chips with X-removable active-filter pills).
+- AND-narrowing semantics correctness check (Playwright golden tests with combinations).
+- 0-result UX with "letzten Filter entfernen" undo.
+- B1↔B2 state transitions: switching between modes preserves filters where mappable.
+
+### Phase E — Cleanup (gate-gated, deferred)
+
+- Retire `mode-procedure-tab` / `mode-event-tab` legacy DOM (drop ~200 lines of frontend code).
+- Drop the `?legacy=1` parity escape hatch.
+- Drop "Klassische Ansicht" footer link.
+- Coder shift only after m's go-direction; default = keep legacy until telemetry says no one uses it.
+
+**Each phase is independently shippable** — A is purely data, B is the structural change without B1/B2 logic, C and D are the substantive Pathway-B implementations.
+
+---
+
+## 8. Out of scope (deliberate)
+
+- **AI-driven event detection from court PDFs** (Phase H from v2; deferred).
+- **Per-user / firm-level event_categories overrides.** v3 is curated-only; the taxonomy is a maintained corpus, not user-editable.
+- **Dynamic decision-tree edges.** No "if X then offer Y" rules at the data-driven level — sub-buttons are static children of the parent. If a future leaf needs conditional offering, model it as a deeper level rather than a runtime rule.
+- **Event taxonomy as a reusable axis on `paliad.deadlines`.** The decision tree informs Frist-creation but doesn't tag the resulting Frist with its event_category. (That bridge could be built later — t-paliad-088's event_types is the natural place to merge taxonomies.)
+- **Mobile-first redesign of the proceeding tile grid.** Stays as today.
+- **Forum filter beyond DE/UPC/EPA/DPMA.** No CFI-vs-CoA-vs-LD breakdown for UPC; rules are shared at that level. If telemetry says LD-vs-CD matters, expand the bucket map.
+
+---
+
+## 9. Risks & mitigations
+
+| Risk | Severity | Mitigation |
+|---|---|---|
+| Decision-tree taxonomy gets stale as concept layer evolves | Medium | Phase A migration ends with a `DO $$` orphan-check; future concept additions that don't add an event_category leaf fail the migration |
+| Seed taxonomy mis-classifies an event (wrong concept outcomes) | Medium | Each leaf surface ≤ 4 concepts; spot-checkable on PR; m or a colleague reviews at concept-coverage matrix in the PR description |
+| B1 cascade feels claustrophobic vs B2's open search | Low | "Filter / Suche verwenden →" link visible at every step; "Skip step" affordance lets users finalise early; B1↔B2 mid-flow toggle preserves state |
+| Forum filter buckets diverge from user mental model | Medium | Bucket map lives in Go (not DB); rebucket = code change; first-cut buckets matched to existing proceeding_types so coverage is testable |
+| Legacy users bookmarked `/tools/fristenrechner?proc=…` URLs from the old layout | Low | URL params preserved — `?proc=…` auto-redirects to `?path=a&proc=…`; same handler reads them |
+| Event_categories table grows large and joins get expensive | Low | Mat-view stays primary search engine; event_categories is a join filter, ≤ 100 rows total, fits L1 cache |
+| Trigger-event pill drill-in regresses while Phase C inline-calculator unfinished | Medium | Phase B ships the `?path=a&trigger=` shim that mirrors the legacy "Was kommt nach…" tab — pill click works end-to-end before Phase C lands its inline calculator |
+| Seeding 120 junction rows is error-prone | Medium | Inline orphan-check + Playwright golden test ("click CMS-Eingang › Gericht › Hinweisbeschluss → see exactly these 2 cards") pins the tree shape |
+| Existing `?q=…` URLs from Phase D cease to work | Low | `?q=` in absence of `?path=` redirects to `?path=b&mode=filter&q=…`; same backend |
+
+---
+
+## 10. Locked decisions (m, 2026-05-05 10:33)
+
+m approved all 12 questions in one batch. Locked spec for the implementation:
+
+1. **Legacy tabs → RETIRED in Phase E.** "We are only resorting" — no second-class fallback. Drop `mode-procedure-tab` / `mode-event-tab` DOM and their client logic; remove the `?legacy=1` escape hatch.
+
+2. **Decision-tree depth → UNLIMITED.** m: "theoretisch unbegrenzt — neue Konstruktion, ich habe noch nicht alle Konstellationen durchdacht." The cascade can grow as the taxonomy reveals deeper structures we hadn't enumerated yet. Frontend renders one step at a time; depth is a property of the data, not a hard-coded UI constant.
+
+3. **Navigation → CLICKABLE BREADCRUMB.** Each completed step is clickable to jump back to that level. Equivalent visualisations are fine (e.g. inline back-arrows per breadcrumb segment); the literal "← zurück" button on the previous step can stay or go — breadcrumb suffices.
+
+4. **Partial-path bookmarks → YES.** URL `?b1=cms-eingang.gericht.hinweisbeschluss` round-trips. Refresh restores the cascade at that node.
+
+5. **Forum filter → MULTI-SELECT.** m: "usually we will only want one" — so the chip group defaults to **one chip pre-selected** (or none, with a hint), but the user can hold-shift / cmd-click to multi-select. Adding a 2nd chip UNIONs within forum, AND-intersected with other filters.
+
+6. **Cards at each B1 step → PATH-MATCHING ONLY.** Empty result set shows a **"Schritt zurück"** link (NOT "Pfad lockern" — m: "should be 'Schritt zurück' or similar"). The link sends the user back one breadcrumb level.
+
+7. **Visual language → EMOJIS, no separate colour treatment.** m: "Colors for what?! Emojis are okay generally." Use the same lime-green accent as the rest of Paliad; pathway / step / forum buttons differentiate via emoji (📖 / 📅 / 📥 / 👥 / ⚖ / 🎤 / 📊 / 🚫 / ❓). No purple-for-Lernen / blue-for-Eintragen distinction.
+
+8. **Forum buckets → COURT-SYSTEM granularity (10 buckets):** UPC CFI (LD + CD combined per m: "UPC CFI") · UPC CoA · DE LG · DE OLG · DE BGH · DE BPatG · EPA Erteilung (Prüfungsabt.) · EPA Einspruchsabt. · EPA Beschwerdek. · DPMA. Maria's clarification at 10:16 was about court-system level; m's 10:33 simplified UPC to CFI vs CoA (LD/CD merged because the rules are identical). Map lives in Go (`forumToProceedingCodes`); rebucketing = code change, no migration.
+
+9. **B1↔B2 state-sharing → YES.** Same backend filter state, two presentations. Switching between modes preserves filters where mappable; B2 chips that don't map onto B1 steps surface as a banner ("Aktive Filter: …").
+
+10. **Phasing → SINGLE BRANCH, SEQUENTIAL COMMITS, ONE FINAL MERGE.** m: "no need to separate into PRs, just commits — and in the end we merge." All implementation lands on `mai/cronus/fristenrechner-v3-pathways` as commits A → B → C → D → E. Final merge is a single `--no-ff` to `main`.
+
+11. **Party perspective default → CLAIMANT / PROACTIVE.** URL persists `?my_side=claimant|defendant` (literal column-side semantics: the chosen side becomes the Proactive column). For role-swap proceedings (UPC_APP, DE_INF_OLG, DE_INF_BGH, DE_NULL_BGH, DPMA_BPATG_BESCHWERDE, DPMA_BGH_RB, EPA_APP, UPC_COST_APPEAL), an additional toggle `?appeal_filed_by=claimant|defendant` flips first-instance-claimant ↔ second-instance-appellant mapping. localStorage remembers the last-used choice across sessions.
+
+12. **"Both parties" rule mirroring → BILATERAL FLAG ON RULE ROW.** New column `paliad.deadline_rules.is_bilateral bool NOT NULL DEFAULT false`. When `is_bilateral=true` AND `primary_party='both'`, the rule renders in BOTH party columns (today's behaviour). Otherwise `primary_party='both'` rules apply to the side that actually took the action — which is determined per-rule from the parent rule's party (or, if root, from the perspective selector). Genuinely-bilateral rules (Schriftsatzfristen with Anwaltszwang, mündliche Verhandlung, joint Mängelbeseitigung) are flagged in seed migration 050. Spot-checkable list (~5 rules expected); m or HLC colleague reviews.
+
+### Maria's scope additions (folded in)
+
+**Court-system granularity for forum filter** (head 10:16): the v2 Q8 "drop forum" was about not differentiating Munich vs Paris within ONE UPC LD — not about dropping the system axis. v3 brings the system axis back; locked at the 10-bucket list above.
+
+**Party-perspective selector** (head 10:21, absorbs t-paliad-132): `Ich vertrete: ◉ Klägerseite ◯ Beklagtenseite` selector above the columns view. For appeal proceedings: additional `Wer hat Berufung eingelegt?` toggle. Columns re-flow: Proactive = user's perspective, Reactive = other side. Bilateral rules respect the new `is_bilateral` flag (Q12). The B1 decision tree implicitly encodes perspective: "CMS-Eingang → ich habe etwas erhalten" = Reactive, "Ich will einreichen" = Proactive. Cards in B2 may render party-relative ("Erwiderungsfrist FÜR DICH"). Closes t-paliad-132 as done-by-v3.
+
+### What this means for the spec elsewhere in the doc
+
+Sections updated to match the locked decisions:
+- §3.4 — tree depth language changed from "max 4" to "unlimited; cascade renders one step at a time".
+- §3.4 — "Pfad lockern" empty-state link renamed to "Schritt zurück".
+- §3.5 — forum bucket count changed from 7 to 10; LD/OLG/BGH/BPatG distinctions kept; UPC split into CFI / CoA; EPA split into Erteilung / Einspruchsabt. / Beschwerdek.
+- §4.1 — `paliad.deadline_rules` migration adds `is_bilateral bool NOT NULL DEFAULT false` column; seed migration 050 backfills the genuinely-bilateral rules.
+- §5.2 — extended endpoint section gains `?my_side=` and `?appeal_filed_by=` query params with the rule-mirroring contract.
+- §6.1 — frontend file list adds `frontend/src/client/perspective.ts` (~150 lines) for the party-perspective selector + appeal-filed-by toggle + URL state machine.
+- §7 — phasing kept (A → E) but single-branch single-merge per m's "just commits" call.
+
+---
+
+## 11. Proposed cycle
+
+- **Inventor (this shift, cronus, branch `mai/cronus/fristenrechner-v3-pathways`):** v3 design doc + go/no-go gate (this commit).
+- **Coder (next shift, after m's go-ahead):** Phase A (1 PR) — schema + seed + handler + tests.
+- **Coder (third shift):** Phase B (1 PR) — landing fork + URL state + Pathway A wrap + Pathway B shell with stub panels.
+- **Coder (fourth shift):** Phase C (1 PR) — B1 decision tree + state machine + tree narrow integration with concept-card list.
+- **Coder (fifth shift):** Phase D (1 PR) — B2 forum filter + AND-narrowing correctness + B1↔B2 state share.
+- **Coder (sixth shift, gate-gated):** Phase E (1 PR) — retire legacy tabs IF m approves at that point.
+
+Inventor recommends **cronus** (continuity with t-paliad-131) or **knuth** (frontend-heavy) as implementer. Phase A is data-only and equally fits **curie** (port-heavy seed work — same shape as t-paliad-084 / t-paliad-086). Head decides.
+
+---
+
+## Appendix A — Files & references
+
+**Code paths read / cited:**
+
+- `frontend/src/fristenrechner.tsx` (current 320-line page shell — search bar + 2-tab wizard)
+- `frontend/src/client/fristenrechner.ts` (~1500 lines — proceeding wizard + event mode + dates)
+- `frontend/src/client/fristenrechner-search.ts` (~600 lines — Phase D concept-card search)
+- `internal/services/fristenrechner.go` (calculator with AnchorOverrides + condition_flag arrays)
+- `internal/services/event_deadline_service.go` (trigger-event calculator, untouched)
+- `internal/handlers/fristenrechner.go` (Phase D search endpoint)
+- `internal/db/migrations/037-046` (concept layer + B1-B6 coverage from t-131)
+- `internal/db/migrations/047_deadline_search_view.up.sql` (matview, untouched in v3)
+
+**DB read:**
+
+- `paliad.proceeding_types` (19 active fristenrechner-category — 8 UPC + 5 DE + 3 EPA + 3 DPMA)
+- `paliad.deadline_concepts` (57 concepts, all categories — submission/decision/order/hearing/...)
+- `paliad.deadline_rules` (137 rules)
+- `paliad.trigger_events` (107 active — 100 youpc UPC + 7 paliad-native cross-cutting at id ≥ 200)
+- `paliad.deadline_search` (matview — UNION of rule + trigger × concept)
+
+**Prior work:**
+
+- `docs/plans/unified-fristenrechner.md` (v2 — search-by-concept + coverage; v3 inherits all of §4-§6 unchanged)
+- t-paliad-131 — what just shipped (Phases A-D over 9 PRs)
+- t-paliad-088 — Event Types on `paliad.deadlines` (junction layer; could share taxonomy slugs with v3 in a future merger)
+- t-paliad-129 — column-timeline view (out of scope here)
+- t-paliad-132 — column-timeline party-perspective selector (parked, related)
+- m's note (2026-05-05 10:06) — source of truth for the restructuring brief
+
+**Memory references (mai-memory):**
+
+- `paliad t-paliad-131 — Phase B complete (7 PRs shipped, unified Fristenrechner)`
+- `Design: Event Types for deadlines + submissions (t-paliad-088, cronus)`
+- `paliad t-paliad-088 SHIPPED — Event Types live, smoke 6/6 pass (cronus)`
+
+---
+
+**End of design doc.**
+
+Inventor stays parked on this branch. Next step: m's go/no-go on the open questions in §10. After approval, head assigns a coder for Phase A.
diff --git a/frontend/src/client/fristenrechner.ts b/frontend/src/client/fristenrechner.ts
index 5dc8820..a8b17ae 100644
--- a/frontend/src/client/fristenrechner.ts
+++ b/frontend/src/client/fristenrechner.ts
@@ -1278,7 +1278,14 @@ async function runSearch() {
const seq = ++searchSeq;
let resp: Response;
try {
- resp = await fetch(`/api/tools/fristenrechner/search?q=${encodeURIComponent(q)}&limit=12`, { credentials: "same-origin" });
+ {
+ const searchURL = new URL("/api/tools/fristenrechner/search", window.location.origin);
+ searchURL.searchParams.set("q", q);
+ searchURL.searchParams.set("limit", "12");
+ const forums = getActiveForumsParam();
+ if (forums) searchURL.searchParams.set("forum", forums);
+ resp = await fetch(searchURL.toString(), { credentials: "same-origin" });
+ }
} catch {
if (seq !== searchSeq) return;
results.classList.remove("is-loading");
@@ -1448,9 +1455,14 @@ function drillToProceeding(procCode: string, focusCode: string | null) {
}
function drillToTrigger(triggerId: number) {
- const eventTab = document.getElementById("mode-event-tab");
- if (eventTab) eventTab.click();
- // Defer a tick so the tab activation has run before we touch event-mode state.
+ // v3 (Phase E): legacy tabs are gone. Show the event panel directly.
+ // Triggered from concept-card pill clicks; routes via Pathway A so the
+ // Verfahrensablauf user surface stays consistent.
+ const procedurePanel = document.getElementById("mode-procedure-panel");
+ const eventPanel = document.getElementById("mode-event-panel");
+ if (procedurePanel) procedurePanel.hidden = true;
+ if (eventPanel) eventPanel.hidden = false;
+ // Defer a tick so the panel swap has rendered before we touch state.
window.setTimeout(() => {
selectTriggerEvent(triggerId);
document.getElementById("event-step-2")?.scrollIntoView({ behavior: "smooth", block: "start" });
@@ -1554,3 +1566,550 @@ function initSearch() {
// Wire on DOM ready (the existing DOMContentLoaded handler is already busy;
// add a lightweight follow-up listener to keep the diff small).
document.addEventListener("DOMContentLoaded", initSearch);
+
+// ============================================================================
+// v3 pathway fork (t-paliad-133)
+// ============================================================================
+// Three-state landing surface: fork (default), Pathway A (Verfahrensablauf —
+// existing wizard), Pathway B (Frist eintragen — search/B1/B2). URL ?path=
+// drives visibility; localStorage remembers the last-used pathway for soft
+// re-entry. ?legacy=1 keeps the pre-v3 layout (no fork) for parity testing
+// during the rollout window.
+
+type Pathway = "fork" | "a" | "b";
+type BMode = "tree" | "filter";
+
+const PATHWAY_STORAGE_KEY = "paliad.fristen.pathway";
+
+function readPathwayFromURL(): Pathway {
+ const sp = new URLSearchParams(window.location.search);
+ const p = sp.get("path");
+ if (p === "a" || p === "b") return p;
+ return "fork";
+}
+
+function readBModeFromURL(): BMode {
+ const sp = new URLSearchParams(window.location.search);
+ const m = sp.get("mode");
+ if (m === "tree" || m === "filter") return m;
+ // Default: tree mode (B1 cascade is the discovery surface; the
+ // free-text/filter B2 mode is for power users who already know what
+ // they want).
+ return "tree";
+}
+
+function setPathwayURL(path: Pathway, mode?: BMode, replace = false) {
+ const url = new URL(window.location.href);
+ if (path === "fork") {
+ url.searchParams.delete("path");
+ url.searchParams.delete("mode");
+ url.searchParams.delete("b1");
+ } else {
+ url.searchParams.set("path", path);
+ if (path === "b" && mode) {
+ url.searchParams.set("mode", mode);
+ } else {
+ url.searchParams.delete("mode");
+ }
+ }
+ if (replace) {
+ window.history.replaceState({}, "", url.toString());
+ } else {
+ window.history.pushState({}, "", url.toString());
+ }
+}
+
+function showPathway(path: Pathway, mode?: BMode) {
+ const fork = document.getElementById("fristen-pathway-fork");
+ const a = document.getElementById("fristen-pathway-a");
+ const b = document.getElementById("fristen-pathway-b");
+ if (!fork || !a || !b) return;
+
+ fork.hidden = path !== "fork";
+ a.hidden = path !== "a";
+ b.hidden = path !== "b";
+
+ if (path === "b") {
+ showBMode(mode || readBModeFromURL());
+ }
+}
+
+function showBMode(mode: BMode) {
+ const tree = document.getElementById("fristen-b1-panel");
+ const filter = document.getElementById("fristen-b2-panel");
+ const treeRadio = document.getElementById("fristen-b-mode-tree") as HTMLInputElement | null;
+ const filterRadio = document.getElementById("fristen-b-mode-filter") as HTMLInputElement | null;
+ if (!tree || !filter) return;
+ tree.hidden = mode !== "tree";
+ filter.hidden = mode !== "filter";
+ if (treeRadio) treeRadio.checked = mode === "tree";
+ if (filterRadio) filterRadio.checked = mode === "filter";
+
+ // Phase B B1 stub — will be replaced by the real cascade in Phase C.
+ if (mode === "tree") {
+ const cascade = document.getElementById("fristen-b1-cascade");
+ if (cascade && cascade.childElementCount === 0) {
+ cascade.innerHTML =
+ `${escHtml(t("deadlines.pathway.b.tree.coming_soon"))}
`;
+ }
+ }
+}
+
+function navigateToPathway(path: Pathway, mode?: BMode) {
+ setPathwayURL(path, mode);
+ showPathway(path, mode);
+ if (path !== "fork") {
+ try {
+ localStorage.setItem(PATHWAY_STORAGE_KEY, path);
+ } catch { /* private mode */ }
+ }
+}
+
+function initPathwayFork() {
+ // Initial render from URL (or saved preference if URL is bare).
+ const initial = readPathwayFromURL();
+ const initialMode = readBModeFromURL();
+ showPathway(initial, initialMode);
+
+ // Persist initial choice from URL.
+ if (initial !== "fork") {
+ try { localStorage.setItem(PATHWAY_STORAGE_KEY, initial); } catch { /* */ }
+ }
+
+ // Click handlers on the two fork cards.
+ document.getElementById("fristen-pathway-a-cta")?.addEventListener("click", () => {
+ navigateToPathway("a");
+ });
+ document.getElementById("fristen-pathway-b-cta")?.addEventListener("click", () => {
+ // Default to tree mode on first entry to Pathway B.
+ navigateToPathway("b", "tree");
+ });
+
+ // Back-to-fork buttons inside each pathway shell.
+ document.getElementById("fristen-pathway-a-back")?.addEventListener("click", () => {
+ navigateToPathway("fork");
+ });
+ document.getElementById("fristen-pathway-b-back")?.addEventListener("click", () => {
+ navigateToPathway("fork");
+ });
+
+ // B1/B2 mode toggle inside Pathway B.
+ const bModeRadios = document.querySelectorAll("input[name='fristen-b-mode']");
+ bModeRadios.forEach((r) => {
+ r.addEventListener("change", () => {
+ if (!r.checked) return;
+ const mode: BMode = r.value === "tree" ? "tree" : "filter";
+ setPathwayURL("b", mode);
+ showBMode(mode);
+ });
+ });
+
+ // Quick-pick chips on the fork shortcut row → jump straight to Pathway B + filter mode + prefilled query.
+ document.querySelectorAll("#fristen-fork-chips .fristen-search-chip").forEach((chip) => {
+ chip.addEventListener("click", () => {
+ const q = chip.dataset.q || "";
+ const url = new URL(window.location.href);
+ url.searchParams.set("path", "b");
+ url.searchParams.set("mode", "filter");
+ if (q) url.searchParams.set("q", q);
+ window.history.pushState({}, "", url.toString());
+ showPathway("b", "filter");
+ // initSearch listens for popstate, but we used pushState; sync the
+ // search input directly.
+ const input = document.getElementById("fristen-search-input") as HTMLInputElement | null;
+ if (input && q) {
+ input.value = q;
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ }
+ });
+ });
+
+ // Browser back/forward should restore pathway state.
+ window.addEventListener("popstate", () => {
+ const path = readPathwayFromURL();
+ const mode = readBModeFromURL();
+ showPathway(path, mode);
+ });
+}
+
+document.addEventListener("DOMContentLoaded", initPathwayFork);
+
+// ============================================================================
+// v3 B1 decision tree (t-paliad-133 Phase C)
+// ============================================================================
+// Data-driven cascade: fetch the event-categories tree from
+// GET /api/tools/fristenrechner/event-categories, render the current
+// step's button set, walk down on click, show breadcrumb + reset.
+// Result cards below come from /api/tools/fristenrechner/search with
+// ?event_category_slug= narrowing.
+
+interface EventCategoryNode {
+ id: string;
+ slug: string;
+ label_de: string;
+ label_en: string;
+ description_de?: string;
+ description_en?: string;
+ step_question_de?: string;
+ step_question_en?: string;
+ icon?: string;
+ sort_order: number;
+ is_leaf: boolean;
+ children?: EventCategoryNode[];
+}
+
+let eventCategoryTree: EventCategoryNode[] | null = null;
+let eventCategoryFetchInflight: Promise | null = null;
+
+async function loadEventCategoryTree(): Promise {
+ if (eventCategoryTree) return eventCategoryTree;
+ if (eventCategoryFetchInflight) return eventCategoryFetchInflight;
+ eventCategoryFetchInflight = (async () => {
+ try {
+ const r = await fetch("/api/tools/fristenrechner/event-categories");
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ const data = await r.json();
+ eventCategoryTree = (data.tree || []) as EventCategoryNode[];
+ return eventCategoryTree;
+ } finally {
+ eventCategoryFetchInflight = null;
+ }
+ })();
+ return eventCategoryFetchInflight;
+}
+
+function readB1PathFromURL(): string {
+ return new URLSearchParams(window.location.search).get("b1") || "";
+}
+
+function setB1PathInURL(slug: string, replace = false) {
+ const url = new URL(window.location.href);
+ if (slug) {
+ url.searchParams.set("b1", slug);
+ } else {
+ url.searchParams.delete("b1");
+ }
+ if (replace) {
+ window.history.replaceState({}, "", url.toString());
+ } else {
+ window.history.pushState({}, "", url.toString());
+ }
+}
+
+function findNodeBySlug(roots: EventCategoryNode[], slug: string): EventCategoryNode | null {
+ for (const root of roots) {
+ if (root.slug === slug) return root;
+ if (root.children) {
+ const inner = findNodeBySlug(root.children, slug);
+ if (inner) return inner;
+ }
+ }
+ return null;
+}
+
+function buildBreadcrumb(roots: EventCategoryNode[], slug: string): EventCategoryNode[] {
+ // Slug is dot-separated; walk down each segment.
+ if (!slug) return [];
+ const parts = slug.split(".");
+ const trail: EventCategoryNode[] = [];
+ let scope = roots;
+ let cumulative = "";
+ for (const seg of parts) {
+ cumulative = cumulative ? `${cumulative}.${seg}` : seg;
+ const node = scope.find((n) => n.slug === cumulative);
+ if (!node) break;
+ trail.push(node);
+ scope = node.children || [];
+ }
+ return trail;
+}
+
+function nodeLabel(n: EventCategoryNode): string {
+ return getLang() === "de" ? n.label_de : n.label_en;
+}
+
+function nodeStepQuestion(n: EventCategoryNode): string {
+ return getLang() === "de"
+ ? (n.step_question_de || "")
+ : (n.step_question_en || n.step_question_de || "");
+}
+
+function renderB1Cascade(currentSlug: string) {
+ const cascade = document.getElementById("fristen-b1-cascade");
+ if (!cascade || !eventCategoryTree) return;
+
+ const trail = buildBreadcrumb(eventCategoryTree, currentSlug);
+ const node = trail.length > 0 ? trail[trail.length - 1] : null;
+ const childScope = node ? (node.children || []) : eventCategoryTree;
+
+ const breadcrumbHtml = trail.length === 0
+ ? ""
+ : `
+
+ ${escHtml(t("deadlines.pathway.b.tree.reset"))}
+
+ ${trail.map((c, i) =>
+ `›
+
+ ${c.icon ? `${escHtml(c.icon)} ` : ""}${escHtml(nodeLabel(c))}
+ `).join("")}
+ `;
+
+ const question = node && node.step_question_de
+ ? `${escHtml(nodeStepQuestion(node))}
`
+ : trail.length === 0
+ ? `${escHtml(t("deadlines.pathway.b.tree.start_question") || "Was ist passiert?")}
`
+ : "";
+
+ let buttonsHtml = "";
+ if (childScope.length > 0) {
+ buttonsHtml = `${
+ childScope.map((c) =>
+ `
+ ${c.icon ? `${escHtml(c.icon)} ` : ""}
+ ${escHtml(nodeLabel(c))}
+ `).join("")
+ }
`;
+ }
+
+ // Skip-step affordance on non-leaf intermediate nodes.
+ let skipHtml = "";
+ if (node && !node.is_leaf && childScope.length > 0) {
+ skipHtml = `
+ ${escHtml(t("deadlines.pathway.b.tree.skip"))}
+ `;
+ }
+
+ // Step-back affordance on any non-root state.
+ let backHtml = "";
+ if (trail.length > 0) {
+ const parentSlug = trail.length > 1 ? trail[trail.length - 2].slug : "";
+ backHtml = `
+ ← ${escHtml(t("deadlines.pathway.b.tree.step.back"))}
+ `;
+ }
+
+ cascade.innerHTML = `${breadcrumbHtml}${question}${buttonsHtml}${skipHtml}${backHtml}`;
+
+ // Wire button clicks.
+ cascade.querySelectorAll(".fristen-b1-button, .fristen-b1-crumb, .fristen-b1-step-back").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const slug = btn.dataset.slug || "";
+ navigateB1(slug);
+ });
+ });
+
+ // Skip-step clicks the deepest path with current slug as the anchor —
+ // it just means "search at this node level without deeper narrowing".
+ cascade.querySelectorAll(".fristen-b1-skip").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ runB1Search(currentSlug);
+ });
+ });
+
+ runB1Search(currentSlug);
+}
+
+async function runB1Search(slug: string) {
+ const results = document.getElementById("fristen-search-results");
+ if (!results) return;
+ if (!slug) {
+ // Root state: empty results until user picks a step.
+ results.innerHTML = "";
+ return;
+ }
+ results.innerHTML = `${escHtml(t("deadlines.search.loading"))}
`;
+ try {
+ const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
+ url.searchParams.set("event_category_slug", slug);
+ url.searchParams.set("limit", "30");
+ const forums = getActiveForumsParam();
+ if (forums) url.searchParams.set("forum", forums);
+ const r = await fetch(url.toString());
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
+ const data = await r.json();
+ if (!data.cards || data.cards.length === 0) {
+ results.innerHTML = `
+ ${escHtml(t("deadlines.pathway.b.tree.empty"))}
+
+ ${escHtml(t("deadlines.pathway.b.tree.step.back"))}
+
+
`;
+ results.querySelector(".fristen-b1-loosen-link")?.addEventListener("click", () => {
+ const trail = buildBreadcrumb(eventCategoryTree || [], slug);
+ const parent = trail.length > 1 ? trail[trail.length - 2].slug : "";
+ navigateB1(parent);
+ });
+ return;
+ }
+ renderSearchResults(data);
+ } catch (e) {
+ results.innerHTML = `
+ ${escHtml(t("deadlines.search.no_hits"))}
+
`;
+ }
+}
+
+function navigateB1(slug: string) {
+ setB1PathInURL(slug);
+ renderB1Cascade(slug);
+}
+
+async function initB1Cascade() {
+ const panel = document.getElementById("fristen-b1-panel");
+ if (!panel) return;
+ // Lazy-load the tree the first time the user arrives in tree mode.
+ const loadAndRender = async () => {
+ try {
+ await loadEventCategoryTree();
+ renderB1Cascade(readB1PathFromURL());
+ } catch (e) {
+ const cascade = document.getElementById("fristen-b1-cascade");
+ if (cascade) {
+ cascade.innerHTML = `${escHtml(t("deadlines.pathway.b.tree.empty"))}
`;
+ }
+ }
+ };
+
+ // Watch for tree mode becoming visible (Phase B's mode toggle).
+ const treeRadio = document.getElementById("fristen-b-mode-tree") as HTMLInputElement | null;
+ if (treeRadio) {
+ treeRadio.addEventListener("change", () => {
+ if (treeRadio.checked) loadAndRender();
+ });
+ }
+
+ // Initial render if the URL already lands in tree mode.
+ const sp = new URLSearchParams(window.location.search);
+ if (sp.get("path") === "b" && sp.get("mode") === "tree") {
+ loadAndRender();
+ }
+
+ // popstate restores the cascade depth.
+ window.addEventListener("popstate", () => {
+ const params = new URLSearchParams(window.location.search);
+ if (params.get("path") === "b" && params.get("mode") === "tree" && eventCategoryTree) {
+ renderB1Cascade(params.get("b1") || "");
+ }
+ });
+}
+
+document.addEventListener("DOMContentLoaded", initB1Cascade);
+
+// ============================================================================
+// v3 B2 forum filter (t-paliad-133 Phase D)
+// ============================================================================
+// 10 forum buckets per m's spec lock §10 Q8. Multi-select chips,
+// AND-narrowing: each chip click toggles its membership in the active
+// set; the active set is sent as ?forum= on every
+// search. Empty set = no filter.
+
+const FORUM_BUCKETS: { slug: string; i18nKey: string }[] = [
+ { slug: "upc_cfi", i18nKey: "deadlines.filter.forum.upc_cfi" },
+ { slug: "upc_coa", i18nKey: "deadlines.filter.forum.upc_coa" },
+ { slug: "de_lg", i18nKey: "deadlines.filter.forum.de_lg" },
+ { slug: "de_olg", i18nKey: "deadlines.filter.forum.de_olg" },
+ { slug: "de_bgh", i18nKey: "deadlines.filter.forum.de_bgh" },
+ { slug: "de_bpatg", i18nKey: "deadlines.filter.forum.de_bpatg" },
+ { slug: "epa_grant", i18nKey: "deadlines.filter.forum.epa_grant" },
+ { slug: "epa_opp", i18nKey: "deadlines.filter.forum.epa_opp" },
+ { slug: "epa_appeal", i18nKey: "deadlines.filter.forum.epa_appeal" },
+ { slug: "dpma", i18nKey: "deadlines.filter.forum.dpma" },
+];
+
+const activeForums = new Set();
+
+function readForumsFromURL(): string[] {
+ const sp = new URLSearchParams(window.location.search);
+ const raw = sp.get("forum");
+ if (!raw) return [];
+ return raw.split(",").map((s) => s.trim()).filter((s) => FORUM_BUCKETS.some((b) => b.slug === s));
+}
+
+function writeForumsToURL(replace = false) {
+ const url = new URL(window.location.href);
+ if (activeForums.size === 0) {
+ url.searchParams.delete("forum");
+ } else {
+ url.searchParams.set("forum", Array.from(activeForums).sort().join(","));
+ }
+ if (replace) {
+ window.history.replaceState({}, "", url.toString());
+ } else {
+ window.history.pushState({}, "", url.toString());
+ }
+}
+
+function renderForumChips() {
+ const container = document.getElementById("fristen-forum-chips");
+ const wrapper = document.getElementById("fristen-forum-filter");
+ if (!container || !wrapper) return;
+ wrapper.hidden = false;
+ container.innerHTML = FORUM_BUCKETS.map((b) => {
+ const active = activeForums.has(b.slug);
+ return `
+ ${escHtml(t(b.i18nKey))}
+ `;
+ }).join("");
+ container.querySelectorAll(".fristen-forum-chip").forEach((chip) => {
+ chip.addEventListener("click", () => {
+ const slug = chip.dataset.forum || "";
+ if (!slug) return;
+ if (activeForums.has(slug)) {
+ activeForums.delete(slug);
+ } else {
+ activeForums.add(slug);
+ }
+ writeForumsToURL();
+ renderForumChips();
+ reissueSearchWithCurrentFilters();
+ });
+ });
+}
+
+function reissueSearchWithCurrentFilters() {
+ // If we're in B1 mode, refresh the current cascade slug's results.
+ const sp = new URLSearchParams(window.location.search);
+ if (sp.get("mode") === "tree") {
+ const slug = sp.get("b1") || "";
+ if (slug) {
+ runB1Search(slug);
+ return;
+ }
+ }
+ // Otherwise re-trigger the B2 search input handler.
+ const input = document.getElementById("fristen-search-input") as HTMLInputElement | null;
+ if (input && input.value.trim() !== "") {
+ input.dispatchEvent(new Event("input", { bubbles: true }));
+ }
+}
+
+function getActiveForumsParam(): string {
+ if (activeForums.size === 0) return "";
+ return Array.from(activeForums).sort().join(",");
+}
+
+function initForumFilter() {
+ // Hydrate from URL on first load.
+ for (const slug of readForumsFromURL()) {
+ activeForums.add(slug);
+ }
+ renderForumChips();
+
+ // Restore on browser nav.
+ window.addEventListener("popstate", () => {
+ activeForums.clear();
+ for (const slug of readForumsFromURL()) {
+ activeForums.add(slug);
+ }
+ renderForumChips();
+ });
+
+ // Re-render labels on language change.
+ onLangChange(() => renderForumChips());
+}
+
+document.addEventListener("DOMContentLoaded", initForumFilter);
+
diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts
index 9bde47a..aafa7f3 100644
--- a/frontend/src/client/i18n.ts
+++ b/frontend/src/client/i18n.ts
@@ -284,6 +284,36 @@ const translations: Record> = {
"deadlines.search.results.count": "{n} Treffer",
"deadlines.search.results.count_one": "1 Treffer",
"deadlines.search.clear": "Suche leeren",
+ "deadlines.pathway.fork.heading": "Was möchten Sie tun?",
+ "deadlines.pathway.a.title": "Verfahrensablauf informieren",
+ "deadlines.pathway.a.desc": "Verfahrenstyp wählen und alle dazugehörigen Fristen auf einer Zeitleiste sehen.",
+ "deadlines.pathway.b.title": "Frist eintragen aufgrund Ereignis",
+ "deadlines.pathway.b.desc": "Ein Ereignis ist eingetreten — ich brauche die richtige Frist für meine Akte.",
+ "deadlines.pathway.shortcut.label": "oder direkt zu einer Frist springen:",
+ "deadlines.pathway.back": "zurück zur Auswahl",
+ "deadlines.pathway.b.mode.tree": "Schritt-für-Schritt (Entscheidungsbaum)",
+ "deadlines.pathway.b.mode.filter": "Filter / Suche",
+ "deadlines.pathway.b.tree.coming_soon": "Der Entscheidungsbaum ist in Vorbereitung. Wechseln Sie zu „Filter / Suche\" oder kehren Sie zur Auswahl zurück.",
+ "deadlines.pathway.b.tree.step.back": "Schritt zurück",
+ "deadlines.pathway.b.tree.empty": "Keine Treffer für diesen Pfad.",
+ "deadlines.pathway.b.tree.reset": "Neu starten",
+ "deadlines.pathway.b.tree.skip": "Diesen Schritt überspringen",
+ "deadlines.pathway.b.tree.start_question": "Was ist passiert?",
+ "deadlines.filter.forum.label": "Gericht / System:",
+ "deadlines.filter.forum.upc_cfi": "UPC CFI",
+ "deadlines.filter.forum.upc_coa": "UPC CoA",
+ "deadlines.filter.forum.de_lg": "DE LG",
+ "deadlines.filter.forum.de_olg": "DE OLG",
+ "deadlines.filter.forum.de_bgh": "DE BGH",
+ "deadlines.filter.forum.de_bpatg": "DE BPatG",
+ "deadlines.filter.forum.epa_grant": "EPA Erteilung",
+ "deadlines.filter.forum.epa_opp": "EPA Einspruchsabt.",
+ "deadlines.filter.forum.epa_appeal": "EPA Beschwerdek.",
+ "deadlines.filter.forum.dpma": "DPMA",
+ "deadlines.perspective.label": "Ich vertrete:",
+ "deadlines.perspective.claimant": "Klägerseite (Proactive)",
+ "deadlines.perspective.defendant": "Beklagtenseite (Reactive)",
+ "deadlines.perspective.appeal_filed_by.label": "Berufung eingelegt durch:",
"deadlines.event.composite.label": "Zusammengesetzt:",
"deadlines.event.unit.days.one": "Tag",
"deadlines.event.unit.days.many": "Tage",
@@ -1838,6 +1868,36 @@ const translations: Record> = {
"deadlines.search.results.count": "{n} hits",
"deadlines.search.results.count_one": "1 hit",
"deadlines.search.clear": "Clear search",
+ "deadlines.pathway.fork.heading": "What would you like to do?",
+ "deadlines.pathway.a.title": "Browse a proceeding",
+ "deadlines.pathway.a.desc": "Pick a proceeding type and see all its deadlines on a single timeline.",
+ "deadlines.pathway.b.title": "File a deadline based on an event",
+ "deadlines.pathway.b.desc": "Something happened — find the right deadline for the matter.",
+ "deadlines.pathway.shortcut.label": "or jump straight to a deadline:",
+ "deadlines.pathway.back": "back to selection",
+ "deadlines.pathway.b.mode.tree": "Step-by-step (decision tree)",
+ "deadlines.pathway.b.mode.filter": "Filter / Search",
+ "deadlines.pathway.b.tree.coming_soon": "The decision tree is coming soon. Switch to \"Filter / Search\" or return to selection.",
+ "deadlines.pathway.b.tree.step.back": "step back",
+ "deadlines.pathway.b.tree.empty": "No matches for this path.",
+ "deadlines.pathway.b.tree.reset": "Restart",
+ "deadlines.pathway.b.tree.skip": "Skip this step",
+ "deadlines.pathway.b.tree.start_question": "What happened?",
+ "deadlines.filter.forum.label": "Forum / System:",
+ "deadlines.filter.forum.upc_cfi": "UPC CFI",
+ "deadlines.filter.forum.upc_coa": "UPC CoA",
+ "deadlines.filter.forum.de_lg": "DE LG",
+ "deadlines.filter.forum.de_olg": "DE OLG",
+ "deadlines.filter.forum.de_bgh": "DE BGH",
+ "deadlines.filter.forum.de_bpatg": "DE BPatG",
+ "deadlines.filter.forum.epa_grant": "EPO Examining",
+ "deadlines.filter.forum.epa_opp": "EPO Opposition",
+ "deadlines.filter.forum.epa_appeal": "EPO Board of Appeal",
+ "deadlines.filter.forum.dpma": "DPMA",
+ "deadlines.perspective.label": "I represent:",
+ "deadlines.perspective.claimant": "Claimant side (Proactive)",
+ "deadlines.perspective.defendant": "Defendant side (Reactive)",
+ "deadlines.perspective.appeal_filed_by.label": "Appeal filed by:",
"deadlines.event.composite.label": "Composite:",
"deadlines.event.unit.days.one": "day",
"deadlines.event.unit.days.many": "days",
diff --git a/frontend/src/fristenrechner.tsx b/frontend/src/fristenrechner.tsx
index 87399a8..0f12fea 100644
--- a/frontend/src/fristenrechner.tsx
+++ b/frontend/src/fristenrechner.tsx
@@ -78,46 +78,133 @@ export function renderFristenrechner(): string {
-
-
Frist suchen
-
-
-
-
-
-
-
- ×
+ {/* v3 landing fork (t-paliad-133) — visible by default, hidden once
+ the user picks a pathway. URL ?path= drives visibility. */}
+
+
Was möchten Sie tun?
+
+
+ 📖
+ Verfahrensablauf informieren
+
+ Verfahrenstyp wählen und alle dazugehörigen Fristen auf einer Zeitleiste sehen.
+
+
+
+ 📅
+ Frist eintragen aufgrund Ereignis
+
+ Ein Ereignis ist eingetreten — ich brauche die richtige Frist für meine Akte.
+
-
-
Schnellzugriff:
-
Klageerwiderung
-
Berufung
-
Einspruch
-
Replik
-
Beschwerde
-
Statement of Defence
-
Schadensbemessung
-
Wiedereinsetzung
+
+
+ oder direkt zu einer Frist springen:
+
+
+ Klageerwiderung
+ Berufung
+ Einspruch
+ Replik
+ Beschwerde
+ Wiedereinsetzung
+
-
-
-
Verfahrensablauf
-
Was kommt nach…
+ {/* Pathway B container — search bar relocates here from the page top.
+ Mode toggle (B1 tree / B2 filter) sits above the panels.
+ Hidden until ?path=b. */}
+
+
+ ← {" "}
+ zurück zur Auswahl
+
+
+ 📅 {" "}
+ Frist eintragen aufgrund Ereignis
+
+
+
+
+
+ Schritt-für-Schritt (Entscheidungsbaum)
+
+
+
+ Filter / Suche
+
+
+
+ {/* B1 panel — populated by fristenrechner-tree.ts in Phase C. */}
+
+
+ {/* B2 panel — search bar + chips + concept-card results.
+ The search input + chips + results host live here so
+ fristenrechner.ts can drive both Phase D (today) and the
+ B1↔B2 state-share in Phase D (forum filter). */}
+
+
+
Frist suchen
+
+
+
+
+
+
+
+ ×
+
+
+
+ Schnellzugriff:
+ Klageerwiderung
+ Berufung
+ Einspruch
+ Replik
+ Beschwerde
+ Statement of Defence
+ Schadensbemessung
+ Wiedereinsetzung
+
+ {/* Forum filter row — populated by Phase D. */}
+
+
+
+
-
+ {/* Pathway A container — wraps the existing wizard.
+ Hidden until ?path=a. */}
+
+
+ ← {" "}
+ zurück zur Auswahl
+
+
+ 📖 {" "}
+ Verfahrensablauf informieren
+
+
+ {/* v3: legacy mode tabs retired (m's spec lock §10 Q1, 2026-05-05).
+ Pathway A is Verfahrensablauf-only; trigger-event drill-in
+ surfaces via concept-card pills with ?path=a&trigger=N URL,
+ which resurfaces mode-event-panel programmatically below. */}
+
1
@@ -243,7 +330,7 @@ export function renderFristenrechner(): string {
-
+
1
@@ -308,6 +395,7 @@ export function renderFristenrechner(): string {
← Neu berechnen
+
{/* /pathway-a */}
diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts
index 5c2d526..03c7b87 100644
--- a/frontend/src/i18n-keys.ts
+++ b/frontend/src/i18n-keys.ts
@@ -637,6 +637,17 @@ export type I18nKey =
| "deadlines.filter.all"
| "deadlines.filter.completed"
| "deadlines.filter.event_type"
+ | "deadlines.filter.forum.de_bgh"
+ | "deadlines.filter.forum.de_bpatg"
+ | "deadlines.filter.forum.de_lg"
+ | "deadlines.filter.forum.de_olg"
+ | "deadlines.filter.forum.dpma"
+ | "deadlines.filter.forum.epa_appeal"
+ | "deadlines.filter.forum.epa_grant"
+ | "deadlines.filter.forum.epa_opp"
+ | "deadlines.filter.forum.label"
+ | "deadlines.filter.forum.upc_cfi"
+ | "deadlines.filter.forum.upc_coa"
| "deadlines.filter.later"
| "deadlines.filter.nextweek"
| "deadlines.filter.overdue"
@@ -673,6 +684,25 @@ export type I18nKey =
| "deadlines.party.claimant"
| "deadlines.party.court"
| "deadlines.party.defendant"
+ | "deadlines.pathway.a.desc"
+ | "deadlines.pathway.a.title"
+ | "deadlines.pathway.b.desc"
+ | "deadlines.pathway.b.mode.filter"
+ | "deadlines.pathway.b.mode.tree"
+ | "deadlines.pathway.b.title"
+ | "deadlines.pathway.b.tree.coming_soon"
+ | "deadlines.pathway.b.tree.empty"
+ | "deadlines.pathway.b.tree.reset"
+ | "deadlines.pathway.b.tree.skip"
+ | "deadlines.pathway.b.tree.start_question"
+ | "deadlines.pathway.b.tree.step.back"
+ | "deadlines.pathway.back"
+ | "deadlines.pathway.fork.heading"
+ | "deadlines.pathway.shortcut.label"
+ | "deadlines.perspective.appeal_filed_by.label"
+ | "deadlines.perspective.claimant"
+ | "deadlines.perspective.defendant"
+ | "deadlines.perspective.label"
| "deadlines.print"
| "deadlines.priority.date"
| "deadlines.reset"
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index c272009..50bd267 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -1533,6 +1533,321 @@ input[type="range"]::-moz-range-thumb {
/* --- Fristenrechner --- */
+/* Fristenrechner v3 (t-paliad-133) — landing fork + pathway shells.
+ The fork is the default landing surface. Each pathway is a peer
+ container that slides in once chosen; back-button returns to fork. */
+
+.fristen-pathway-fork {
+ margin: 2rem 0 2.5rem;
+ padding: 1.75rem;
+ border: 1px solid var(--color-border);
+ border-radius: 12px;
+ background: var(--color-surface);
+}
+
+.fristen-pathway-fork-heading {
+ margin: 0 0 1.25rem;
+ font-size: 1.25rem;
+ font-weight: 600;
+ color: var(--color-text);
+}
+
+.fristen-pathway-fork-cards {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+}
+
+@media (max-width: 720px) {
+ .fristen-pathway-fork-cards {
+ grid-template-columns: 1fr;
+ }
+}
+
+.fristen-pathway-card {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 0.5rem;
+ padding: 1.25rem;
+ border: 1px solid var(--color-border);
+ border-radius: 10px;
+ background: var(--color-bg);
+ cursor: pointer;
+ text-align: left;
+ transition: border-color 120ms, box-shadow 120ms, transform 60ms;
+}
+
+.fristen-pathway-card:hover {
+ border-color: var(--color-accent);
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
+}
+
+.fristen-pathway-card:active {
+ transform: translateY(1px);
+}
+
+.fristen-pathway-card:focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 2px;
+}
+
+.fristen-pathway-card-icon {
+ font-size: 1.75rem;
+ line-height: 1;
+}
+
+.fristen-pathway-card-title {
+ font-size: 1.05rem;
+ font-weight: 600;
+ color: var(--color-text);
+}
+
+.fristen-pathway-card-desc {
+ font-size: 0.9rem;
+ color: var(--color-text-muted);
+ line-height: 1.4;
+}
+
+.fristen-pathway-fork-shortcut {
+ border-top: 1px dashed var(--color-border);
+ padding-top: 1rem;
+}
+
+.fristen-pathway-fork-shortcut-label {
+ font-size: 0.85rem;
+ color: var(--color-text-muted);
+ margin-bottom: 0.5rem;
+}
+
+.fristen-pathway-shell {
+ margin: 1.5rem 0;
+}
+
+.fristen-pathway-back {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.4rem;
+ background: none;
+ border: none;
+ color: var(--color-text-muted);
+ cursor: pointer;
+ padding: 0.25rem 0.5rem;
+ margin-bottom: 0.75rem;
+ font-size: 0.9rem;
+ border-radius: 4px;
+}
+
+.fristen-pathway-back:hover {
+ color: var(--color-text);
+ background: var(--color-bg-muted);
+}
+
+.fristen-pathway-heading {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ margin: 0 0 1.5rem;
+ font-size: 1.4rem;
+ font-weight: 600;
+ color: var(--color-text);
+}
+
+.fristen-mode-toggle {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 1rem;
+ margin-bottom: 1.5rem;
+ padding-bottom: 1rem;
+ border-bottom: 1px solid var(--color-border);
+}
+
+.fristen-mode-toggle-option {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ cursor: pointer;
+ font-size: 0.95rem;
+}
+
+.fristen-b1-stub {
+ padding: 1.5rem;
+ border: 1px dashed var(--color-border);
+ border-radius: 8px;
+ color: var(--color-text-muted);
+ font-style: italic;
+ text-align: center;
+}
+
+/* B1 cascade — Phase C decision-tree UI (t-paliad-133) */
+
+.fristen-b1-breadcrumb {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.4rem;
+ margin-bottom: 1rem;
+ padding-bottom: 0.75rem;
+ border-bottom: 1px solid var(--color-border);
+}
+
+.fristen-b1-crumb {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.3rem;
+ background: var(--color-bg-muted);
+ border: 1px solid var(--color-border);
+ border-radius: 999px;
+ padding: 0.2rem 0.65rem;
+ font-size: 0.85rem;
+ color: var(--color-text);
+ cursor: pointer;
+ transition: background 120ms;
+}
+
+.fristen-b1-crumb:hover {
+ background: var(--color-bg);
+}
+
+.fristen-b1-crumb--current {
+ background: var(--color-accent);
+ border-color: var(--color-accent);
+ color: var(--color-accent-text, #000);
+ font-weight: 500;
+ cursor: default;
+}
+
+.fristen-b1-crumb--root {
+ font-style: italic;
+}
+
+.fristen-b1-crumb-sep {
+ color: var(--color-text-muted);
+ font-size: 0.9rem;
+}
+
+.fristen-b1-question {
+ margin: 0.5rem 0 1rem;
+ font-size: 1.05rem;
+ font-weight: 600;
+ color: var(--color-text);
+}
+
+.fristen-b1-buttons {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 0.6rem;
+ margin-bottom: 1rem;
+}
+
+.fristen-b1-button {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.85rem 1rem;
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ background: var(--color-bg);
+ cursor: pointer;
+ font-size: 0.95rem;
+ text-align: left;
+ color: var(--color-text);
+ transition: border-color 120ms, background 120ms;
+}
+
+.fristen-b1-button:hover {
+ border-color: var(--color-accent);
+ background: var(--color-bg-muted);
+}
+
+.fristen-b1-button:focus-visible {
+ outline: 2px solid var(--color-accent);
+ outline-offset: 2px;
+}
+
+.fristen-b1-button--leaf {
+ /* Leaf nodes get a subtle marker so users sense they'll see results,
+ not deeper buttons. */
+ border-left: 3px solid var(--color-accent);
+}
+
+.fristen-b1-button-icon {
+ font-size: 1.25rem;
+ line-height: 1;
+}
+
+.fristen-b1-button-label {
+ flex: 1;
+}
+
+.fristen-b1-skip,
+.fristen-b1-step-back,
+.fristen-b1-loosen-link {
+ background: none;
+ border: none;
+ color: var(--color-accent);
+ cursor: pointer;
+ padding: 0.25rem 0.5rem;
+ font-size: 0.9rem;
+ margin-right: 0.5rem;
+}
+
+.fristen-b1-skip:hover,
+.fristen-b1-step-back:hover,
+.fristen-b1-loosen-link:hover {
+ text-decoration: underline;
+}
+
+.fristen-b1-error {
+ padding: 1rem;
+ color: var(--color-text-muted);
+ font-style: italic;
+}
+
+.fristen-forum-filter {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.6rem;
+ margin: 0.75rem 0 1rem;
+}
+
+.fristen-forum-filter-label {
+ font-size: 0.9rem;
+ color: var(--color-text-muted);
+}
+
+.fristen-forum-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.4rem;
+}
+
+.fristen-forum-chip {
+ display: inline-flex;
+ align-items: center;
+ padding: 0.25rem 0.65rem;
+ border: 1px solid var(--color-border);
+ border-radius: 999px;
+ background: var(--color-bg);
+ cursor: pointer;
+ font-size: 0.85rem;
+ color: var(--color-text);
+ transition: background 120ms, border-color 120ms;
+}
+
+.fristen-forum-chip:hover {
+ background: var(--color-bg-muted);
+}
+
+.fristen-forum-chip--active {
+ background: var(--color-accent);
+ border-color: var(--color-accent);
+ color: var(--color-accent-text, #000);
+ font-weight: 500;
+}
+
/* Phase D search (t-paliad-131) — augments the proceeding tile grid by
letting the user type a phrase and drill into the right calculator
from a ranked card list. Sits above the mode tabs. */
diff --git a/internal/db/migrations/048_event_categories.down.sql b/internal/db/migrations/048_event_categories.down.sql
new file mode 100644
index 0000000..93d638b
--- /dev/null
+++ b/internal/db/migrations/048_event_categories.down.sql
@@ -0,0 +1,11 @@
+-- t-paliad-133 Phase A schema rollback.
+
+-- 3. Drop is_bilateral column.
+DROP INDEX IF EXISTS paliad.deadline_rules_is_bilateral;
+ALTER TABLE paliad.deadline_rules DROP COLUMN IF EXISTS is_bilateral;
+
+-- 2. Drop the junction table.
+DROP TABLE IF EXISTS paliad.event_category_concepts;
+
+-- 1. Drop the taxonomy tree (CASCADE handles children).
+DROP TABLE IF EXISTS paliad.event_categories;
diff --git a/internal/db/migrations/048_event_categories.up.sql b/internal/db/migrations/048_event_categories.up.sql
new file mode 100644
index 0000000..b9b8cf2
--- /dev/null
+++ b/internal/db/migrations/048_event_categories.up.sql
@@ -0,0 +1,137 @@
+-- t-paliad-133 Phase A: event taxonomy schema for the Fristenrechner v3
+-- decision tree (Pathway B / B1) AND a bilateral-rule flag for the
+-- party-perspective selector.
+--
+-- Three artefacts in one migration (additive, no breaking changes):
+--
+-- 1. paliad.event_categories — the decision-tree taxonomy. Recursive
+-- tree (parent_id self-FK) of "what happened" nodes. Internal
+-- nodes carry a step_question_de/en (the question to ask under
+-- this node). Leaves are user-actionable events that map to one
+-- or more concepts via the junction below.
+--
+-- 2. paliad.event_category_concepts — many-to-many junction from
+-- taxonomy leaves to deadline_concepts. Optional
+-- proceeding_type_code narrows the card pills at this leaf to a
+-- specific context (e.g. "Hinweisbeschluss" → response-to-
+-- preliminary-opinion narrowed to DE_NULL only).
+--
+-- 3. paliad.deadline_rules.is_bilateral — new bool flag for genuinely-
+-- bilateral rules (Schriftsatzfristen with Anwaltszwang, joint
+-- Mängelbeseitigung). When true AND primary_party='both', the rule
+-- mirrors into both party columns of the columns view. Otherwise
+-- 'both' → the rule applies to whichever party took the action.
+--
+-- Seed data + bilateral backfill ship in migrations 049 / 050.
+--
+-- Design ref: docs/plans/unified-fristenrechner-v3.md §4.1 + §10 Q12.
+
+-- ============================================================================
+-- 1. paliad.event_categories — recursive taxonomy tree
+-- ============================================================================
+
+CREATE TABLE paliad.event_categories (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ parent_id uuid REFERENCES paliad.event_categories(id) ON DELETE CASCADE,
+ slug text NOT NULL UNIQUE,
+ label_de text NOT NULL,
+ label_en text NOT NULL,
+ description_de text,
+ description_en text,
+ step_question_de text,
+ step_question_en text,
+ icon text,
+ sort_order int NOT NULL DEFAULT 100,
+ is_leaf bool NOT NULL DEFAULT false,
+ is_active bool NOT NULL DEFAULT true,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+
+COMMENT ON TABLE paliad.event_categories IS
+ 'Decision-tree taxonomy for Fristenrechner v3 Pathway B / B1. Each '
+ 'row is a node in a recursive tree of "what happened" categories. '
+ 'The tree shape is data-driven — depth is unlimited per m''s '
+ '2026-05-05 spec lock.';
+
+COMMENT ON COLUMN paliad.event_categories.slug IS
+ 'Materialised path-with-dots (e.g. cms-eingang.gericht.hinweisbeschluss). '
+ 'Unique across the table. Drives URL bookmarks ?b1=
.';
+
+COMMENT ON COLUMN paliad.event_categories.step_question_de IS
+ 'The question this node''s CHILDREN answer. Rendered above the button '
+ 'row for the next step. NULL on leaves (terminal events that map to '
+ 'concepts via paliad.event_category_concepts).';
+
+COMMENT ON COLUMN paliad.event_categories.is_leaf IS
+ 'True when this node has no children and produces concept outcomes '
+ 'via the junction. False on internal navigation nodes.';
+
+COMMENT ON COLUMN paliad.event_categories.icon IS
+ 'Single emoji or icon slug rendered on the button face. Optional.';
+
+CREATE INDEX event_categories_parent_id ON paliad.event_categories (parent_id);
+CREATE INDEX event_categories_slug ON paliad.event_categories (slug);
+CREATE INDEX event_categories_active ON paliad.event_categories (is_active);
+
+-- updated_at is managed app-side (consistent with paliad.deadline_concepts
+-- and other tables in this schema). No DB-level trigger.
+
+-- ============================================================================
+-- 2. paliad.event_category_concepts — leaf → concept junction
+-- ============================================================================
+
+CREATE TABLE paliad.event_category_concepts (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ event_category_id uuid NOT NULL
+ REFERENCES paliad.event_categories(id) ON DELETE CASCADE,
+ concept_id uuid NOT NULL
+ REFERENCES paliad.deadline_concepts(id) ON DELETE CASCADE,
+ proceeding_type_code text,
+ sort_order int NOT NULL DEFAULT 100,
+ -- NULLS NOT DISTINCT: treat (leaf, concept, NULL) as a single tuple
+ -- so we can't accidentally seed the same concept twice with no
+ -- proceeding-narrowing. Requires PostgreSQL 15+ (Supabase ships ≥15).
+ UNIQUE NULLS NOT DISTINCT (event_category_id, concept_id, proceeding_type_code)
+);
+
+COMMENT ON TABLE paliad.event_category_concepts IS
+ 'Many-to-many junction: an event_category leaf produces one or more '
+ 'deadline_concepts as its candidate Frist outcomes. Optional '
+ 'proceeding_type_code narrows the resulting card pills to that '
+ 'context only.';
+
+COMMENT ON COLUMN paliad.event_category_concepts.proceeding_type_code IS
+ 'NULL = all contexts of the concept apply (the result card shows '
+ 'every pill). Set = limit the card''s pill set to this proceeding '
+ '(e.g. leaf cms-eingang.gericht.hinweisbeschluss → response-to-'
+ 'preliminary-opinion narrowed to DE_NULL only).';
+
+CREATE INDEX event_category_concepts_category
+ ON paliad.event_category_concepts (event_category_id);
+CREATE INDEX event_category_concepts_concept
+ ON paliad.event_category_concepts (concept_id);
+CREATE INDEX event_category_concepts_proceeding
+ ON paliad.event_category_concepts (proceeding_type_code)
+ WHERE proceeding_type_code IS NOT NULL;
+
+-- ============================================================================
+-- 3. paliad.deadline_rules.is_bilateral — genuinely-bilateral flag
+-- ============================================================================
+
+ALTER TABLE paliad.deadline_rules
+ ADD COLUMN is_bilateral bool NOT NULL DEFAULT false;
+
+COMMENT ON COLUMN paliad.deadline_rules.is_bilateral IS
+ 'When true AND primary_party=''both'', the rule mirrors into both '
+ 'party columns of the columns view (genuinely-bilateral rules: '
+ 'Schriftsatzfristen with Anwaltszwang, joint Mängelbeseitigung, '
+ 'mündliche Verhandlung). When false (default) AND primary_party='
+ '''both'', the rule applies only to the side that took the action '
+ '(determined from the parent rule''s party or, if root, from the '
+ 'perspective selector). Backfilled in migration 050 for the small '
+ 'set of genuinely-bilateral rules in the existing corpus.';
+
+CREATE INDEX deadline_rules_is_bilateral
+ ON paliad.deadline_rules (is_bilateral)
+ WHERE is_bilateral = true;
diff --git a/internal/db/migrations/049_event_categories_seed.down.sql b/internal/db/migrations/049_event_categories_seed.down.sql
new file mode 100644
index 0000000..1c88bd3
--- /dev/null
+++ b/internal/db/migrations/049_event_categories_seed.down.sql
@@ -0,0 +1,12 @@
+-- t-paliad-133 Phase A seed rollback. CASCADE on the parent_id FK
+-- handles descendant rows automatically; we delete by root slug.
+DELETE FROM paliad.event_categories
+ WHERE parent_id IS NULL
+ AND slug IN (
+ 'cms-eingang',
+ 'muendl-verhandlung',
+ 'beschluss-entscheidung',
+ 'frist-verpasst',
+ 'ich-moechte-einreichen',
+ 'sonstiges'
+ );
diff --git a/internal/db/migrations/049_event_categories_seed.up.sql b/internal/db/migrations/049_event_categories_seed.up.sql
new file mode 100644
index 0000000..2c51bbd
--- /dev/null
+++ b/internal/db/migrations/049_event_categories_seed.up.sql
@@ -0,0 +1,790 @@
+-- t-paliad-133 Phase A seed: event taxonomy for the v3 decision tree.
+--
+-- Six root buckets (m's design 2026-05-05):
+-- 📥 cms-eingang — CMS-Eingang (Schriftstück erhalten)
+-- 🎤 muendl-verhandlung — Mündliche Verhandlung
+-- 📊 beschluss-entscheidung — Beschluss / Entscheidung erhalten
+-- 🚫 frist-verpasst — Frist verpasst
+-- 📤 ich-moechte-einreichen — Ich möchte etwas einreichen (proactive)
+-- ❓ sonstiges — Anderes / Sonstiges (B2 fallback)
+--
+-- Tree depth is unlimited per design lock §10 Q2; current seed reaches
+-- depth 4 at deepest (cms-eingang › gericht › endentscheidung › ).
+--
+-- Coverage gate at the end of this migration: every concept with
+-- category='submission' must be reachable from at least one leaf, except
+-- the pure-administrative slugs in the exempt list (filing,
+-- request-for-examination, approval-and-translation).
+
+-- ============================================================================
+-- 0. Locals: stable parent-slug references for INSERT-time lookups.
+-- ============================================================================
+
+-- We INSERT in tree order (roots first, then children) so each parent_id
+-- subquery resolves at execution time. Slugs are unique → safe lookup.
+
+-- ============================================================================
+-- 1. Roots
+-- ============================================================================
+
+INSERT INTO paliad.event_categories
+ (slug, parent_id, label_de, label_en, description_de, description_en,
+ step_question_de, step_question_en, icon, sort_order, is_leaf)
+VALUES
+ ('cms-eingang', NULL, 'CMS-Eingang', 'CMS receipt',
+ 'Ein Schriftstück ist im CMS eingegangen.',
+ 'A document arrived in the CMS.',
+ 'Von wem ist das Schriftstück?',
+ 'Who sent the document?',
+ '📥', 100, false),
+
+ ('muendl-verhandlung', NULL, 'Mündliche Verhandlung', 'Oral hearing',
+ 'Etwas rund um eine mündliche Verhandlung.',
+ 'Something about an oral hearing.',
+ 'Was ist mit der Verhandlung passiert?',
+ 'What happened with the hearing?',
+ '🎤', 200, false),
+
+ ('beschluss-entscheidung', NULL, 'Beschluss / Entscheidung', 'Decision / Order',
+ 'Eine Entscheidung oder ein Beschluss wurde erlassen.',
+ 'A decision or order has been issued.',
+ 'Welche Art von Entscheidung?',
+ 'What kind of decision?',
+ '📊', 300, false),
+
+ ('frist-verpasst', NULL, 'Frist verpasst', 'Missed deadline',
+ 'Eine gesetzliche Frist wurde versäumt — Wiedereinsetzung prüfen.',
+ 'A statutory deadline was missed — check for re-establishment of rights.',
+ 'In welchem System?',
+ 'Which legal system?',
+ '🚫', 400, false),
+
+ ('ich-moechte-einreichen', NULL, 'Ich möchte etwas einreichen', 'I want to file something',
+ 'Proaktiv: ein Schriftsatz / Klage / Antrag soll eingereicht werden.',
+ 'Proactive: a submission / claim / application is to be filed.',
+ 'Was möchten Sie einreichen?',
+ 'What would you like to file?',
+ '📤', 500, false),
+
+ ('sonstiges', NULL, 'Anderes / Sonstiges', 'Other',
+ 'Etwas Anderes — wechseln Sie zum Filter / zur Suche.',
+ 'Something else — switch to filter / search.',
+ NULL, NULL,
+ '❓', 600, true);
+
+-- ============================================================================
+-- 2. Level 2 — children of cms-eingang
+-- ============================================================================
+
+INSERT INTO paliad.event_categories
+ (slug, parent_id, label_de, label_en, step_question_de, step_question_en,
+ icon, sort_order, is_leaf)
+SELECT
+ s.slug, p.id, s.label_de, s.label_en, s.step_question_de, s.step_question_en,
+ s.icon, s.sort_order, s.is_leaf
+FROM paliad.event_categories p
+CROSS JOIN (VALUES
+ ('cms-eingang.gericht', 'Vom Gericht', 'From the Court',
+ 'Welcher Charakter hat das Schriftstück?',
+ 'What is the nature of the document?',
+ '⚖', 100, false),
+ ('cms-eingang.gegenseite', 'Von der Gegenseite', 'From the opposing party',
+ 'In welchem Verfahrenstyp?',
+ 'In which proceeding type?',
+ '👥', 200, false)
+) AS s(slug, label_de, label_en, step_question_de, step_question_en,
+ icon, sort_order, is_leaf)
+WHERE p.slug = 'cms-eingang';
+
+-- ============================================================================
+-- 3. Level 3 — children of cms-eingang.gericht
+-- ============================================================================
+
+INSERT INTO paliad.event_categories
+ (slug, parent_id, label_de, label_en, step_question_de, step_question_en,
+ icon, sort_order, is_leaf)
+SELECT
+ s.slug, p.id, s.label_de, s.label_en, s.step_question_de, s.step_question_en,
+ s.icon, s.sort_order, s.is_leaf
+FROM paliad.event_categories p
+CROSS JOIN (VALUES
+ ('cms-eingang.gericht.hinweisbeschluss',
+ 'Hinweisbeschluss / vorläufige Würdigung',
+ 'Preliminary opinion / court hint',
+ NULL, NULL, '📋', 100, true),
+
+ ('cms-eingang.gericht.ladung',
+ 'Ladung zur mündlichen Verhandlung',
+ 'Summons to oral hearing',
+ NULL, NULL, '📅', 200, true),
+
+ ('cms-eingang.gericht.bescheid-mit-frist',
+ 'Bescheid mit explizit gesetzter Frist',
+ 'Order with court-set deadline',
+ NULL, NULL, '📨', 300, true),
+
+ ('cms-eingang.gericht.endentscheidung',
+ 'Endentscheidung / Urteil',
+ 'Final decision / Judgment',
+ 'Welche Instanz / welches Verfahren?',
+ 'Which instance / proceeding?',
+ '🏛', 400, false),
+
+ ('cms-eingang.gericht.kostenfestsetzung',
+ 'Kostenfestsetzungsbeschluss',
+ 'Cost-fixing order',
+ NULL, NULL, '💰', 500, true),
+
+ ('cms-eingang.gericht.rechtsverlust-epa',
+ 'Mitteilung über Rechtsverlust (EPA)',
+ 'Loss-of-rights notice (EPO)',
+ NULL, NULL, '🚫', 600, true),
+
+ ('cms-eingang.gericht.anordnung',
+ 'Anordnung / Order (PI, Beweissicherung)',
+ 'Order (PI, evidence preservation)',
+ NULL, NULL, '📌', 700, true)
+) AS s(slug, label_de, label_en, step_question_de, step_question_en,
+ icon, sort_order, is_leaf)
+WHERE p.slug = 'cms-eingang.gericht';
+
+-- ============================================================================
+-- 4. Level 4 — children of cms-eingang.gericht.endentscheidung
+-- ============================================================================
+
+INSERT INTO paliad.event_categories
+ (slug, parent_id, label_de, label_en, step_question_de, step_question_en,
+ icon, sort_order, is_leaf)
+SELECT
+ s.slug, p.id, s.label_de, s.label_en, NULL, NULL,
+ s.icon, s.sort_order, true
+FROM paliad.event_categories p
+CROSS JOIN (VALUES
+ ('cms-eingang.gericht.endentscheidung.urteil-de-inf-lg',
+ 'Urteil LG (Verletzung)', 'LG judgment (infringement)', '⚖', 100),
+ ('cms-eingang.gericht.endentscheidung.urteil-de-inf-olg',
+ 'Urteil OLG (Verletzung)', 'OLG judgment (infringement)', '⚖', 200),
+ ('cms-eingang.gericht.endentscheidung.urteil-de-null-bpatg',
+ 'Urteil BPatG (Nichtigkeit)', 'BPatG judgment (nullity)', '⚖', 300),
+ ('cms-eingang.gericht.endentscheidung.urteil-upc-cfi',
+ 'Sachentscheidung UPC (CFI)', 'UPC CFI decision', '⚖', 400),
+ ('cms-eingang.gericht.endentscheidung.urteil-upc-coa',
+ 'Sachentscheidung UPC (CoA)', 'UPC CoA decision', '⚖', 500),
+ ('cms-eingang.gericht.endentscheidung.entscheidung-epa-opp',
+ 'Einspruchsentscheidung EPA', 'EPO opposition decision', '⚖', 600),
+ ('cms-eingang.gericht.endentscheidung.entscheidung-epa-boa',
+ 'Beschwerdeentscheidung EPA', 'EPO Board of Appeal decision', '⚖', 700),
+ ('cms-eingang.gericht.endentscheidung.entscheidung-dpma',
+ 'DPMA-Einspruchsentscheidung', 'DPMA opposition decision', '⚖', 800),
+ ('cms-eingang.gericht.endentscheidung.beschluss-bpatg-beschwerde',
+ 'Beschluss BPatG (DPMA-Beschwerde)', 'BPatG order (DPMA appeal)', '⚖', 900),
+ ('cms-eingang.gericht.endentscheidung.versaeumnisurteil',
+ 'Versäumnisurteil (DE)', 'Default judgment (DE)', '📊', 1000)
+) AS s(slug, label_de, label_en, icon, sort_order)
+WHERE p.slug = 'cms-eingang.gericht.endentscheidung';
+
+-- ============================================================================
+-- 5. Level 3 — children of cms-eingang.gegenseite
+-- ============================================================================
+
+INSERT INTO paliad.event_categories
+ (slug, parent_id, label_de, label_en, step_question_de, step_question_en,
+ icon, sort_order, is_leaf)
+SELECT
+ s.slug, p.id, s.label_de, s.label_en, s.step_question_de, s.step_question_en,
+ s.icon, s.sort_order, s.is_leaf
+FROM paliad.event_categories p
+CROSS JOIN (VALUES
+ ('cms-eingang.gegenseite.upc-inf',
+ 'UPC Verletzungsverfahren', 'UPC infringement',
+ 'Welcher Schriftsatz wurde eingereicht?',
+ 'Which submission was filed?', '⚖', 100, false),
+
+ ('cms-eingang.gegenseite.upc-rev',
+ 'UPC Nichtigkeitsverfahren', 'UPC revocation',
+ 'Welcher Schriftsatz?',
+ 'Which submission?', '⚖', 200, false),
+
+ ('cms-eingang.gegenseite.de-inf',
+ 'DE Verletzungsklage (LG/OLG)', 'DE infringement (LG/OLG)',
+ 'Welcher Schriftsatz?',
+ 'Which submission?', '🏛', 300, false),
+
+ ('cms-eingang.gegenseite.de-null',
+ 'DE Nichtigkeitsklage (BPatG)', 'DE nullity (BPatG)',
+ 'Welcher Schriftsatz?',
+ 'Which submission?', '🏛', 400, false),
+
+ ('cms-eingang.gegenseite.epa-opp',
+ 'EPA Einspruch', 'EPO opposition',
+ 'Welcher Schriftsatz?',
+ 'Which submission?', '🏛', 500, false),
+
+ ('cms-eingang.gegenseite.epa-app',
+ 'EPA Beschwerde', 'EPO appeal',
+ NULL, NULL, '🏛', 600, true),
+
+ ('cms-eingang.gegenseite.dpma-opp',
+ 'DPMA Einspruchsschrift', 'DPMA opposition',
+ NULL, NULL, '🏛', 700, true)
+) AS s(slug, label_de, label_en, step_question_de, step_question_en,
+ icon, sort_order, is_leaf)
+WHERE p.slug = 'cms-eingang.gegenseite';
+
+-- ============================================================================
+-- 6. Level 4 — children of cms-eingang.gegenseite.upc-inf
+-- ============================================================================
+
+INSERT INTO paliad.event_categories
+ (slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
+SELECT
+ s.slug, p.id, s.label_de, s.label_en, s.icon, s.sort_order, true
+FROM paliad.event_categories p
+CROSS JOIN (VALUES
+ ('cms-eingang.gegenseite.upc-inf.klageschrift',
+ 'Klageschrift', 'Statement of claim', '📜', 100),
+ ('cms-eingang.gegenseite.upc-inf.klageerwiderung-mit-ccr',
+ 'Klageerwiderung MIT Nichtigkeitswiderklage',
+ 'Statement of defence WITH CCR', '🔄', 200),
+ ('cms-eingang.gegenseite.upc-inf.klageerwiderung-ohne-ccr',
+ 'Klageerwiderung OHNE Nichtigkeitswiderklage',
+ 'Statement of defence WITHOUT CCR', '🔄', 300),
+ ('cms-eingang.gegenseite.upc-inf.replik',
+ 'Replik (Reply to Defence)', 'Reply to Defence', '↩', 400),
+ ('cms-eingang.gegenseite.upc-inf.antrag-patentaenderung',
+ 'Antrag auf Patentänderung (R.30)',
+ 'Application to amend (R.30)', '🔁', 500),
+ ('cms-eingang.gegenseite.upc-inf.berufungsschrift',
+ 'Berufungsschrift Gegner', 'Opposing party''s notice of appeal', '📈', 600)
+) AS s(slug, label_de, label_en, icon, sort_order)
+WHERE p.slug = 'cms-eingang.gegenseite.upc-inf';
+
+-- ============================================================================
+-- 7. Level 4 — children of cms-eingang.gegenseite.upc-rev
+-- ============================================================================
+
+INSERT INTO paliad.event_categories
+ (slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
+SELECT
+ s.slug, p.id, s.label_de, s.label_en, s.icon, s.sort_order, true
+FROM paliad.event_categories p
+CROSS JOIN (VALUES
+ ('cms-eingang.gegenseite.upc-rev.nichtigkeitsklage',
+ 'Nichtigkeitsklage (Statement for Revocation)',
+ 'Application for revocation', '📜', 100),
+ ('cms-eingang.gegenseite.upc-rev.defence-to-revocation',
+ 'Defence to Revocation (mit/ohne Amend, mit/ohne CCI)',
+ 'Defence to revocation', '🔄', 200),
+ ('cms-eingang.gegenseite.upc-rev.berufungsschrift',
+ 'Berufungsschrift Gegner (UPC_REV)',
+ 'Opposing party''s notice of appeal (UPC_REV)', '📈', 300)
+) AS s(slug, label_de, label_en, icon, sort_order)
+WHERE p.slug = 'cms-eingang.gegenseite.upc-rev';
+
+-- ============================================================================
+-- 8. Level 4 — children of cms-eingang.gegenseite.de-inf
+-- ============================================================================
+
+INSERT INTO paliad.event_categories
+ (slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
+SELECT
+ s.slug, p.id, s.label_de, s.label_en, s.icon, s.sort_order, true
+FROM paliad.event_categories p
+CROSS JOIN (VALUES
+ ('cms-eingang.gegenseite.de-inf.klageschrift',
+ 'Klageschrift LG', 'LG statement of claim', '📜', 100),
+ ('cms-eingang.gegenseite.de-inf.klageerwiderung',
+ 'Klageerwiderung LG', 'LG statement of defence', '🔄', 200),
+ ('cms-eingang.gegenseite.de-inf.berufungsschrift-olg',
+ 'Berufungsschrift OLG', 'OLG notice of appeal', '📈', 300)
+) AS s(slug, label_de, label_en, icon, sort_order)
+WHERE p.slug = 'cms-eingang.gegenseite.de-inf';
+
+-- ============================================================================
+-- 9. Level 4 — children of cms-eingang.gegenseite.de-null
+-- ============================================================================
+
+INSERT INTO paliad.event_categories
+ (slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
+SELECT
+ s.slug, p.id, s.label_de, s.label_en, s.icon, s.sort_order, true
+FROM paliad.event_categories p
+CROSS JOIN (VALUES
+ ('cms-eingang.gegenseite.de-null.nichtigkeitsklage',
+ 'Nichtigkeitsklage BPatG', 'BPatG application for revocation', '📜', 100),
+ ('cms-eingang.gegenseite.de-null.klageerwiderung',
+ 'Klageerwiderung BPatG', 'BPatG statement of defence', '🔄', 200),
+ ('cms-eingang.gegenseite.de-null.berufungsschrift-bgh',
+ 'Berufungsschrift BGH (Nichtigkeit)',
+ 'BGH notice of appeal (nullity)', '📈', 300)
+) AS s(slug, label_de, label_en, icon, sort_order)
+WHERE p.slug = 'cms-eingang.gegenseite.de-null';
+
+-- ============================================================================
+-- 10. Level 4 — children of cms-eingang.gegenseite.epa-opp
+-- ============================================================================
+
+INSERT INTO paliad.event_categories
+ (slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
+SELECT
+ s.slug, p.id, s.label_de, s.label_en, '📜', s.sort_order, true
+FROM paliad.event_categories p
+CROSS JOIN (VALUES
+ ('cms-eingang.gegenseite.epa-opp.einspruchsschrift',
+ 'Einspruchsschrift', 'Notice of opposition', 100)
+) AS s(slug, label_de, label_en, sort_order)
+WHERE p.slug = 'cms-eingang.gegenseite.epa-opp';
+
+-- ============================================================================
+-- 11. Level 2 — children of muendl-verhandlung
+-- ============================================================================
+
+INSERT INTO paliad.event_categories
+ (slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
+SELECT
+ s.slug, p.id, s.label_de, s.label_en, s.icon, s.sort_order, true
+FROM paliad.event_categories p
+CROSS JOIN (VALUES
+ ('muendl-verhandlung.geladen',
+ 'Geladen — wann findet sie statt?', 'Summoned — when?', '📅', 100),
+ ('muendl-verhandlung.gehalten',
+ 'Soeben gehalten / heute', 'Just held / today', '⌛', 200),
+ ('muendl-verhandlung.verlegt',
+ 'Verlegt', 'Postponed', '🔁', 300),
+ ('muendl-verhandlung.zwischenverfahren',
+ 'Zwischenverfahren / interim conference', 'Interim conference', '🤝', 400)
+) AS s(slug, label_de, label_en, icon, sort_order)
+WHERE p.slug = 'muendl-verhandlung';
+
+-- ============================================================================
+-- 12. Level 2 — children of beschluss-entscheidung
+-- (parallel set to cms-eingang.gericht.endentscheidung — different
+-- mental classification, same outcomes)
+-- ============================================================================
+
+INSERT INTO paliad.event_categories
+ (slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
+SELECT
+ s.slug, p.id, s.label_de, s.label_en, s.icon, s.sort_order, true
+FROM paliad.event_categories p
+CROSS JOIN (VALUES
+ ('beschluss-entscheidung.urteil-de-inf-lg',
+ 'Urteil LG (Verletzung)', 'LG judgment (infringement)', '⚖', 100),
+ ('beschluss-entscheidung.urteil-de-inf-olg',
+ 'Urteil OLG (Verletzung)', 'OLG judgment (infringement)', '⚖', 200),
+ ('beschluss-entscheidung.urteil-de-null-bpatg',
+ 'Urteil BPatG (Nichtigkeit)', 'BPatG judgment (nullity)', '⚖', 300),
+ ('beschluss-entscheidung.urteil-upc-cfi',
+ 'Sachentscheidung UPC (CFI)', 'UPC CFI decision', '⚖', 400),
+ ('beschluss-entscheidung.urteil-upc-coa',
+ 'Sachentscheidung UPC (CoA)', 'UPC CoA decision', '⚖', 500),
+ ('beschluss-entscheidung.entscheidung-epa-opp',
+ 'Einspruchsentscheidung EPA', 'EPO opposition decision', '⚖', 600),
+ ('beschluss-entscheidung.entscheidung-epa-boa',
+ 'Beschwerdeentscheidung EPA', 'EPO Board of Appeal decision', '⚖', 700),
+ ('beschluss-entscheidung.entscheidung-dpma',
+ 'DPMA-Einspruchsentscheidung', 'DPMA opposition decision', '⚖', 800),
+ ('beschluss-entscheidung.beschluss-bpatg-beschwerde',
+ 'Beschluss BPatG (DPMA-Beschwerde)', 'BPatG order (DPMA appeal)', '⚖', 900),
+ ('beschluss-entscheidung.versaeumnisurteil',
+ 'Versäumnisurteil (DE)', 'Default judgment (DE)', '📊', 1000),
+ ('beschluss-entscheidung.kostenfestsetzung',
+ 'Kostenfestsetzungsbeschluss', 'Cost-fixing order', '💰', 1100)
+) AS s(slug, label_de, label_en, icon, sort_order)
+WHERE p.slug = 'beschluss-entscheidung';
+
+-- ============================================================================
+-- 13. Level 2 — children of frist-verpasst
+-- ============================================================================
+
+INSERT INTO paliad.event_categories
+ (slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
+SELECT
+ s.slug, p.id, s.label_de, s.label_en, '🏛', s.sort_order, true
+FROM paliad.event_categories p
+CROSS JOIN (VALUES
+ ('frist-verpasst.de-patg',
+ 'DE Patentverfahren (PatG §123 — 2 Monate)',
+ 'DE patent proceedings (PatG §123)', 100),
+ ('frist-verpasst.de-zpo',
+ 'DE Zivilverfahren (ZPO §233 — 2 Wochen!)',
+ 'DE civil proceedings (ZPO §233 — 2 weeks!)', 200),
+ ('frist-verpasst.epa',
+ 'EPA (Art. 122 EPÜ — 2 Monate)',
+ 'EPO (Art. 122 EPC)', 300),
+ ('frist-verpasst.dpma',
+ 'DPMA (PatG §123)',
+ 'DPMA (PatG §123)', 400)
+) AS s(slug, label_de, label_en, sort_order)
+WHERE p.slug = 'frist-verpasst';
+
+-- ============================================================================
+-- 14. Level 2 — children of ich-moechte-einreichen
+-- ============================================================================
+
+INSERT INTO paliad.event_categories
+ (slug, parent_id, label_de, label_en, step_question_de, step_question_en,
+ icon, sort_order, is_leaf)
+SELECT
+ s.slug, p.id, s.label_de, s.label_en, s.step_question_de, s.step_question_en,
+ s.icon, s.sort_order, s.is_leaf
+FROM paliad.event_categories p
+CROSS JOIN (VALUES
+ ('ich-moechte-einreichen.klage',
+ 'Klage / Antrag (1. Instanz)', 'Claim / application (1st instance)',
+ 'In welchem Verfahren?', 'Which proceeding?',
+ '📜', 100, false),
+ ('ich-moechte-einreichen.berufung',
+ 'Berufung / Beschwerde (höhere Instanz)',
+ 'Appeal (higher instance)',
+ 'Welche Konstellation?', 'Which constellation?',
+ '📈', 200, false),
+ ('ich-moechte-einreichen.widerklage',
+ 'Widerklage / Counterclaim',
+ 'Counterclaim',
+ 'Welche Art von Widerklage?', 'Which kind of counterclaim?',
+ '🔁', 300, false),
+ ('ich-moechte-einreichen.spaetere-schriftsaetze',
+ 'Späterer Schriftsatz im laufenden Verfahren',
+ 'Later submission in pending proceedings',
+ 'Welcher späterer Schriftsatz?', 'Which later submission?',
+ '↩', 400, false),
+ ('ich-moechte-einreichen.einspruch-erteilung',
+ 'Einspruchsfrist nach Erteilung',
+ 'Opposition deadline after grant',
+ NULL, NULL,
+ '⚖', 500, true)
+) AS s(slug, label_de, label_en, step_question_de, step_question_en,
+ icon, sort_order, is_leaf)
+WHERE p.slug = 'ich-moechte-einreichen';
+
+-- ============================================================================
+-- 15. Level 3 — children of ich-moechte-einreichen.klage
+-- ============================================================================
+
+INSERT INTO paliad.event_categories
+ (slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
+SELECT
+ s.slug, p.id, s.label_de, s.label_en, '📜', s.sort_order, true
+FROM paliad.event_categories p
+CROSS JOIN (VALUES
+ ('ich-moechte-einreichen.klage.upc-inf',
+ 'UPC Verletzungsklage', 'UPC infringement claim', 100),
+ ('ich-moechte-einreichen.klage.upc-rev',
+ 'UPC Nichtigkeitsklage', 'UPC revocation claim', 200),
+ ('ich-moechte-einreichen.klage.upc-pi',
+ 'UPC einstw. Maßnahmen', 'UPC provisional measures', 300),
+ ('ich-moechte-einreichen.klage.upc-damages',
+ 'UPC Schadensbemessung', 'UPC damages determination', 400),
+ ('ich-moechte-einreichen.klage.upc-discovery',
+ 'UPC Bucheinsicht', 'UPC lay open books', 500),
+ ('ich-moechte-einreichen.klage.de-inf',
+ 'DE Verletzungsklage LG', 'DE infringement claim (LG)', 600),
+ ('ich-moechte-einreichen.klage.de-null',
+ 'DE Nichtigkeitsklage BPatG', 'DE revocation (BPatG)', 700),
+ ('ich-moechte-einreichen.klage.epa-opp',
+ 'EPA Einspruch', 'EPO opposition', 800),
+ ('ich-moechte-einreichen.klage.dpma-opp',
+ 'DPMA Einspruch', 'DPMA opposition', 900)
+) AS s(slug, label_de, label_en, sort_order)
+WHERE p.slug = 'ich-moechte-einreichen.klage';
+
+-- ============================================================================
+-- 16. Level 3 — children of ich-moechte-einreichen.berufung
+-- ============================================================================
+
+INSERT INTO paliad.event_categories
+ (slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
+SELECT
+ s.slug, p.id, s.label_de, s.label_en, '📈', s.sort_order, true
+FROM paliad.event_categories p
+CROSS JOIN (VALUES
+ ('ich-moechte-einreichen.berufung.de-olg',
+ 'Berufung OLG (Verletzung)', 'OLG appeal (infringement)', 100),
+ ('ich-moechte-einreichen.berufung.de-bgh-nzb',
+ 'Nichtzulassungsbeschwerde BGH', 'BGH leave-appeal complaint', 200),
+ ('ich-moechte-einreichen.berufung.de-bgh-revision',
+ 'Revision BGH', 'BGH revision', 300),
+ ('ich-moechte-einreichen.berufung.de-bgh-null',
+ 'Berufung BGH (Patentnichtigkeit)', 'BGH appeal (nullity)', 400),
+ ('ich-moechte-einreichen.berufung.upc-coa',
+ 'Berufung UPC CoA', 'UPC CoA appeal', 500),
+ ('ich-moechte-einreichen.berufung.upc-coa-orders',
+ 'Berufung UPC mit Zulassung (Anordnungen)',
+ 'UPC appeal with leave (orders)', 600),
+ ('ich-moechte-einreichen.berufung.upc-cost',
+ 'Berufung UPC Kostenentscheidung', 'UPC cost-decision appeal', 700),
+ ('ich-moechte-einreichen.berufung.epa',
+ 'Beschwerde EPA', 'EPO appeal', 800),
+ ('ich-moechte-einreichen.berufung.bpatg-beschwerde',
+ 'Beschwerde BPatG (gegen DPMA)',
+ 'BPatG appeal (vs DPMA)', 900),
+ ('ich-moechte-einreichen.berufung.bgh-rb',
+ 'Rechtsbeschwerde BGH (DPMA)',
+ 'BGH legal-complaint (DPMA)', 1000)
+) AS s(slug, label_de, label_en, sort_order)
+WHERE p.slug = 'ich-moechte-einreichen.berufung';
+
+-- ============================================================================
+-- 17. Level 3 — children of ich-moechte-einreichen.widerklage
+-- ============================================================================
+
+INSERT INTO paliad.event_categories
+ (slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
+SELECT
+ s.slug, p.id, s.label_de, s.label_en, '🔁', s.sort_order, true
+FROM paliad.event_categories p
+CROSS JOIN (VALUES
+ ('ich-moechte-einreichen.widerklage.nichtigkeit-upc',
+ 'Nichtigkeitswiderklage (UPC R.25)',
+ 'Counterclaim for revocation (UPC R.25)', 100),
+ ('ich-moechte-einreichen.widerklage.verletzung-upc',
+ 'Verletzungswiderklage (UPC R.50/49.2.b)',
+ 'Counterclaim for infringement (UPC R.50/49.2.b)', 200)
+) AS s(slug, label_de, label_en, sort_order)
+WHERE p.slug = 'ich-moechte-einreichen.widerklage';
+
+-- ============================================================================
+-- 18. Level 3 — children of ich-moechte-einreichen.spaetere-schriftsaetze
+-- ============================================================================
+
+INSERT INTO paliad.event_categories
+ (slug, parent_id, label_de, label_en, icon, sort_order, is_leaf)
+SELECT
+ s.slug, p.id, s.label_de, s.label_en, '↩', s.sort_order, true
+FROM paliad.event_categories p
+CROSS JOIN (VALUES
+ ('ich-moechte-einreichen.spaetere-schriftsaetze.replik-ccr-upc',
+ 'Replik auf Erwiderung zur Nichtigkeitsw. (UPC R.29.d)',
+ 'Reply to Defence to CCR (UPC R.29.d)', 100),
+ ('ich-moechte-einreichen.spaetere-schriftsaetze.duplik-ccr-upc',
+ 'Duplik auf Replik zur Nichtigkeitsw. (UPC R.29.e)',
+ 'Rejoinder on Reply to CCR (UPC R.29.e)', 200),
+ ('ich-moechte-einreichen.spaetere-schriftsaetze.duplik-amend-upc',
+ 'Duplik auf Replik zum Patentänderungsantrag (UPC R.32.3)',
+ 'Rejoinder on Reply to Application to amend (UPC R.32.3)', 300),
+ ('ich-moechte-einreichen.spaetere-schriftsaetze.replik-cci-upc',
+ 'Replik auf Erwiderung zur Verletzungsw. (UPC R.56.3)',
+ 'Reply to Defence to CCI (UPC R.56.3)', 400),
+ ('ich-moechte-einreichen.spaetere-schriftsaetze.duplik-cci-upc',
+ 'Duplik auf Replik zur Verletzungsw. (UPC R.56.4)',
+ 'Rejoinder on Reply to CCI (UPC R.56.4)', 500),
+ ('ich-moechte-einreichen.spaetere-schriftsaetze.kostenantrag',
+ 'Antrag auf Kostenentscheidung',
+ 'Application for cost decision', 600)
+) AS s(slug, label_de, label_en, sort_order)
+WHERE p.slug = 'ich-moechte-einreichen.spaetere-schriftsaetze';
+
+-- ============================================================================
+-- 19. Junction: leaf → concept (with optional proceeding_type_code)
+-- Each row is one (leaf, concept, proceeding_code) tuple.
+-- ============================================================================
+
+WITH leaf AS (
+ SELECT id, slug FROM paliad.event_categories WHERE is_leaf = true
+), concept AS (
+ SELECT id, slug FROM paliad.deadline_concepts
+)
+INSERT INTO paliad.event_category_concepts
+ (event_category_id, concept_id, proceeding_type_code, sort_order)
+SELECT l.id, c.id, m.proceeding_type_code, m.sort_order
+FROM (VALUES
+ -- ── 1. CMS-Eingang vom Gericht ──
+ ('cms-eingang.gericht.hinweisbeschluss', 'response-to-preliminary-opinion', 'DE_NULL', 100),
+ ('cms-eingang.gericht.hinweisbeschluss', 'response-to-preliminary-opinion', 'DE_INF', 200),
+ ('cms-eingang.gericht.ladung', 'r116-final-submissions', 'EPA_OPP', 100),
+ ('cms-eingang.gericht.ladung', 'r116-final-submissions', 'EPA_APP', 200),
+ ('cms-eingang.gericht.ladung', 'schriftsatznachreichung', NULL, 300),
+ ('cms-eingang.gericht.kostenfestsetzung', 'notice-of-appeal', 'UPC_COST_APPEAL', 100),
+ ('cms-eingang.gericht.rechtsverlust-epa', 'weiterbehandlung', NULL, 100),
+ ('cms-eingang.gericht.rechtsverlust-epa', 'wiedereinsetzung', NULL, 200),
+ ('cms-eingang.gericht.anordnung', 'request-for-discretionary-review', NULL, 100),
+
+ -- ── 1.x Endentscheidung (CMS-Eingang variant) ──
+ ('cms-eingang.gericht.endentscheidung.urteil-de-inf-lg', 'notice-of-appeal', 'DE_INF_OLG', 100),
+ ('cms-eingang.gericht.endentscheidung.urteil-de-inf-lg', 'statement-of-grounds-of-appeal', 'DE_INF_OLG', 200),
+ ('cms-eingang.gericht.endentscheidung.urteil-de-inf-olg', 'nichtzulassungsbeschwerde', 'DE_INF_BGH', 100),
+ ('cms-eingang.gericht.endentscheidung.urteil-de-inf-olg', 'nichtzulassungsbeschwerde-begruendung', 'DE_INF_BGH', 200),
+ ('cms-eingang.gericht.endentscheidung.urteil-de-inf-olg', 'revisionsfrist', 'DE_INF_BGH', 300),
+ ('cms-eingang.gericht.endentscheidung.urteil-de-inf-olg', 'revisionsbegruendung', 'DE_INF_BGH', 400),
+ ('cms-eingang.gericht.endentscheidung.urteil-de-null-bpatg', 'notice-of-appeal', 'DE_NULL_BGH', 100),
+ ('cms-eingang.gericht.endentscheidung.urteil-de-null-bpatg', 'statement-of-grounds-of-appeal', 'DE_NULL_BGH', 200),
+ ('cms-eingang.gericht.endentscheidung.urteil-upc-cfi', 'notice-of-appeal', 'UPC_APP', 100),
+ ('cms-eingang.gericht.endentscheidung.urteil-upc-cfi', 'statement-of-grounds-of-appeal', 'UPC_APP', 200),
+ ('cms-eingang.gericht.endentscheidung.urteil-upc-coa', 'petition-for-review', NULL, 100),
+ ('cms-eingang.gericht.endentscheidung.entscheidung-epa-opp', 'notice-of-appeal', 'EPA_APP', 100),
+ ('cms-eingang.gericht.endentscheidung.entscheidung-epa-opp', 'statement-of-grounds-of-appeal', 'EPA_APP', 200),
+ ('cms-eingang.gericht.endentscheidung.entscheidung-epa-boa', 'petition-for-review', 'EPA_APP', 100),
+ ('cms-eingang.gericht.endentscheidung.entscheidung-dpma', 'notice-of-appeal', 'DPMA_BPATG_BESCHWERDE', 100),
+ ('cms-eingang.gericht.endentscheidung.entscheidung-dpma', 'statement-of-grounds-of-appeal', 'DPMA_BPATG_BESCHWERDE', 200),
+ ('cms-eingang.gericht.endentscheidung.beschluss-bpatg-beschwerde', 'rechtsbeschwerde', 'DPMA_BGH_RB', 100),
+ ('cms-eingang.gericht.endentscheidung.beschluss-bpatg-beschwerde', 'rechtsbeschwerde-begruendung', 'DPMA_BGH_RB', 200),
+ ('cms-eingang.gericht.endentscheidung.versaeumnisurteil', 'versaeumnisurteil-einspruch', NULL, 100),
+
+ -- ── 1.x Gegenseite UPC_INF ──
+ ('cms-eingang.gegenseite.upc-inf.klageschrift', 'notice-of-defence-intention', 'UPC_INF', 100),
+ ('cms-eingang.gegenseite.upc-inf.klageschrift', 'statement-of-defence', 'UPC_INF', 200),
+ ('cms-eingang.gegenseite.upc-inf.klageerwiderung-mit-ccr', 'defence-to-counterclaim-for-revocation', 'UPC_INF', 100),
+ ('cms-eingang.gegenseite.upc-inf.klageerwiderung-mit-ccr', 'application-to-amend', 'UPC_INF', 200),
+ ('cms-eingang.gegenseite.upc-inf.klageerwiderung-mit-ccr', 'reply-to-defence', 'UPC_INF', 300),
+ ('cms-eingang.gegenseite.upc-inf.klageerwiderung-ohne-ccr', 'reply-to-defence', 'UPC_INF', 100),
+ ('cms-eingang.gegenseite.upc-inf.replik', 'rejoinder', 'UPC_INF', 100),
+ ('cms-eingang.gegenseite.upc-inf.antrag-patentaenderung', 'defence-to-application-to-amend', 'UPC_INF', 100),
+ ('cms-eingang.gegenseite.upc-inf.antrag-patentaenderung', 'reply-to-defence-to-application-to-amend', 'UPC_INF', 200),
+ ('cms-eingang.gegenseite.upc-inf.berufungsschrift', 'response-to-appeal', 'UPC_APP', 100),
+ ('cms-eingang.gegenseite.upc-inf.berufungsschrift', 'cross-appeal', 'UPC_APP', 200),
+
+ -- ── 1.x Gegenseite UPC_REV ──
+ ('cms-eingang.gegenseite.upc-rev.nichtigkeitsklage', 'statement-of-defence', 'UPC_REV', 100),
+ ('cms-eingang.gegenseite.upc-rev.defence-to-revocation', 'reply-to-defence', 'UPC_REV', 100),
+ ('cms-eingang.gegenseite.upc-rev.defence-to-revocation', 'defence-to-application-to-amend', 'UPC_REV', 200),
+ ('cms-eingang.gegenseite.upc-rev.defence-to-revocation', 'defence-to-counterclaim-for-infringement', 'UPC_REV', 300),
+ ('cms-eingang.gegenseite.upc-rev.berufungsschrift', 'response-to-appeal', 'UPC_APP', 100),
+ ('cms-eingang.gegenseite.upc-rev.berufungsschrift', 'cross-appeal', 'UPC_APP', 200),
+
+ -- ── 1.x Gegenseite DE_INF ──
+ ('cms-eingang.gegenseite.de-inf.klageschrift', 'notice-of-defence-intention', 'DE_INF', 100),
+ ('cms-eingang.gegenseite.de-inf.klageschrift', 'statement-of-defence', 'DE_INF', 200),
+ ('cms-eingang.gegenseite.de-inf.klageerwiderung', 'reply-to-defence', 'DE_INF', 100),
+ ('cms-eingang.gegenseite.de-inf.berufungsschrift-olg', 'response-to-appeal', 'DE_INF_OLG', 100),
+ ('cms-eingang.gegenseite.de-inf.berufungsschrift-olg', 'cross-appeal', 'DE_INF_OLG', 200),
+
+ -- ── 1.x Gegenseite DE_NULL ──
+ ('cms-eingang.gegenseite.de-null.nichtigkeitsklage', 'statement-of-defence', 'DE_NULL', 100),
+ ('cms-eingang.gegenseite.de-null.klageerwiderung', 'reply-to-defence', 'DE_NULL', 100),
+ ('cms-eingang.gegenseite.de-null.berufungsschrift-bgh', 'response-to-appeal', 'DE_NULL_BGH', 100),
+
+ -- ── 1.x Gegenseite EPA OPP / APP / DPMA ──
+ ('cms-eingang.gegenseite.epa-opp.einspruchsschrift', 'statement-of-defence', 'EPA_OPP', 100),
+ ('cms-eingang.gegenseite.epa-opp.einspruchsschrift', 'r79-further-stellungnahme', 'EPA_OPP', 200),
+ ('cms-eingang.gegenseite.epa-app', 'response-to-appeal', 'EPA_APP', 100),
+ ('cms-eingang.gegenseite.dpma-opp', 'statement-of-defence', 'DPMA_OPP', 100),
+
+ -- ── 2. Mündliche Verhandlung ──
+ ('muendl-verhandlung.geladen', 'r116-final-submissions', 'EPA_OPP', 100),
+ ('muendl-verhandlung.geladen', 'r116-final-submissions', 'EPA_APP', 200),
+ ('muendl-verhandlung.geladen', 'schriftsatznachreichung', NULL, 300),
+ ('muendl-verhandlung.gehalten', 'schriftsatznachreichung', NULL, 100),
+ ('muendl-verhandlung.zwischenverfahren', 'interim-conference', 'UPC_INF', 100),
+ ('muendl-verhandlung.zwischenverfahren', 'interim-conference', 'UPC_REV', 200),
+ -- (verlegt has no concept outcome — informational card only)
+
+ -- ── 3. Beschluss / Entscheidung (parallel set, same outcomes) ──
+ ('beschluss-entscheidung.urteil-de-inf-lg', 'notice-of-appeal', 'DE_INF_OLG', 100),
+ ('beschluss-entscheidung.urteil-de-inf-lg', 'statement-of-grounds-of-appeal', 'DE_INF_OLG', 200),
+ ('beschluss-entscheidung.urteil-de-inf-olg', 'nichtzulassungsbeschwerde', 'DE_INF_BGH', 100),
+ ('beschluss-entscheidung.urteil-de-inf-olg', 'nichtzulassungsbeschwerde-begruendung', 'DE_INF_BGH', 200),
+ ('beschluss-entscheidung.urteil-de-inf-olg', 'revisionsfrist', 'DE_INF_BGH', 300),
+ ('beschluss-entscheidung.urteil-de-inf-olg', 'revisionsbegruendung', 'DE_INF_BGH', 400),
+ ('beschluss-entscheidung.urteil-de-null-bpatg', 'notice-of-appeal', 'DE_NULL_BGH', 100),
+ ('beschluss-entscheidung.urteil-de-null-bpatg', 'statement-of-grounds-of-appeal', 'DE_NULL_BGH', 200),
+ ('beschluss-entscheidung.urteil-upc-cfi', 'notice-of-appeal', 'UPC_APP', 100),
+ ('beschluss-entscheidung.urteil-upc-cfi', 'statement-of-grounds-of-appeal', 'UPC_APP', 200),
+ ('beschluss-entscheidung.urteil-upc-coa', 'petition-for-review', NULL, 100),
+ ('beschluss-entscheidung.entscheidung-epa-opp', 'notice-of-appeal', 'EPA_APP', 100),
+ ('beschluss-entscheidung.entscheidung-epa-opp', 'statement-of-grounds-of-appeal', 'EPA_APP', 200),
+ ('beschluss-entscheidung.entscheidung-epa-boa', 'petition-for-review', 'EPA_APP', 100),
+ ('beschluss-entscheidung.entscheidung-dpma', 'notice-of-appeal', 'DPMA_BPATG_BESCHWERDE', 100),
+ ('beschluss-entscheidung.entscheidung-dpma', 'statement-of-grounds-of-appeal', 'DPMA_BPATG_BESCHWERDE', 200),
+ ('beschluss-entscheidung.beschluss-bpatg-beschwerde', 'rechtsbeschwerde', 'DPMA_BGH_RB', 100),
+ ('beschluss-entscheidung.beschluss-bpatg-beschwerde', 'rechtsbeschwerde-begruendung', 'DPMA_BGH_RB', 200),
+ ('beschluss-entscheidung.versaeumnisurteil', 'versaeumnisurteil-einspruch', NULL, 100),
+ ('beschluss-entscheidung.kostenfestsetzung', 'notice-of-appeal', 'UPC_COST_APPEAL', 100),
+
+ -- ── 4. Frist verpasst ──
+ ('frist-verpasst.de-patg', 'wiedereinsetzung', NULL, 100),
+ ('frist-verpasst.de-zpo', 'wiedereinsetzung', NULL, 100),
+ ('frist-verpasst.epa', 'wiedereinsetzung', NULL, 100),
+ ('frist-verpasst.epa', 'weiterbehandlung', NULL, 200),
+ ('frist-verpasst.dpma', 'wiedereinsetzung', NULL, 100),
+
+ -- ── 5. Ich möchte einreichen — Klage ──
+ ('ich-moechte-einreichen.klage.upc-inf', 'statement-of-claim', 'UPC_INF', 100),
+ ('ich-moechte-einreichen.klage.upc-rev', 'application-for-revocation', 'UPC_REV', 100),
+ ('ich-moechte-einreichen.klage.upc-pi', 'application-for-provisional-measures', 'UPC_PI', 100),
+ ('ich-moechte-einreichen.klage.upc-damages', 'application-for-determination-of-damages', 'UPC_DAMAGES', 100),
+ ('ich-moechte-einreichen.klage.upc-discovery', 'request-to-lay-open-books', 'UPC_DISCOVERY', 100),
+ ('ich-moechte-einreichen.klage.de-inf', 'statement-of-claim', 'DE_INF', 100),
+ ('ich-moechte-einreichen.klage.de-null', 'application-for-revocation', 'DE_NULL', 100),
+ ('ich-moechte-einreichen.klage.epa-opp', 'opposition', 'EPA_OPP', 100),
+ ('ich-moechte-einreichen.klage.dpma-opp', 'opposition', 'DPMA_OPP', 100),
+
+ -- ── 6. Ich möchte einreichen — Berufung ──
+ ('ich-moechte-einreichen.berufung.de-olg', 'notice-of-appeal', 'DE_INF_OLG', 100),
+ ('ich-moechte-einreichen.berufung.de-olg', 'statement-of-grounds-of-appeal', 'DE_INF_OLG', 200),
+ ('ich-moechte-einreichen.berufung.de-bgh-nzb', 'nichtzulassungsbeschwerde', 'DE_INF_BGH', 100),
+ ('ich-moechte-einreichen.berufung.de-bgh-nzb', 'nichtzulassungsbeschwerde-begruendung', 'DE_INF_BGH', 200),
+ ('ich-moechte-einreichen.berufung.de-bgh-revision', 'revisionsfrist', 'DE_INF_BGH', 100),
+ ('ich-moechte-einreichen.berufung.de-bgh-revision', 'revisionsbegruendung', 'DE_INF_BGH', 200),
+ ('ich-moechte-einreichen.berufung.de-bgh-null', 'notice-of-appeal', 'DE_NULL_BGH', 100),
+ ('ich-moechte-einreichen.berufung.de-bgh-null', 'statement-of-grounds-of-appeal', 'DE_NULL_BGH', 200),
+ ('ich-moechte-einreichen.berufung.upc-coa', 'notice-of-appeal', 'UPC_APP', 100),
+ ('ich-moechte-einreichen.berufung.upc-coa', 'statement-of-grounds-of-appeal', 'UPC_APP', 200),
+ ('ich-moechte-einreichen.berufung.upc-coa-orders', 'appeal-with-leave', 'UPC_APP_ORDERS', 100),
+ ('ich-moechte-einreichen.berufung.upc-coa-orders', 'application-for-leave-to-appeal', 'UPC_APP_ORDERS', 200),
+ ('ich-moechte-einreichen.berufung.upc-cost', 'notice-of-appeal', 'UPC_COST_APPEAL', 100),
+ ('ich-moechte-einreichen.berufung.epa', 'notice-of-appeal', 'EPA_APP', 100),
+ ('ich-moechte-einreichen.berufung.epa', 'statement-of-grounds-of-appeal', 'EPA_APP', 200),
+ ('ich-moechte-einreichen.berufung.bpatg-beschwerde', 'notice-of-appeal', 'DPMA_BPATG_BESCHWERDE', 100),
+ ('ich-moechte-einreichen.berufung.bpatg-beschwerde', 'statement-of-grounds-of-appeal', 'DPMA_BPATG_BESCHWERDE', 200),
+ ('ich-moechte-einreichen.berufung.bgh-rb', 'rechtsbeschwerde', 'DPMA_BGH_RB', 100),
+ ('ich-moechte-einreichen.berufung.bgh-rb', 'rechtsbeschwerde-begruendung', 'DPMA_BGH_RB', 200),
+
+ -- ── 7. Ich möchte einreichen — Widerklage ──
+ ('ich-moechte-einreichen.widerklage.nichtigkeit-upc', 'counterclaim-for-revocation', 'UPC_INF', 100),
+ ('ich-moechte-einreichen.widerklage.nichtigkeit-upc', 'application-to-amend', 'UPC_INF', 200),
+ ('ich-moechte-einreichen.widerklage.verletzung-upc', 'counterclaim-for-infringement', 'UPC_REV', 100),
+
+ -- ── 8. Ich möchte einreichen — Spätere Schriftsätze ──
+ ('ich-moechte-einreichen.spaetere-schriftsaetze.replik-ccr-upc', 'reply-to-defence-to-counterclaim-for-revocation', 'UPC_INF', 100),
+ ('ich-moechte-einreichen.spaetere-schriftsaetze.duplik-ccr-upc', 'rejoinder-on-reply-to-defence-to-ccr', 'UPC_INF', 100),
+ ('ich-moechte-einreichen.spaetere-schriftsaetze.duplik-amend-upc','rejoinder-on-reply-to-amend', 'UPC_INF', 100),
+ ('ich-moechte-einreichen.spaetere-schriftsaetze.duplik-amend-upc','rejoinder-on-reply-to-amend', 'UPC_REV', 200),
+ ('ich-moechte-einreichen.spaetere-schriftsaetze.replik-cci-upc', 'reply-to-defence-to-counterclaim-for-infringement', 'UPC_REV', 100),
+ ('ich-moechte-einreichen.spaetere-schriftsaetze.duplik-cci-upc', 'rejoinder-on-counterclaim-for-infringement', 'UPC_REV', 100),
+ ('ich-moechte-einreichen.spaetere-schriftsaetze.kostenantrag', 'application-for-cost-decision', 'UPC_INF', 100),
+
+ -- ── 9. Einspruch nach Erteilung ──
+ ('ich-moechte-einreichen.einspruch-erteilung', 'opposition', 'EPA_OPP', 100),
+ ('ich-moechte-einreichen.einspruch-erteilung', 'opposition', 'DPMA_OPP', 200)
+
+) AS m(leaf_slug, concept_slug, proceeding_type_code, sort_order)
+JOIN leaf l ON l.slug = m.leaf_slug
+JOIN concept c ON c.slug = m.concept_slug;
+
+-- ============================================================================
+-- 20. Coverage gate: every category='submission' concept must be reachable
+-- from at least one leaf, except a small exempt list of pure-administrative
+-- concepts that live on Pathway A (browse-by-proceeding) only.
+-- ============================================================================
+
+DO $coverage$
+DECLARE
+ unreachable_count int;
+ unreachable_slugs text;
+BEGIN
+ SELECT count(*),
+ string_agg(dc.slug, ', ' ORDER BY dc.slug)
+ INTO unreachable_count, unreachable_slugs
+ FROM paliad.deadline_concepts dc
+ WHERE dc.is_active
+ AND dc.category = 'submission'
+ AND dc.slug NOT IN (
+ -- Pure-administrative concepts: filed during prosecution, not
+ -- typically discovered via "what happened" decision tree.
+ 'filing',
+ 'request-for-examination',
+ 'approval-and-translation'
+ )
+ AND NOT EXISTS (
+ SELECT 1
+ FROM paliad.event_category_concepts ecc
+ WHERE ecc.concept_id = dc.id
+ );
+
+ IF unreachable_count > 0 THEN
+ RAISE EXCEPTION
+ 'Phase A seed: % submission concept(s) unreachable from any leaf: %',
+ unreachable_count, unreachable_slugs;
+ END IF;
+END $coverage$;
diff --git a/internal/db/migrations/050_bilateral_rules_backfill.down.sql b/internal/db/migrations/050_bilateral_rules_backfill.down.sql
new file mode 100644
index 0000000..58a59f7
--- /dev/null
+++ b/internal/db/migrations/050_bilateral_rules_backfill.down.sql
@@ -0,0 +1,9 @@
+-- t-paliad-133 Phase A bilateral-tag rollback.
+UPDATE paliad.deadline_rules
+ SET is_bilateral = false
+ WHERE code IN (
+ 'de_null.stellungnahme',
+ 'epa_opp.r79_further',
+ 'epa_opp.r116',
+ 'epa_app.r116'
+ );
diff --git a/internal/db/migrations/050_bilateral_rules_backfill.up.sql b/internal/db/migrations/050_bilateral_rules_backfill.up.sql
new file mode 100644
index 0000000..41ad5be
--- /dev/null
+++ b/internal/db/migrations/050_bilateral_rules_backfill.up.sql
@@ -0,0 +1,51 @@
+-- t-paliad-133 Phase A backfill: tag genuinely-bilateral deadline rules.
+--
+-- Most rules with primary_party='both' are role-swap appeals — either
+-- party can file depending on who lost / acted at the lower instance.
+-- Those resolve at render time via the new perspective selector
+-- (?my_side= + ?appeal_filed_by=). The renderer assigns them to ONE
+-- column based on perspective.
+--
+-- The exceptions are GENUINELY BILATERAL rules — both parties can or
+-- must file independently of who acted before. These mirror into BOTH
+-- party columns of the v3 column-timeline view.
+--
+-- Set is_bilateral=true ONLY for:
+-- • Stellungnahme zum Hinweisbeschluss (DE_NULL §83(2)) — both parties
+-- comment on the court's preliminary opinion.
+-- • R.79 Stellungnahme weiterer Beteiligter (EPA_OPP) — multi-party
+-- opposition; all parties may submit.
+-- • R.116 Eingaben vor mündl. Verhandlung (EPA_OPP, EPA_APP) — every
+-- party prepares submissions before the oral hearing.
+--
+-- The cross-cutting schriftsatznachreichung lives in event_deadlines
+-- (not deadline_rules), so its bilateral nature is handled by the
+-- frontend renderer separately — no DB change needed.
+--
+-- Spot-checkable list of 4 rules; m or HLC colleague reviews on this
+-- commit.
+
+UPDATE paliad.deadline_rules
+ SET is_bilateral = true
+ WHERE code IN (
+ 'de_null.stellungnahme',
+ 'epa_opp.r79_further',
+ 'epa_opp.r116',
+ 'epa_app.r116'
+ )
+ AND is_active = true;
+
+-- Sanity check: exactly 4 rules tagged.
+DO $check$
+DECLARE
+ tagged int;
+BEGIN
+ SELECT count(*) INTO tagged
+ FROM paliad.deadline_rules
+ WHERE is_bilateral = true;
+ IF tagged <> 4 THEN
+ RAISE EXCEPTION
+ 'Phase A bilateral backfill: expected 4 rules tagged, got %',
+ tagged;
+ END IF;
+END $check$;
diff --git a/internal/handlers/fristenrechner_event_categories.go b/internal/handlers/fristenrechner_event_categories.go
new file mode 100644
index 0000000..6d7699c
--- /dev/null
+++ b/internal/handlers/fristenrechner_event_categories.go
@@ -0,0 +1,31 @@
+package handlers
+
+import (
+ "net/http"
+)
+
+// GET /api/tools/fristenrechner/event-categories — returns the full
+// decision-tree taxonomy for the v3 Pathway B / B1 cascade UI
+// (t-paliad-133). Tree is small (~100 nodes) and mostly static; the
+// frontend ETag-caches it via localStorage.
+//
+// Returns 503 if the DB-backed services aren't wired (DATABASE_URL
+// unset).
+func handleFristenrechnerEventCategories(w http.ResponseWriter, r *http.Request) {
+ if dbSvc == nil || dbSvc.eventCategory == nil {
+ writeJSON(w, http.StatusServiceUnavailable, map[string]string{
+ "error": "Decision-tree-Taxonomie vorübergehend nicht verfügbar (keine Datenbank).",
+ })
+ return
+ }
+ tree, err := dbSvc.eventCategory.Tree(r.Context())
+ if err != nil {
+ writeJSON(w, http.StatusInternalServerError, map[string]string{
+ "error": "Decision-tree fehlgeschlagen: " + err.Error(),
+ })
+ return
+ }
+ writeJSON(w, http.StatusOK, map[string]any{
+ "tree": tree,
+ })
+}
diff --git a/internal/handlers/fristenrechner_search.go b/internal/handlers/fristenrechner_search.go
index 8730581..618a503 100644
--- a/internal/handlers/fristenrechner_search.go
+++ b/internal/handlers/fristenrechner_search.go
@@ -3,15 +3,30 @@ package handlers
import (
"net/http"
"strconv"
+ "strings"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/tools/fristenrechner/search — unified search across the
-// Fristenrechner concept layer (t-paliad-131 Phase C). Returns at most
-// `limit` concept cards, each with its proceeding pills. Supports
-// optional facet filters: party, proc (proceeding code), source
-// (legal_source prefix).
+// Fristenrechner concept layer (t-paliad-131 Phase C, t-paliad-133 v3
+// extension). Returns at most `limit` concept cards, each with its
+// proceeding pills.
+//
+// Query params:
+// q - free-text search (trigram + alias)
+// party - filter by effective_party
+// proc - filter by proceeding_type code
+// source - filter by legal_source prefix
+// event_category_slug - v3 B1 narrowing; only concepts reachable
+// from this taxonomy node and its descendants
+// appear. Empty q is allowed when this is set
+// (browse mode).
+// forum - comma-separated v3 forum-bucket slugs
+// (upc_cfi, upc_coa, de_lg, de_olg, de_bgh,
+// de_bpatg, epa_grant, epa_opp, epa_appeal,
+// dpma). Trigger pills bypass this filter.
+// limit - max cards (default 12, max 30)
//
// Returns an empty cards array (not 400) when q is empty — that lets
// the frontend boot the search input without a server round-trip.
@@ -24,10 +39,12 @@ func handleFristenrechnerSearch(w http.ResponseWriter, r *http.Request) {
}
q := r.URL.Query().Get("q")
opts := services.SearchOptions{
- Party: r.URL.Query().Get("party"),
- Proc: r.URL.Query().Get("proc"),
- Source: r.URL.Query().Get("source"),
- Limit: parseLimit(r.URL.Query().Get("limit")),
+ Party: r.URL.Query().Get("party"),
+ Proc: r.URL.Query().Get("proc"),
+ Source: r.URL.Query().Get("source"),
+ EventCategorySlug: r.URL.Query().Get("event_category_slug"),
+ Forums: parseCSV(r.URL.Query().Get("forum")),
+ Limit: parseLimit(r.URL.Query().Get("limit")),
}
resp, err := dbSvc.deadlineSearch.Search(r.Context(), q, opts)
if err != nil {
@@ -37,6 +54,26 @@ func handleFristenrechnerSearch(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
+// parseCSV splits a comma-separated query-string value into a slice of
+// trimmed non-empty entries. Empty input → nil.
+func parseCSV(raw string) []string {
+ if raw == "" {
+ return nil
+ }
+ parts := strings.Split(raw, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ p = strings.TrimSpace(p)
+ if p != "" {
+ out = append(out, p)
+ }
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
func parseLimit(raw string) int {
if raw == "" {
return 0
diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go
index 1fe0f47..f5b40b9 100644
--- a/internal/handlers/handlers.go
+++ b/internal/handlers/handlers.go
@@ -49,6 +49,7 @@ type Services struct {
Fristenrechner *services.FristenrechnerService
EventDeadline *services.EventDeadlineService
DeadlineSearch *services.DeadlineSearchService
+ EventCategory *services.EventCategoryService
EventType *services.EventTypeService
Dashboard *services.DashboardService
Note *services.NoteService
@@ -81,6 +82,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
fristenrechner: svc.Fristenrechner,
eventDeadline: svc.EventDeadline,
deadlineSearch: svc.DeadlineSearch,
+ eventCategory: svc.EventCategory,
eventType: svc.EventType,
dashboard: svc.Dashboard,
note: svc.Note,
@@ -136,6 +138,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/tools/trigger-events", handleTriggerEventsList)
protected.HandleFunc("POST /api/tools/event-deadlines", handleEventDeadlinesCalculate)
protected.HandleFunc("GET /api/tools/fristenrechner/search", handleFristenrechnerSearch)
+ protected.HandleFunc("GET /api/tools/fristenrechner/event-categories", handleFristenrechnerEventCategories)
protected.HandleFunc("GET /downloads", handleDownloadsPage)
protected.HandleFunc("GET /glossary", handleGlossaryPage)
protected.HandleFunc("GET /api/glossary", handleGlossaryAPI)
diff --git a/internal/handlers/projects.go b/internal/handlers/projects.go
index ffd8886..efcb7da 100644
--- a/internal/handlers/projects.go
+++ b/internal/handlers/projects.go
@@ -29,6 +29,7 @@ type dbServices struct {
fristenrechner *services.FristenrechnerService
eventDeadline *services.EventDeadlineService
deadlineSearch *services.DeadlineSearchService
+ eventCategory *services.EventCategoryService
eventType *services.EventTypeService
dashboard *services.DashboardService
note *services.NoteService
diff --git a/internal/services/deadline_search_service.go b/internal/services/deadline_search_service.go
index ec1d013..1de9fc9 100644
--- a/internal/services/deadline_search_service.go
+++ b/internal/services/deadline_search_service.go
@@ -25,22 +25,64 @@ import (
// 2. Fetch all matview rows for those concept_ids and assemble the
// per-pill payload.
//
-// See docs/plans/unified-fristenrechner.md §4.6 + §6.
+// v3 (t-paliad-133) extends the service to accept:
+// - EventCategorySlug: drives the B1 decision-tree narrowing. When
+// set, only concepts reachable from that taxonomy node (via the
+// paliad.event_category_concepts junction) appear in results.
+// An empty `q` is permitted when EventCategorySlug is set — the
+// tree alone is enough to produce a candidate concept set.
+// - Forums: a list of forum slugs from the v3 bucket map. Translated
+// to proceeding_type_codes by the search service; trigger-event
+// pills bypass the forum filter (cross-cutting by design).
+//
+// See docs/plans/unified-fristenrechner.md §4.6 + §6 (v2) and
+// docs/plans/unified-fristenrechner-v3.md §3.5 + §5.2 (v3).
type DeadlineSearchService struct {
- db *sqlx.DB
+ db *sqlx.DB
+ eventCategory *EventCategoryService
}
-// NewDeadlineSearchService wires the service to its DB pool.
+// NewDeadlineSearchService wires the service to its DB pool. The
+// EventCategoryService dependency is optional — pass nil if the v3
+// taxonomy isn't needed (legacy callers).
func NewDeadlineSearchService(db *sqlx.DB) *DeadlineSearchService {
return &DeadlineSearchService{db: db}
}
+// SetEventCategoryService injects the optional v3 event-category
+// resolver. Wired by main.go after both services exist.
+func (s *DeadlineSearchService) SetEventCategoryService(ec *EventCategoryService) {
+ s.eventCategory = ec
+}
+
+// ForumToProceedingCodes maps the v3 forum buckets to proceeding_type
+// codes. Lives here (rather than in the DB) because the bucket choice
+// is presentation, not data — m can rebucket via code change without
+// migration. m's spec lock §10 Q8 (2026-05-05): 10 buckets.
+//
+// Empty bucket slug = no narrowing.
+var ForumToProceedingCodes = map[string][]string{
+ "upc_cfi": {"UPC_INF", "UPC_REV", "UPC_PI", "UPC_DAMAGES", "UPC_DISCOVERY", "UPC_APP_ORDERS"},
+ "upc_coa": {"UPC_APP", "UPC_COST_APPEAL"},
+ "de_lg": {"DE_INF"},
+ "de_olg": {"DE_INF_OLG"},
+ "de_bgh": {"DE_INF_BGH", "DE_NULL_BGH", "DPMA_BGH_RB"},
+ "de_bpatg": {"DE_NULL", "DPMA_BPATG_BESCHWERDE"},
+ "epa_grant": {"EP_GRANT"},
+ "epa_opp": {"EPA_OPP"},
+ "epa_appeal": {"EPA_APP"},
+ "dpma": {"DPMA_OPP"},
+}
+
// SearchOptions carries the optional facet filters from the URL query
-// string. Empty strings mean "no filter on this facet".
+// string. Empty strings / empty slices mean "no filter on this facet".
type SearchOptions struct {
Party string
Proc string
Source string
+ // v3 (t-paliad-133):
+ EventCategorySlug string // drives B1 decision-tree narrowing
+ Forums []string // multi-select forum buckets (UNION within)
Limit int
MaxLimit int
}
@@ -153,9 +195,10 @@ type pillRow struct {
// Search runs the two-query pipeline and assembles the cards.
//
-// q is the raw user input. Empty q returns an empty result set (no
-// filtering across the entire matview — that's a "browse" surface
-// the design doc reserves for Phase D).
+// q is the raw user input. Empty q returns an empty result set UNLESS
+// opts.EventCategorySlug is set — that triggers v3 browse-mode where the
+// taxonomy alone produces a candidate concept list (used by the B1
+// decision-tree cascade in Pathway B).
func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts SearchOptions) (*SearchResponse, error) {
limit := opts.Limit
if limit <= 0 {
@@ -176,18 +219,45 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
}
qNorm := normalizeQuery(q)
- if qNorm == "" {
+ browseMode := qNorm == "" && opts.EventCategorySlug != ""
+
+ // v3: resolve the event-category slug to a concept_id allow-list.
+ var allowConceptIDs []string
+ if opts.EventCategorySlug != "" && s.eventCategory != nil {
+ ids, err := s.eventCategory.ConceptIDsForSlug(ctx, opts.EventCategorySlug)
+ if err != nil {
+ return nil, err
+ }
+ if len(ids) == 0 {
+ // Slug resolves to no concepts; return empty without hitting
+ // the matview.
+ return resp, nil
+ }
+ allowConceptIDs = ids
+ }
+
+ // v3: translate forum slugs to proceeding_code allow-list.
+ forumCodes := translateForums(opts.Forums)
+
+ if !browseMode && qNorm == "" {
return resp, nil
}
- qLow := strings.ToLower(qNorm)
party := nullable(opts.Party)
proc := nullable(opts.Proc)
source := nullable(opts.Source)
- ranks, err := s.rankConcepts(ctx, qNorm, qLow, party, proc, source, limit)
- if err != nil {
- return nil, err
+ var ranks []rankRow
+ if browseMode {
+ // Browse mode: synthesize ranks from the allow-list directly.
+ ranks = s.browseRanks(ctx, allowConceptIDs, party, proc, source, forumCodes, limit)
+ } else {
+ qLow := strings.ToLower(qNorm)
+ var err error
+ ranks, err = s.rankConcepts(ctx, qNorm, qLow, party, proc, source, allowConceptIDs, forumCodes, limit)
+ if err != nil {
+ return nil, err
+ }
}
if len(ranks) == 0 {
return resp, nil
@@ -197,7 +267,7 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
for i, r := range ranks {
conceptIDs[i] = r.ConceptID
}
- pills, err := s.loadPills(ctx, conceptIDs, party, proc, source)
+ pills, err := s.loadPills(ctx, conceptIDs, party, proc, source, forumCodes)
if err != nil {
return nil, err
}
@@ -209,12 +279,99 @@ func (s *DeadlineSearchService) Search(ctx context.Context, q string, opts Searc
return resp, nil
}
+// translateForums maps a list of forum slugs to the union of their
+// proceeding_type_codes via ForumToProceedingCodes. Unknown slugs are
+// silently dropped.
+func translateForums(slugs []string) []string {
+ if len(slugs) == 0 {
+ return nil
+ }
+ seen := map[string]bool{}
+ var out []string
+ for _, slug := range slugs {
+ codes, ok := ForumToProceedingCodes[slug]
+ if !ok {
+ continue
+ }
+ for _, c := range codes {
+ if seen[c] {
+ continue
+ }
+ seen[c] = true
+ out = append(out, c)
+ }
+ }
+ return out
+}
+
+// browseRanks synthesizes a rank list from a concept-id allow-list
+// (v3 B1 browse mode). No trigram scoring — order is by concept
+// sort_order then name. Forum filter applies post-hoc to keep concepts
+// that have at least one matching pill.
+func (s *DeadlineSearchService) browseRanks(
+ ctx context.Context,
+ conceptIDs []string,
+ party, proc, source *string,
+ forumCodes []string,
+ limit int,
+) []rankRow {
+ const sqlText = `
+SELECT DISTINCT
+ s.concept_id,
+ false AS alias_hit,
+ 1.0 AS score,
+ s.concept_sort_order,
+ s.concept_name_de,
+ ARRAY[]::text[] AS matched_aliases
+ FROM paliad.deadline_search s
+ WHERE s.concept_id = ANY($1::uuid[])
+ AND ($2::text IS NULL OR s.effective_party = $2)
+ AND ($3::text IS NULL OR s.proceeding_code = $3)
+ AND ($4::text IS NULL OR s.legal_source LIKE $4 || '%')
+ AND (
+ $5::text[] IS NULL
+ OR cardinality($5::text[]) = 0
+ OR s.kind = 'trigger'
+ OR s.proceeding_code = ANY($5::text[])
+ )
+ ORDER BY s.concept_sort_order ASC, s.concept_name_de ASC
+ LIMIT $6
+`
+ var rows []rankRow
+ if err := s.db.SelectContext(ctx, &rows, sqlText,
+ pq.Array(conceptIDs),
+ party, proc, source,
+ nullableArray(forumCodes),
+ limit,
+ ); err != nil {
+ // Browse mode failures degrade to empty (taxonomy-driven UX
+ // shouldn't crash on a malformed slug); log via the caller.
+ return nil
+ }
+ return rows
+}
+
+// nullableArray returns nil for empty input so the SQL `IS NULL OR
+// cardinality = 0` short-circuit applies cleanly. pq.Array on a nil
+// slice still produces a non-NULL empty array, which doesn't match
+// the IS NULL test — hence the explicit nil sentinel.
+func nullableArray(s []string) any {
+ if len(s) == 0 {
+ return nil
+ }
+ return pq.Array(s)
+}
+
func (s *DeadlineSearchService) rankConcepts(
ctx context.Context,
q, qLow string,
party, proc, source *string,
+ allowConceptIDs []string,
+ forumCodes []string,
limit int,
) ([]rankRow, error) {
+ // $1 q · $2 qLow · $3 party · $4 proc · $5 source ·
+ // $6 concept_allow uuid[]? · $7 forum_codes text[]? · $8 limit
const sqlText = `
WITH matched AS (
SELECT
@@ -253,6 +410,13 @@ WITH matched AS (
AND ($3::text IS NULL OR s.effective_party = $3)
AND ($4::text IS NULL OR s.proceeding_code = $4)
AND ($5::text IS NULL OR s.legal_source LIKE $5 || '%')
+ AND ($6::uuid[] IS NULL OR s.concept_id = ANY($6::uuid[]))
+ AND (
+ $7::text[] IS NULL
+ OR cardinality($7::text[]) = 0
+ OR s.kind = 'trigger'
+ OR s.proceeding_code = ANY($7::text[])
+ )
)
SELECT
m.concept_id,
@@ -261,16 +425,20 @@ SELECT
THEN 0.2 ELSE 0 END AS score,
min(m.concept_sort_order) AS concept_sort_order,
min(m.concept_name_de) AS concept_name_de,
- -- All rows in a concept share the same aliases; min() over identical
- -- text[] values is well-defined and returns one of them verbatim.
COALESCE(min(m.row_matched_aliases), ARRAY[]::text[]) AS matched_aliases
FROM matched m
GROUP BY m.concept_id
ORDER BY score DESC, concept_sort_order ASC, concept_name_de ASC
- LIMIT $6
+ LIMIT $8
`
var rows []rankRow
- if err := s.db.SelectContext(ctx, &rows, sqlText, q, qLow, party, proc, source, limit); err != nil {
+ if err := s.db.SelectContext(ctx, &rows, sqlText,
+ q, qLow,
+ party, proc, source,
+ nullableArray(allowConceptIDs),
+ nullableArray(forumCodes),
+ limit,
+ ); err != nil {
return nil, fmt.Errorf("rank concepts: %w", err)
}
return rows, nil
@@ -280,6 +448,7 @@ func (s *DeadlineSearchService) loadPills(
ctx context.Context,
conceptIDs []string,
party, proc, source *string,
+ forumCodes []string,
) ([]pillRow, error) {
const sqlText = `
SELECT
@@ -311,10 +480,18 @@ SELECT
AND ($2::text IS NULL OR s.effective_party = $2)
AND ($3::text IS NULL OR s.proceeding_code = $3)
AND ($4::text IS NULL OR s.legal_source LIKE $4 || '%')
+ AND (
+ $5::text[] IS NULL
+ OR cardinality($5::text[]) = 0
+ OR s.kind = 'trigger'
+ OR s.proceeding_code = ANY($5::text[])
+ )
ORDER BY s.concept_id, s.kind, s.proceeding_code NULLS LAST, s.rule_local_code
`
var rows []pillRow
- if err := s.db.SelectContext(ctx, &rows, sqlText, pq.Array(conceptIDs), party, proc, source); err != nil {
+ if err := s.db.SelectContext(ctx, &rows, sqlText,
+ pq.Array(conceptIDs), party, proc, source, nullableArray(forumCodes),
+ ); err != nil {
return nil, fmt.Errorf("load pills: %w", err)
}
return rows, nil
diff --git a/internal/services/event_category_service.go b/internal/services/event_category_service.go
new file mode 100644
index 0000000..1fdb348
--- /dev/null
+++ b/internal/services/event_category_service.go
@@ -0,0 +1,253 @@
+package services
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+
+ "github.com/jmoiron/sqlx"
+ "github.com/lib/pq"
+)
+
+// EventCategoryService backs the Fristenrechner v3 decision tree
+// (Pathway B / B1, t-paliad-133). The taxonomy is a recursive tree of
+// "what happened" event categories; leaves map to deadline_concepts via
+// the paliad.event_category_concepts junction.
+//
+// Two main operations:
+// 1. Tree: hand the entire taxonomy to the frontend as a nested JSON
+// structure for the cascade UI (small dataset, ETag-cached).
+// 2. ConceptsForSlug: given a slug like "cms-eingang.gericht.hinweisbeschluss",
+// return the (concept_id, proceeding_type_code) tuples reachable from
+// that node OR any of its descendants. Drives B1's narrowing of the
+// shared concept-card list.
+type EventCategoryService struct {
+ db *sqlx.DB
+}
+
+// NewEventCategoryService wires the service to its DB pool.
+func NewEventCategoryService(db *sqlx.DB) *EventCategoryService {
+ return &EventCategoryService{db: db}
+}
+
+// EventCategoryNode is one row in the taxonomy with its children attached.
+// JSON shape is what the frontend consumes from
+// GET /api/tools/fristenrechner/event-categories.
+type EventCategoryNode struct {
+ ID string `json:"id"`
+ Slug string `json:"slug"`
+ LabelDE string `json:"label_de"`
+ LabelEN string `json:"label_en"`
+ DescriptionDE *string `json:"description_de,omitempty"`
+ DescriptionEN *string `json:"description_en,omitempty"`
+ StepQuestionDE *string `json:"step_question_de,omitempty"`
+ StepQuestionEN *string `json:"step_question_en,omitempty"`
+ Icon *string `json:"icon,omitempty"`
+ SortOrder int `json:"sort_order"`
+ IsLeaf bool `json:"is_leaf"`
+ Children []EventCategoryNode `json:"children,omitempty"`
+}
+
+// ConceptOutcome maps a leaf to one (concept, optional proceeding) pair.
+// Used to narrow the deadline_search matview by event_category.
+type ConceptOutcome struct {
+ ConceptID string `db:"concept_id" json:"concept_id"`
+ ProceedingTypeCode *string `db:"proceeding_type_code" json:"proceeding_type_code,omitempty"`
+ SortOrder int `db:"sort_order" json:"sort_order"`
+}
+
+// categoryRow is the flat row shape from the DB.
+type categoryRow struct {
+ ID string `db:"id"`
+ ParentID sql.NullString `db:"parent_id"`
+ Slug string `db:"slug"`
+ LabelDE string `db:"label_de"`
+ LabelEN string `db:"label_en"`
+ DescriptionDE sql.NullString `db:"description_de"`
+ DescriptionEN sql.NullString `db:"description_en"`
+ StepQuestionDE sql.NullString `db:"step_question_de"`
+ StepQuestionEN sql.NullString `db:"step_question_en"`
+ Icon sql.NullString `db:"icon"`
+ SortOrder int `db:"sort_order"`
+ IsLeaf bool `db:"is_leaf"`
+}
+
+// Tree returns the full taxonomy as a list of root nodes with children
+// nested in. Inactive rows are excluded.
+//
+// Result is small (≤ ~100 nodes today) and stable across requests, so the
+// handler ETag-caches it.
+func (s *EventCategoryService) Tree(ctx context.Context) ([]EventCategoryNode, error) {
+ const sqlText = `
+SELECT id, parent_id, slug, label_de, label_en,
+ description_de, description_en,
+ step_question_de, step_question_en,
+ icon, sort_order, is_leaf
+ FROM paliad.event_categories
+ WHERE is_active = true
+ ORDER BY sort_order ASC, slug ASC
+`
+ var rows []categoryRow
+ if err := s.db.SelectContext(ctx, &rows, sqlText); err != nil {
+ return nil, fmt.Errorf("event_categories list: %w", err)
+ }
+
+ // Build node map and stitch children to parents in one pass.
+ nodes := make(map[string]*EventCategoryNode, len(rows))
+ for _, r := range rows {
+ n := EventCategoryNode{
+ ID: r.ID,
+ Slug: r.Slug,
+ LabelDE: r.LabelDE,
+ LabelEN: r.LabelEN,
+ SortOrder: r.SortOrder,
+ IsLeaf: r.IsLeaf,
+ }
+ if r.DescriptionDE.Valid {
+ n.DescriptionDE = &r.DescriptionDE.String
+ }
+ if r.DescriptionEN.Valid {
+ n.DescriptionEN = &r.DescriptionEN.String
+ }
+ if r.StepQuestionDE.Valid {
+ n.StepQuestionDE = &r.StepQuestionDE.String
+ }
+ if r.StepQuestionEN.Valid {
+ n.StepQuestionEN = &r.StepQuestionEN.String
+ }
+ if r.Icon.Valid {
+ n.Icon = &r.Icon.String
+ }
+ nodes[r.ID] = &n
+ }
+
+ var roots []EventCategoryNode
+ for _, r := range rows {
+ node := nodes[r.ID]
+ if !r.ParentID.Valid {
+ roots = append(roots, *node)
+ continue
+ }
+ parent, ok := nodes[r.ParentID.String]
+ if !ok {
+ // Orphan (parent inactive or deleted) — surface as root so the
+ // taxonomy doesn't disappear. Defensive; shouldn't happen in
+ // practice given is_active=true filter applies to both sides.
+ roots = append(roots, *node)
+ continue
+ }
+ parent.Children = append(parent.Children, *node)
+ }
+
+ // Re-collect children from the map into the root copies — the
+ // `roots = append(roots, *node)` above stored a snapshot, so we need
+ // to walk back through and replace each with the live pointer's data.
+ final := make([]EventCategoryNode, 0, len(roots))
+ for _, root := range roots {
+ live := nodes[root.ID]
+ final = append(final, *live)
+ }
+ return final, nil
+}
+
+// ConceptsForSlug returns the (concept, optional proceeding_code) tuples
+// reachable from the named slug OR any of its descendants. Empty slug
+// returns nothing (caller must validate). Unknown slug also returns
+// empty without error so the caller can render an empty result UI.
+func (s *EventCategoryService) ConceptsForSlug(ctx context.Context, slug string) ([]ConceptOutcome, error) {
+ if slug == "" {
+ return nil, nil
+ }
+
+ // Single recursive CTE walks the descendants of `slug`, then joins
+ // the junction. text-cast on concept_id keeps the slice serialisable
+ // to a stable JSON shape.
+ const sqlText = `
+WITH RECURSIVE descendants AS (
+ SELECT id FROM paliad.event_categories
+ WHERE slug = $1 AND is_active = true
+ UNION ALL
+ SELECT c.id
+ FROM paliad.event_categories c
+ JOIN descendants d ON c.parent_id = d.id
+ WHERE c.is_active = true
+)
+SELECT DISTINCT
+ ecc.concept_id::text AS concept_id,
+ ecc.proceeding_type_code AS proceeding_type_code,
+ min(ecc.sort_order) AS sort_order
+ FROM paliad.event_category_concepts ecc
+ JOIN descendants d ON d.id = ecc.event_category_id
+ GROUP BY ecc.concept_id, ecc.proceeding_type_code
+ ORDER BY sort_order ASC, concept_id ASC
+`
+ var rows []ConceptOutcome
+ if err := s.db.SelectContext(ctx, &rows, sqlText, slug); err != nil {
+ return nil, fmt.Errorf("event_category concepts for %q: %w", slug, err)
+ }
+ return rows, nil
+}
+
+// ConceptIDsForSlug is the convenience reduction of ConceptsForSlug to a
+// flat slice of concept_ids for use in `WHERE concept_id = ANY($1::uuid[])`
+// queries against paliad.deadline_search.
+func (s *EventCategoryService) ConceptIDsForSlug(ctx context.Context, slug string) ([]string, error) {
+ rows, err := s.ConceptsForSlug(ctx, slug)
+ if err != nil {
+ return nil, err
+ }
+ if len(rows) == 0 {
+ return nil, nil
+ }
+ seen := make(map[string]bool, len(rows))
+ out := make([]string, 0, len(rows))
+ for _, r := range rows {
+ if seen[r.ConceptID] {
+ continue
+ }
+ seen[r.ConceptID] = true
+ out = append(out, r.ConceptID)
+ }
+ return out, nil
+}
+
+// ProceedingCodesForSlug returns the distinct proceeding_type_code
+// values associated with the slug's reachable concept set. Used by the
+// search service to AND the user's forum filter against the leaf's
+// per-concept narrowing.
+//
+// NULL proceeding_type_code (concept applies to all contexts) is
+// reported as the empty string in the result; callers treat empty as
+// "no narrowing at this leaf".
+func (s *EventCategoryService) ProceedingCodesForSlug(ctx context.Context, slug string) ([]string, error) {
+ if slug == "" {
+ return nil, nil
+ }
+ const sqlText = `
+WITH RECURSIVE descendants AS (
+ SELECT id FROM paliad.event_categories WHERE slug = $1 AND is_active = true
+ UNION ALL
+ SELECT c.id FROM paliad.event_categories c
+ JOIN descendants d ON c.parent_id = d.id
+ WHERE c.is_active = true
+)
+SELECT DISTINCT COALESCE(ecc.proceeding_type_code, '') AS code
+ FROM paliad.event_category_concepts ecc
+ JOIN descendants d ON d.id = ecc.event_category_id
+ ORDER BY code
+`
+ var codes pq.StringArray
+ rows, err := s.db.QueryContext(ctx, sqlText, slug)
+ if err != nil {
+ return nil, fmt.Errorf("proceeding codes for %q: %w", slug, err)
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var c string
+ if err := rows.Scan(&c); err != nil {
+ return nil, fmt.Errorf("scan proceeding code: %w", err)
+ }
+ codes = append(codes, c)
+ }
+ return []string(codes), rows.Err()
+}