diff --git a/docs/design-litigation-planner-2026-05-26.md b/docs/design-litigation-planner-2026-05-26.md index 92fde0e..a7d6ec8 100644 --- a/docs/design-litigation-planner-2026-05-26.md +++ b/docs/design-litigation-planner-2026-05-26.md @@ -1245,16 +1245,26 @@ Frontend logic: 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. -#### Open question for m (escalate via `mai instruct head`) +#### m's answer on Q18.1.1 (2026-05-26 13:40) -**Q18.1.1 — Is Schadensbemessung-as-appeal a duplicate of Endentscheidung, or a distinct set?** +> 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. -Three interpretations: -- A. **Shared rule set**: appeal of a Schadensbemessung uses the SAME rules as appeal of an Endentscheidung (both run the 2/4-month merits track). `applies_to_target=['endentscheidung','schadensbemessung']` on each rule. (R) — simplest, matches what the live `upc.apl.merits` corpus does today (no explicit target distinction). -- B. **Distinct rule set**: Schadensbemessung-appeal has its own anchor + sequence (different trigger event, different timing). Would need 7 new rule rows specifically for `applies_to_target=['schadensbemessung']`. No live evidence for this today. -- C. **Defer**: ship Berufung unification with only 3 targets (endentscheidung / kostenentscheidung / anordnung) for v1; add Schadensbemessung + Bucheinsicht as a follow-up. +→ **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. -Recommendation: **A** — share rules, multi-valued `applies_to_target` array. Frontend renders all 5 chips from day 1; the merits 7 rules show under endentscheidung + schadensbemessung; the order 7 rules show under anordnung + bucheinsicht (the 15-day track DOES apply to Bucheinsicht under R.142+R.220.2). No new rule rows needed. +**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)?** 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=. */} +