diff --git a/docs/design-litigation-planner-2026-05-26.md b/docs/design-litigation-planner-2026-05-26.md index dc43a13..a7d6ec8 100644 --- a/docs/design-litigation-planner-2026-05-26.md +++ b/docs/design-litigation-planner-2026-05-26.md @@ -1141,4 +1141,312 @@ Slice F is a youpc-side task; it needs a worker with youpc-go familiarity (a sep --- +## §18 Slice B — Catalog Interface + Unifications (2026-05-26) + +Slice A landed atomically at `d1d0cf9`. Before Slice B's coder shift begins, three additional decisions m confirmed today need to be folded into the package design: + +- §18.1 — **Berufung unification**. Collapse the 3 active UPC appeal proceeding_types (`upc.apl.merits`, `upc.apl.cost`, `upc.apl.order`) into ONE `upc.apl` proceeding type + an `appeal_target` discriminator. +- §18.2 — **Multi-axis catalog query API**. New `Catalog.LookupEvents` method taking any subset of `{jurisdiction, proceeding_type_id, party, event_category_id}` axes + a depth control (`next` / `all-following`). +- §18.3 — **`primary_party` enum tightening**. Convert the free-text `paliad.deadline_rules.primary_party` column to a CHECK constraint matching the four-value vocabulary `claimant / defendant / court / both`. + +Each subsection follows the same shape: motivation → schema impact → API shape → acceptance criteria. + +### §18.0 Live state on main (audit summary) + +Confirmed via Supabase before drafting (`mai/cronus/inventor-litigation-slice-b` branch off `main`): + +- **9 active UPC proceeding_types**: `upc.inf.cfi` (25 rules), `upc.rev.cfi` (17), `upc.pi.cfi` (7), `upc.dmgs.cfi` (8), `upc.disc.cfi` (4), `upc.ccr.cfi` (0 — sub-track), `upc.apl.merits` (7), `upc.apl.cost` (2), `upc.apl.order` (7). +- **3 appeal-flavoured proceeding_types** = 16 rules across 3 codes. Schadensbemessung + Bucheinsicht are SEPARATE first-instance proceedings today (`upc.dmgs.cfi`, `upc.disc.cfi`), NOT appeal sub-tracks. +- **`paliad.deadline_rules.primary_party`** value distribution: `claimant=26`, `defendant=26`, `court=38`, `both=63`, `NULL=78`. The 78 NULL rows are ALL `proceeding_type_id IS NULL` orphans (cross-cutting concept seeds: Wiedereinsetzung, Versäumnisurteil-Einspruch, Schriftsatznachreichung, Weiterbehandlung — 8 distinct concepts × N rules). Every proceeding-bound rule already has a four-value `primary_party`. +- **`paliad.event_categories.party`** column shape: `text[]` (array). Live distinct values: `{claimant}`, `{defendant}`, NULL. No `court` or `both` in event_categories.party today. The semantic is "from whose perspective is this event triggered?" — narrower than `primary_party` which is "who files this submission". + +### §18.1 Berufung unification + +#### Motivation + +m's framing (2026-05-26 09:55, t-paliad-298 instructions): *"the Verfahrensablauf event picker has 4-5 separate proceeding_types … plus Berufung Schadensbemessung and Berufung Bucheinsicht variants. m doesn't like the pre-separation. He wants ONE 'Berufung' entry in the picker, and the user then picks what the appeal is directed AT … the system derives the correct frist sequence from that target."* + +Today's 3 codes (`upc.apl.merits`, `upc.apl.cost`, `upc.apl.order`) are a leaky abstraction of "appeal" — the user has to know whether it's a merits/cost/order appeal BEFORE they enter the picker, even though that branching question is "what's being appealed?" not "what kind of appeal?". Schadensbemessung + Bucheinsicht aren't in the appeal taxonomy at all today; appeals against those decisions silently fall into `upc.apl.merits`, blurring the rule sequence (RoP.220 vs RoP.221 vs RoP.224 timing). + +The five appeal-target kinds are: + +| Target | Source decision | Typical RoP track | Current proceeding code | +|---|---|---|---| +| Endentscheidung | Final merits decision (UPC.RoP.118.1 / 219) | 2-month notice + 4-month grounds (R.224.1.a / R.224.2.a) | `upc.apl.merits` | +| Kostenentscheidung | Cost decision (R.150 / R.221.1) | 15-day leave-to-appeal (R.221.1) | `upc.apl.cost` | +| Anordnung | Order during proceedings (R.220) | 15-day track (R.220.2 / R.220.3 / R.224.2.b) | `upc.apl.order` | +| Schadensbemessung | Damages-determination decision (R.118.4 + R.140.2.b damages award) | Same merits track (2/4 month), but conceptually distinct anchor | (today maps to `upc.apl.merits`, silently) | +| Bucheinsicht | Lay-open-books decision (R.142) | 15-day track (R.220.2 — order-flavoured) OR merits track depending on the underlying decision shape | (today maps to `upc.apl.merits`, silently) | + +#### Schema impact + +**Migration plan (single `134_berufung_unification.up.sql`)**: + +1. **Add column** `paliad.proceeding_types.appeal_target text NULL` — discriminator on the unified `upc.apl` row. +2. **Add CHECK** on `appeal_target`: NULL OR one of `endentscheidung | kostenentscheidung | anordnung | schadensbemessung | bucheinsicht`. Slugged in English-lowercase to match the package's English-identifier rule; the user-facing label is i18n'd in frontend. +3. **Insert** a new unified row `upc.apl` (name="Berufungsverfahren", name_en="Appeal", jurisdiction="UPC", category="fristenrechner", `appeal_target=NULL`). +4. **Re-target rule rows** by `appeal_target`: + - Today's 7 `upc.apl.merits` rules → keep `proceeding_type_id` pointing at the new `upc.apl` row, set a NEW column on `paliad.deadline_rules` called `applies_to_target text NULL` (CHECK matching the five-value vocab) to `'endentscheidung'`. + - Today's 2 `upc.apl.cost` rules → `applies_to_target='kostenentscheidung'`. + - Today's 7 `upc.apl.order` rules → `applies_to_target='anordnung'`. + - The 7 merits rules ALSO carry the implicit "applies to Schadensbemessung" semantic (the merits track is shared) — explicit duplication or a multi-value applies_to_target array? See §18.1 "Open question" below. +5. **Archive** the 3 old proceeding_types — set `category='archived'`, `is_active=false`. Keep the rows for FK integrity (project_event_choices, etc. may reference them historically; the archive flag stops them surfacing in the picker). +6. **Add 5 stable proceeding-type alias rows** OR **just emit one chip per appeal_target in the frontend**. Recommended (see API shape below): emit chips from the package's catalog, no DB row per target. + +**Two new columns added by this migration:** +- `paliad.proceeding_types.appeal_target text NULL` (CHECK on 5 slugs OR NULL — NULL means "not an appeal"). +- `paliad.deadline_rules.applies_to_target text[] NULL` (CHECK each element ∈ the 5 slugs — array because the merits track applies to BOTH endentscheidung AND schadensbemessung today). + +**Migration audit pass first**: before running step 4 the migration should `RAISE NOTICE` for any rule row whose `applies_to_target` derivation is ambiguous (e.g. an old `upc.apl.merits` rule that has a `condition_flag` that doesn't fit any target). In practice the 16 rules all map cleanly, but the audit pattern matches Phase 2 Step E discipline (see `docs/design-fristen-phase2-2026-05-15.md` §3.E). + +**Down-migration**: re-insert the 3 archived proceeding_types, restore `proceeding_type_id` on rules from the saved `applies_to_target`, drop the two new columns. Standard down-symmetry per `docs/design-fristen-phase2-2026-05-15.md`. + +#### API shape + +The package's existing `Catalog.LoadProceeding(ctx, code, hint)` already returns a `ProceedingType` + `[]Rule`. The Berufung unification fits cleanly: + +- `LoadProceeding(ctx, "upc.apl", hint)` returns the unified Berufung proceeding + ALL appeal rules across the 5 targets. +- A new optional field on the request narrows by target: extend `CalcOptions` with `AppealTarget string`. When non-empty, the engine filters the returned rule list to rules whose `applies_to_target` contains the requested target. +- The package exposes the 5 target slugs as constants: + +```go +const ( + AppealTargetEndentscheidung = "endentscheidung" + AppealTargetKostenentscheidung = "kostenentscheidung" + AppealTargetAnordnung = "anordnung" + AppealTargetSchadensbemessung = "schadensbemessung" + AppealTargetBucheinsicht = "bucheinsicht" +) + +// AppealTargets is the canonical ordered list for UI chip rendering. +var AppealTargets = []string{ + AppealTargetEndentscheidung, + AppealTargetKostenentscheidung, + AppealTargetAnordnung, + AppealTargetSchadensbemessung, + AppealTargetBucheinsicht, +} +``` + +- `ProceedingType` gains a field: `AppealTarget *string ` db:"appeal_target" json:"appealTarget,omitempty"`` (per-row tag for clarity; redundant with the unified row's `code='upc.apl'` but useful for non-appeal proceedings that may carry NULL). +- `Rule` gains a field: `AppliesToTarget []string ` db:"applies_to_target" json:"appliesToTarget,omitempty"`` (per-row applies-to set). + +Frontend logic: +- Verfahrensablauf picker shows one "Berufung" entry (the `upc.apl` proceeding). +- After picking Berufung, a chip group renders the 5 `AppealTargets` slugs (i18n labels in `frontend/src/client/i18n.ts`). +- Selecting a target sets `?target=` query param → backend includes `opts.AppealTarget=` in the request → engine filters. + +#### Acceptance criteria (Slice B sub-tasks for this fold-in) + +1. Migration `134_berufung_unification.up.sql` + paired `.down.sql` apply cleanly against a fresh paliad DB. +2. After migration, `SELECT code FROM paliad.proceeding_types WHERE jurisdiction='UPC' AND is_active=true AND category='fristenrechner'` returns one less row (the 3 old appeal codes collapsed to 1 new code). +3. `Catalog.LoadProceeding(ctx, "upc.apl", hint)` returns the merged 16-rule set; with `opts.AppealTarget="endentscheidung"` it returns exactly 7 rules. +4. Verfahrensablauf renders one "Berufung" picker entry. The 5 target chips render below it post-pick; switching chips re-renders the timeline. +5. Existing project rows that referenced the old `upc.apl.merits` / `upc.apl.cost` / `upc.apl.order` codes still load (the FK integrity is preserved via the archived old rows). +6. The `paliad.proceeding_type_history` follow-up (not in scope here) can later migrate those project FKs to the new `upc.apl` + `appeal_target` field — that's a follow-up. + +#### m's answer on Q18.1.1 (2026-05-26 13:40) + +> Schadensbemessung-as-appeal is a NEW appeal target. Model it as a first-class entry in the appeal_target enum with its own rule set (no shared inheritance from upc.apl.merits). The rules don't exist yet in the catalog; for now the appeal_target value is defined and `CalcOptions.AppealTarget="schadensbemessung"` returns an empty sequence until rules are seeded. + +→ **Option B wins (m overrode inventor's R=A).** Migration 134 still ships the schema + the 5 enum values + the chip group + the engine filter. Existing rules: +- 7 `upc.apl.merits` rules → `applies_to_target=['endentscheidung']` (Endentscheidung only — NOT also Schadensbemessung). +- 2 `upc.apl.cost` rules → `applies_to_target=['kostenentscheidung']`. +- 7 `upc.apl.order` rules → `applies_to_target=['anordnung']`. +- **Schadensbemessung + Bucheinsicht get NO rules in this migration.** `applies_to_target='schadensbemessung'` and `'bucheinsicht'` are valid enum values but no rule row carries them yet. + +**Frontend behaviour with empty rule sets:** +- All 5 target chips still render (the picker promises the user a complete vocabulary). +- Picking Schadensbemessung or Bucheinsicht returns an empty timeline with a banner: "Frist-Sequenz für diesen Berufungstyp ist noch nicht hinterlegt — bitte über /admin/rules einpflegen oder Migrations-Follow-up abwarten." +- Picking the 3 populated targets renders normally. + +**Rule-seeding follow-up (TODO, separate slice):** +- Schadensbemessung-appeal rules: anchor on R.118.4 (Folgeentscheidung Schadensbemessung) decision; conjecture 2/4-month track but distinct legal basis. +- Bucheinsicht-appeal rules: anchor on R.142 (Lay-open-books decision); conjecture 15-day track per R.220.2 + R.224.2.b. +- Can pair with `t-paliad-193` orphan-concept-seed if m wants a combined seeding pass. +- Either path: editorial via `/admin/rules` (rule-editor service, Slice 11a) so the lawyer team can author + audit. + +**Q18.1.2 — User-facing label for "appeal_target": "Worauf richtet sich die Berufung?" (DE) / "Appeal against:" (EN)?** + +Recommendation: yes, those exact strings; defer i18n decisions to the coder shift. + +### §18.2 Multi-axis catalog query API + +#### Motivation + +The current `Catalog` interface (added in Slice A) supports proceeding-code lookups only. The new scenarios surface (Slice D) + the Determinator cascade (t-paliad-166) + a future "show me all next-step events when I'm in state X" need a generalised query that takes any subset of axes and returns matching events. + +m's brief (2026-05-26 13:33): *"any subset of these axes (all optional): jurisdiction, proceeding_type_id, party, event_category_id. Returns matching events with the priority flag and a sequence-depth control: caller picks 'next' (1 hop downstream) or 'all-following' (full chain)."* + +Today the cascade reconstructs this client-side via fanned-out calls to `/api/tools/fristenrechner` etc. — fragile + duplicated logic. The new method centralises the graph walk in the package. + +#### Schema impact + +**None new.** The query reads existing tables: + +- `paliad.proceeding_types` (jurisdiction, id, code) +- `paliad.deadline_rules` (parent_id, sequence_order, primary_party, priority, event_category_id via concept_id → event_category_concepts) +- `paliad.event_categories` (id, party, parent_id for the cascade hierarchy) +- `paliad.event_category_concepts` (junction; concept_id → event_category_id) + +The depth control is a runtime graph walk — `next` returns one hop from the matched parent, `all-following` walks `parent_id` recursively until leaves. + +The audit found one schema gap worth flagging but NOT changing in Slice B: +- `paliad.deadline_rules` has no direct `event_category_id` column — it goes through `concept_id → deadline_concepts → event_category_concepts → event_categories`. The join is well-trodden but introduces an extra hop. v1 of the catalog API uses the join; a future denormalisation (`paliad.deadline_rules.event_category_id` cached column) is out of scope. + +#### API shape + +```go +// EventLookupAxes carries the optional filter axes for LookupEvents. All +// fields are optional; the empty value is "no filter on this axis". When +// multiple axes are set the engine applies them as AND (a rule must +// match ALL non-zero axes). +type EventLookupAxes struct { + Jurisdiction string // "UPC" | "DE" | "EPA" | "DPMA" — empty = any + ProceedingTypeID *int // narrow to one proceeding — nil = any + Party string // "claimant" | "defendant" | "court" | "both" — empty = any + EventCategoryID *uuid.UUID // narrow to one event_categories row — nil = any + AppealTarget string // §18.1 fold-in — empty = any +} + +// EventLookupDepth controls the sequence-depth of the returned events. +type EventLookupDepth string + +const ( + // EventLookupDepthNext returns immediate children of the matched + // anchor (1 hop downstream). Default for "what comes next from + // this point?" queries. + EventLookupDepthNext EventLookupDepth = "next" + // EventLookupDepthAllFollowing returns the entire downstream + // chain (parent_id walk to leaves). Default for "show me the + // whole sequence from here onward" queries. + EventLookupDepthAllFollowing EventLookupDepth = "all-following" +) + +// EventMatch is one result row from LookupEvents. +type EventMatch struct { + Rule Rule `json:"rule"` // full rule row + ProceedingType ProceedingType `json:"proceedingType"` // owning proceeding + Priority string `json:"priority"` // mandatory|recommended|optional|informational + DepthFromAnchor int `json:"depthFromAnchor"` // 1 = next, 2+ = deeper + // ParentRuleID populated when the match has a parent_id in the + // returned set (so the frontend can render a tree). + ParentRuleID *uuid.UUID `json:"parentRuleId,omitempty"` +} + +// LookupEvents on the Catalog interface returns events matching any +// subset of axes, at the requested sequence depth. Returns an empty +// slice (NOT an error) when no events match. +// +// Implementation must respect the catalog's "published + active" rule +// gate that LoadProceeding already enforces. +type Catalog interface { + // ... existing methods (LoadProceeding, LoadProceedingByID, ...) + + LookupEvents(ctx context.Context, axes EventLookupAxes, depth EventLookupDepth) ([]EventMatch, error) +} +``` + +paliad's `paliadCatalog` impl builds one SQL query with optional WHERE clauses + the existing `deadline_concept_event_types` JOIN for the event_category_id axis. youpc.org's embedded snapshot impl runs the same axis-filter pass on the in-memory rule slice. + +#### Acceptance criteria (Slice B sub-tasks) + +1. `Catalog.LookupEvents` exists on the interface + has both a paliad-side impl (SQL) and a stub for the future embedded/upc snapshot impl. +2. Round-trip test: `LookupEvents(ctx, EventLookupAxes{Jurisdiction:"UPC"}, EventLookupDepthAllFollowing)` returns all 77 UPC rules (matches the count from §0). +3. Combined-axis test: `EventLookupAxes{Jurisdiction:"UPC", Party:"claimant"}` returns the claimant-perspective subset. +4. Depth test: with a specific `ProceedingTypeID` + `Party:"defendant"`, `EventLookupDepthNext` returns only 1-hop children of the proceeding's root; `EventLookupDepthAllFollowing` returns the full chain. +5. New axis-driven endpoint at `GET /api/tools/lookup-events?…` proxies the call (separate slice — out of scope for the package-side acceptance, but listed for the coder). + +### §18.3 `primary_party` enum tightening + +#### Motivation + +Today `paliad.deadline_rules.primary_party` is free-text. The live values confirm a stable four-value vocabulary (`claimant=26, defendant=26, court=38, both=63, NULL=78`) but nothing prevents a future rule editor from typing `clamant` or `Court` and silently breaking the appellant-context propagation in `engine.go`. + +m's brief: *"Tighten to a check constraint matching event_categories.party's allowed values: claimant/defendant/court/both. Migration must audit + clean existing rows first; surface dirty rows to m if any don't fit the four-value vocabulary."* + +Note: `event_categories.party` is a `text[]` (array) with current live values `{claimant}`, `{defendant}`, NULL. It does NOT carry `court` or `both` today. The brief's "matching event_categories.party's allowed values" is taken to mean the SEMANTIC vocabulary (claimant/defendant/court/both), not the literal current rows of event_categories.party. The package owns the canonical list. + +#### Schema impact + +**Migration `135_primary_party_check.up.sql`**: + +1. **Audit pass** (DO $$ block): COUNT rules where `proceeding_type_id IS NOT NULL AND primary_party NOT IN ('claimant', 'defendant', 'court', 'both', NULL)`. RAISE NOTICE for each non-matching row's `(id, name, primary_party)`. If COUNT > 0, RAISE EXCEPTION 'dirty rows — see notice; manual cleanup required'. +2. **Add CHECK constraint**: `ALTER TABLE paliad.deadline_rules ADD CONSTRAINT deadline_rules_primary_party_chk CHECK (primary_party IS NULL OR primary_party IN ('claimant', 'defendant', 'court', 'both'))`. +3. **No data change** — every proceeding-bound rule already has a valid four-value value; the 78 NULL rows are orphan concept seeds and stay NULL. +4. **Down-migration**: `ALTER TABLE paliad.deadline_rules DROP CONSTRAINT deadline_rules_primary_party_chk`. No data revert needed. + +**Why NULL stays valid:** +- The 78 NULL rows are cross-cutting concept seeds (Wiedereinsetzung, Versäumnisurteil-Einspruch, Schriftsatznachreichung, Weiterbehandlung) that have NO proceeding binding. They're not in the calculator's path; loosening the CHECK to `IS NULL OR IN (...)` keeps them valid without further schema gymnastics. +- A stricter "NOT NULL when proceeding_type_id is NOT NULL" partial constraint would be cleaner but adds a multi-column rule that's harder to maintain. The simpler form suffices given today's invariant. + +**Should the same vocabulary be propagated to `paliad.event_categories.party`?** + +Recommendation: **NO, not in this migration**. event_categories.party is array-shaped (a category can apply to multiple perspectives) and today carries only `{claimant}` / `{defendant}` per its narrower semantic ("from whose perspective is this category triggered?"). Tightening it to require court/both would force backfill of rows where neither perspective is the trigger. Out of scope for Slice B; flag as a follow-up. + +#### API shape + +The package's `Rule.PrimaryParty` field (already `*string`) stays as-is — the type doesn't change. A new package-level set of constants: + +```go +const ( + PrimaryPartyClaimant = "claimant" + PrimaryPartyDefendant = "defendant" + PrimaryPartyCourt = "court" + PrimaryPartyBoth = "both" +) + +// PrimaryParties is the canonical ordered list for validation + +// admin-UI rendering. +var PrimaryParties = []string{ + PrimaryPartyClaimant, + PrimaryPartyDefendant, + PrimaryPartyCourt, + PrimaryPartyBoth, +} + +// IsValidPrimaryParty returns true for empty (NULL-equivalent) or any +// of the four canonical values. Used by the rule-editor (Slice E2 if +// ever revisited) to validate writes before they hit the CHECK. +func IsValidPrimaryParty(s string) bool { … } +``` + +The rule-editor service (`internal/services/rule_editor_service.go`) gains a validation call against `lp.IsValidPrimaryParty` before any UPDATE — surfaces a user-friendly 400 before the DB CHECK fires with the less-pretty error. + +#### Acceptance criteria (Slice B sub-tasks) + +1. Migration `135_primary_party_check.up.sql` + paired `.down.sql` apply cleanly against a fresh paliad DB. +2. Pre-migration audit pass surfaces zero dirty rows on the current live corpus (verified via Supabase audit before migration drafted). +3. Post-migration, attempting to UPDATE a rule's primary_party to `'foo'` raises a DB CHECK violation. +4. The package exposes the four constants + the `PrimaryParties` slice + the `IsValidPrimaryParty` predicate. +5. Rule-editor service surfaces 400 with a clear message when a write violates the constraint (instead of leaking the raw PG error). + +### §18 Summary table + +| § | Topic | Schema delta | API delta | Migration | +|---|---|---|---|---| +| 18.1 | Berufung unification | +2 columns (proceeding_types.appeal_target + deadline_rules.applies_to_target[]); collapse 3→1 active appeal codes | `Catalog` returns merged proceeding + Rule.AppliesToTarget; CalcOptions.AppealTarget filter; AppealTargets[] constants | `134_berufung_unification.up.sql` | +| 18.2 | Multi-axis catalog query | None new (uses existing joins) | `Catalog.LookupEvents(axes, depth)` new method + EventLookupAxes / EventMatch types | None | +| 18.3 | `primary_party` enum | +CHECK constraint on deadline_rules.primary_party | `PrimaryParties[]` constants + `IsValidPrimaryParty()` predicate | `135_primary_party_check.up.sql` | + +### §18.4 Slice plan refinement (revises §10 for Slice B) + +The original §10 listed Slice B as "Catalog / HolidayCalendar / CourtRegistry interfaces + paliad's default loaders." Slice A already folded those interfaces in (the engine.Calculate signature accepts them). Slice B's revised scope: + +1. **Slice B1 — Berufung unification** (§18.1): migration 134 + package constants + `appeal_target` field on ProceedingType + `applies_to_target[]` on Rule + `CalcOptions.AppealTarget` filter. Frontend updates (verfahrensablauf chip group) follow in the same PR. +2. **Slice B2 — Multi-axis catalog query API** (§18.2): `Catalog.LookupEvents` method + paliad impl + tests. New `GET /api/tools/lookup-events` endpoint optional (slice C may want it earlier). +3. **Slice B3 — primary_party enum tightening** (§18.3): migration 135 + package constants + rule-editor validation hook. + +B1 / B2 / B3 are independently shippable and can land in any order. B1 has the most user-facing impact (the picker change is what m flagged); B3 is the smallest hardening; B2 is the largest API surface. + +### §18.5 Open questions escalated to head + +- §18.1 Q1 — Schadensbemessung-as-appeal: shared vs distinct vs deferred (R: shared, multi-valued `applies_to_target`). +- §18.1 Q2 — i18n label for "Worauf richtet sich die Berufung?" (R: yes, defer to coder). +- §18.3 — Should event_categories.party be tightened in the same migration? (R: no, separate follow-up.) + +No `AskUserQuestion` per inventor protocol; head escalates to m if material. + +--- + *End of design doc.* diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 6775aeb..d303031 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -237,6 +237,13 @@ const translations: Record> = { "deadlines.upc.disc.cfi": "Bucheinsicht", "deadlines.upc.apl.cost": "Berufung Kosten", "deadlines.upc.apl.order": "Berufung Anordnungen", + "deadlines.upc.apl": "Berufung", + "deadlines.appeal_target.label": "Worauf richtet sich die Berufung?", + "deadlines.appeal_target.endentscheidung": "Endentscheidung", + "deadlines.appeal_target.kostenentscheidung": "Kostenentscheidung", + "deadlines.appeal_target.anordnung": "Anordnung", + "deadlines.appeal_target.schadensbemessung": "Schadensbemessung", + "deadlines.appeal_target.bucheinsicht": "Bucheinsicht", "deadlines.de.group.inf": "Verletzungsverfahren", "deadlines.de.group.null": "Nichtigkeitsverfahren", "deadlines.de.inf.lg": "LG (1. Instanz)", @@ -3327,6 +3334,13 @@ const translations: Record> = { "deadlines.upc.dmgs.cfi": "Damages Determination", "deadlines.upc.disc.cfi": "Lay-open Books", "deadlines.upc.apl.cost": "Cost-Decision Appeal", + "deadlines.upc.apl": "Appeal", + "deadlines.appeal_target.label": "Appeal against:", + "deadlines.appeal_target.endentscheidung": "Final Decision", + "deadlines.appeal_target.kostenentscheidung": "Cost Decision", + "deadlines.appeal_target.anordnung": "Order", + "deadlines.appeal_target.schadensbemessung": "Damages Determination", + "deadlines.appeal_target.bucheinsicht": "Lay-open Books", "deadlines.upc.apl.order": "Order Appeal (15-day)", "deadlines.de.group.inf": "Infringement proceedings", "deadlines.de.group.null": "Nullity proceedings", diff --git a/frontend/src/client/verfahrensablauf.ts b/frontend/src/client/verfahrensablauf.ts index 4a2e95d..2054307 100644 --- a/frontend/src/client/verfahrensablauf.ts +++ b/frontend/src/client/verfahrensablauf.ts @@ -64,9 +64,7 @@ let sidePrefilledFromProject = false; // Conservative — false negatives just hide a control; false positives // would show an irrelevant control. const APPELLANT_AXIS_PROCEEDINGS = new Set([ - "upc.apl.merits", - "upc.apl.cost", - "upc.apl.order", + "upc.apl", "de.inf.olg", "de.inf.bgh", "de.null.bgh", @@ -75,6 +73,29 @@ const APPELLANT_AXIS_PROCEEDINGS = new Set([ "epa.opp.boa", ]); +// Slice B1 (m/paliad#124 §18.1) — Berufung unification. +// Proceedings that surface the appeal-target chip group. Currently +// only the unified upc.apl proceeding; future variants (e.g. de.apl) +// can opt in by adding the code here. +const APPEAL_TARGET_PROCEEDINGS = new Set([ + "upc.apl", +]); + +// Five canonical appeal-target slugs (lp.AppealTargets — keep ordered +// in sync with pkg/litigationplanner/types.go AppealTargets). +const APPEAL_TARGETS = [ + "endentscheidung", + "kostenentscheidung", + "anordnung", + "schadensbemessung", + "bucheinsicht", +] as const; +type AppealTarget = (typeof APPEAL_TARGETS)[number] | ""; + +function hasAppealTarget(proceedingType: string): boolean { + return APPEAL_TARGET_PROCEEDINGS.has(proceedingType); +} + function hasAppellantAxis(proceedingType: string): boolean { return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType); } @@ -103,6 +124,32 @@ function writeAppellantToURL(a: Side) { window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash); } +// Slice B1 — appeal-target URL state. Empty string = no target picked +// (the row is hidden because the proceeding isn't an appeal). Any +// other value must be one of APPEAL_TARGETS; unknown values are +// rejected by readAppealTargetFromURL so a stale link can't break +// the engine filter. +function readAppealTargetFromURL(): AppealTarget { + const raw = new URLSearchParams(window.location.search).get("target") || ""; + if ((APPEAL_TARGETS as readonly string[]).includes(raw)) { + return raw as AppealTarget; + } + return ""; +} + +function writeAppealTargetToURL(t: AppealTarget) { + const url = new URL(window.location.href); + if (t === "") url.searchParams.delete("target"); + else url.searchParams.set("target", t); + window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash); +} + +// Default target on first picker entry into upc.apl. m: Endentscheidung +// is the most-common appeal target; the chip group also defaults +// "Endentscheidung" checked in verfahrensablauf.tsx. Keep these two in +// sync so the URL-less default render hits the same code path. +let currentAppealTarget: AppealTarget = ""; + // Per-rule anchor overrides set by the click-to-edit affordance on // timeline / column date cells. Posted as `anchorOverrides` to the // /api/tools/fristenrechner calc so downstream rules re-anchor off the @@ -268,6 +315,13 @@ async function doCalc() { const overrides: Record = {}; for (const [code, date] of anchorOverrides) overrides[code] = date; + // Slice B1 (m/paliad#124 §18.1): for the unified upc.apl Berufung, + // default to "endentscheidung" when no chip pick is stored in URL. + // For non-appeal proceedings the engine ignores opts.AppealTarget. + const appealTarget = hasAppealTarget(selectedType) + ? (currentAppealTarget || "endentscheidung") + : ""; + const data = await calculateDeadlines({ proceedingType: selectedType, triggerDate, @@ -276,6 +330,7 @@ async function doCalc() { courtId, perCardChoices, includeHidden: showHidden, + appealTarget, }); if (seq !== calcSeq) return; if (!data) return; @@ -447,6 +502,7 @@ function selectProceeding(btn: HTMLButtonElement) { void populateCourtPicker("court-picker-row", "court-picker", selectedType); syncFlagRows(); syncAppellantRowVisibility(); + syncAppealTargetRowVisibility(); setProceedingPickerCollapsed(true, proceedingDisplayName(btn)); @@ -471,6 +527,23 @@ function syncAppellantRowVisibility() { } } +// Slice B1 (m/paliad#124 §18.1) — Berufung unification. +// syncAppealTargetRowVisibility shows the appeal-target chip group +// when the unified upc.apl Berufung tile is selected, hides it +// otherwise. Mirrors syncAppellantRowVisibility's pattern: clears +// state + URL when hiding so a stale ?target= can't leak. +function syncAppealTargetRowVisibility() { + const row = document.getElementById("appeal-target-row"); + if (!row) return; + const visible = hasAppealTarget(selectedType); + row.style.display = visible ? "" : "none"; + if (!visible && currentAppealTarget !== "") { + currentAppealTarget = ""; + writeAppealTargetToURL(""); + syncRadioGroup("appeal-target", "endentscheidung"); + } +} + function syncRadioGroup(name: string, value: string) { document.querySelectorAll(`input[type=radio][name=${name}]`).forEach((input) => { input.checked = input.value === value; @@ -655,8 +728,10 @@ function initViewToggle() { function initPerspectiveControls() { currentSide = readSideFromURL(); currentAppellant = readAppellantFromURL(); + currentAppealTarget = readAppealTargetFromURL(); syncRadioGroup("side", currentSide ?? ""); syncRadioGroup("appellant", currentAppellant ?? ""); + syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung"); syncSideHintVisibility(); document.querySelectorAll("input[type=radio][name=side]").forEach((input) => { @@ -679,6 +754,23 @@ function initPerspectiveControls() { if (lastResponse) renderResults(lastResponse); }); }); + + // Slice B1 (m/paliad#124 §18.1) — appeal-target chip handler. + // Each chip change re-fetches with the new target slug so the + // timeline re-renders against the matching rule subset. + document.querySelectorAll("input[type=radio][name=appeal-target]").forEach((input) => { + input.addEventListener("change", () => { + if (!input.checked) return; + const v = input.value; + if ((APPEAL_TARGETS as readonly string[]).includes(v)) { + currentAppealTarget = v as AppealTarget; + } else { + currentAppealTarget = ""; + } + writeAppealTargetToURL(currentAppealTarget); + scheduleCalc(0); + }); + }); } document.addEventListener("DOMContentLoaded", () => { diff --git a/frontend/src/client/views/verfahrensablauf-core.ts b/frontend/src/client/views/verfahrensablauf-core.ts index fe1afa4..ff26182 100644 --- a/frontend/src/client/views/verfahrensablauf-core.ts +++ b/frontend/src/client/views/verfahrensablauf-core.ts @@ -195,6 +195,12 @@ export interface CalcParams { // Sent only when the page-level "Ausgeblendete anzeigen" toggle is // ON. includeHidden?: boolean; + // Slice B1 / m/paliad#124 §18.1: narrows the unified UPC Berufung + // (upc.apl) timeline to the rule subset whose applies_to_target + // contains the requested slug. Empty = no filter. Valid values: + // endentscheidung | kostenentscheidung | anordnung | + // schadensbemessung | bucheinsicht. + appealTarget?: string; } const PARTY_CLASS: Record = { @@ -811,6 +817,7 @@ export async function calculateDeadlines(params: CalcParams): Promise + {/* Appeal-target chip row (Slice B1 / m/paliad#124 §18.1). + Shown only when the unified upc.apl Berufung tile is + selected; lets the user narrow the timeline to the + rules whose applies_to_target contains the picked + decision kind. URL state ?target=. */} +