From 7d7b20651d0693f823e01d72cb920e8005fc34a8 Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 26 May 2026 17:56:12 +0200 Subject: [PATCH 1/2] feat(litigationplanner): appeal-target synthetic trigger row + appeal-role stamping (t-paliad-307, m/paliad#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Engine side of the four Verfahrensablauf appeal bugs in m/paliad#136. Bug 2 — Missing trigger event row. When CalcOptions.AppealTarget is set, Calculate now prepends a synthetic TimelineEntry to the deadlines slice dated to the trigger date, carrying the per-appeal-target label from TriggerEventLabelForAppealTarget (Endentscheidung (R.118), Kosten- entscheidung, Anordnung, Schadensbemessung, Bucheinsicht). Marked IsRootEvent + IsTriggerEvent + party=court + priority=informational so the frontend renders it as a dimmed anchor card without a save button / choices caret / click-to-edit affordance. Empty Code so it doesn't collide with real rule UUIDs downstream. Bug 1 (engine half) — Side selector dead on appeal. Every appeal filing rule carries primary_party='both' in the catalog, so the column bucketer couldn't distinguish Berufungskläger vs Berufungs- beklagter filings from primary_party alone. Engine now stamps the new TimelineEntry.AppealRole field with appellant/appellee from the rule-semantic AppealFilerRole mapping (appeal_role.go) when an appeal_target is in scope. The frontend half of the fix (next commit) consumes this to route each "both" rule into the user-perspective column once the user picks a side. Mapping covers all 12 appeal filing rules across the three applies_to_target tracks (endentscheidung/schadensbemessung, kostenentscheidung, anordnung/bucheinsicht). Court-issued events (merits.decision, merits.oral, cost.decision, order.order) stay empty — they continue to route on Party='court'. Unmapped submission_codes return empty so a new appeal rule we forgot to map falls through to the bucketer's legacy path rather than silently picking a side. Tests: TestAppealFilerRole pins the mapping; TestCalculate_Appeal SyntheticTriggerRow covers (a) synthetic row prepended + AppealRole stamped when target is set, (b) no synthetic row + no AppealRole when target is unset (regression guard), (c) unknown target short-circuits to no-op. Existing tests untouched — both behaviours gate on opts.AppealTarget != "". No DB migration — the bugs are calc-side. deadline_rules untouched. --- pkg/litigationplanner/appeal_role.go | 58 +++++++ pkg/litigationplanner/appeal_role_test.go | 192 ++++++++++++++++++++++ pkg/litigationplanner/engine.go | 40 +++++ pkg/litigationplanner/types.go | 16 ++ 4 files changed, 306 insertions(+) create mode 100644 pkg/litigationplanner/appeal_role.go create mode 100644 pkg/litigationplanner/appeal_role_test.go diff --git a/pkg/litigationplanner/appeal_role.go b/pkg/litigationplanner/appeal_role.go new file mode 100644 index 0000000..3d88dcf --- /dev/null +++ b/pkg/litigationplanner/appeal_role.go @@ -0,0 +1,58 @@ +package litigationplanner + +// AppealRole* are the canonical filer-role slugs used by the unified +// upc.apl Berufung proceeding (t-paliad-307 / m/paliad#136 Bug 1). +// +// Every appeal filing rule carries primary_party='both' in the catalog +// (either party could be the appellant, depending on which side lost +// downstream), so the static primary_party column can't drive +// column-bucketing under a user-perspective `?side=` pick. The +// per-rule appeal role fills that gap: "appellant" rules are filed by +// the Berufungskläger (the party who lost in the lower instance and +// is now appealing); "appellee" rules are filed by the +// Berufungsbeklagter (the party defending the lower-instance +// decision). The mapping is rule-semantic, not data-driven — we know +// from R.224/235 which submission belongs to which side. +const ( + AppealRoleAppellant = "appellant" + AppealRoleAppellee = "appellee" +) + +// AppealFilerRole returns the appeal-filer role for a submission code +// in the unified upc.apl proceeding. Empty string for codes whose role +// is not statically known (court-issued events, unmapped codes, or +// non-appeal proceedings). +// +// The engine stamps TimelineEntry.AppealRole with this value when +// CalcOptions.AppealTarget is set so the frontend column-bucketer can +// route each "both"-party rule into the correct user-perspective +// column (Berufungskläger vs Berufungsbeklagter) once the user picks +// a side. +// +// Adding a new appeal rule? Add its submission_code to the matching +// branch below. Court-issued events (cost.decision, order.order, +// merits.oral, merits.decision) deliberately stay empty — they route +// to the court column on primary_party='court'. +func AppealFilerRole(submissionCode string) string { + switch submissionCode { + // Appellant filings — Berufungskläger initiates the appeal + + // replies to the cross-appeal. + case "upc.apl.merits.notice", + "upc.apl.merits.grounds", + "upc.apl.merits.cross_a_reply", + "upc.apl.cost.leave_app", + "upc.apl.order.with_leave", + "upc.apl.order.grounds_orders", + "upc.apl.order.discretion", + "upc.apl.order.cross_reply": + return AppealRoleAppellant + // Appellee filings — Berufungsbeklagter responds to the appeal + + // files the cross-appeal. + case "upc.apl.merits.response", + "upc.apl.merits.cross_a", + "upc.apl.order.response_orders", + "upc.apl.order.cross": + return AppealRoleAppellee + } + return "" +} diff --git a/pkg/litigationplanner/appeal_role_test.go b/pkg/litigationplanner/appeal_role_test.go new file mode 100644 index 0000000..fafe072 --- /dev/null +++ b/pkg/litigationplanner/appeal_role_test.go @@ -0,0 +1,192 @@ +package litigationplanner + +import ( + "context" + "testing" + + "github.com/google/uuid" +) + +// TestAppealFilerRole pins the rule-semantic mapping that drives +// column-bucketing on the unified upc.apl Berufung timeline +// (t-paliad-307 / m/paliad#136 Bug 1). Every appeal filing rule has +// primary_party='both' in the catalog so the bucketer can't decide +// between Berufungskläger and Berufungsbeklagter columns from +// primary_party alone — the appeal role fills that gap. +func TestAppealFilerRole(t *testing.T) { + cases := []struct { + code string + want string + }{ + // Appellant filings (Berufungskläger initiates / replies to cross). + {"upc.apl.merits.notice", AppealRoleAppellant}, + {"upc.apl.merits.grounds", AppealRoleAppellant}, + {"upc.apl.merits.cross_a_reply", AppealRoleAppellant}, + {"upc.apl.cost.leave_app", AppealRoleAppellant}, + {"upc.apl.order.with_leave", AppealRoleAppellant}, + {"upc.apl.order.grounds_orders", AppealRoleAppellant}, + {"upc.apl.order.discretion", AppealRoleAppellant}, + {"upc.apl.order.cross_reply", AppealRoleAppellant}, + // Appellee filings (Berufungsbeklagter responds + cross-appeals). + {"upc.apl.merits.response", AppealRoleAppellee}, + {"upc.apl.merits.cross_a", AppealRoleAppellee}, + {"upc.apl.order.response_orders", AppealRoleAppellee}, + {"upc.apl.order.cross", AppealRoleAppellee}, + // Court-issued events stay empty — they route on party='court'. + {"upc.apl.merits.decision", ""}, + {"upc.apl.merits.oral", ""}, + {"upc.apl.cost.decision", ""}, + {"upc.apl.order.order", ""}, + // Unmapped codes are empty (defensive — never silently picks a + // side for a new appeal rule we forgot to map). + {"upc.inf.cfi.soc", ""}, + {"", ""}, + {"foo.bar", ""}, + } + for _, c := range cases { + if got := AppealFilerRole(c.code); got != c.want { + t.Errorf("AppealFilerRole(%q) = %q, want %q", c.code, got, c.want) + } + } +} + +// TestCalculate_AppealSyntheticTriggerRow exercises the synthetic root +// row the engine prepends when CalcOptions.AppealTarget is set +// (t-paliad-307 / m/paliad#136 Bug 2). The row carries the +// per-appeal-target label, the trigger date as DueDate, IsRootEvent= +// IsTriggerEvent=true, and party=court. Without the appeal_target +// filter, no synthetic row is emitted (regression guard). +func TestCalculate_AppealSyntheticTriggerRow(t *testing.T) { + ctx := context.Background() + + jurisdiction := "UPC" + procID := 1 + pt := ProceedingType{ + ID: procID, + Code: "upc.apl.unified", + Name: "Berufung", + NameEN: "Appeal", + Jurisdiction: &jurisdiction, + IsActive: true, + } + + mkID := func() uuid.UUID { + id, _ := uuid.NewRandom() + return id + } + str := func(s string) *string { return &s } + procIDPtr := &procID + + noticeCode := "upc.apl.merits.notice" + groundsCode := "upc.apl.merits.grounds" + + rules := []Rule{ + { + ID: mkID(), + ProceedingTypeID: procIDPtr, + SubmissionCode: ¬iceCode, + Name: "Berufungseinlegung", + NameEN: "Notice of Appeal", + PrimaryParty: str(PrimaryPartyBoth), + DurationValue: 2, + DurationUnit: "months", + Timing: str("after"), + SequenceOrder: 0, + IsActive: true, + LifecycleState: "published", + Priority: "mandatory", + AppliesToTarget: []string{AppealTargetEndentscheidung, AppealTargetSchadensbemessung}, + }, + { + ID: mkID(), + ProceedingTypeID: procIDPtr, + SubmissionCode: &groundsCode, + Name: "Berufungsbegründung", + NameEN: "Statement of Grounds", + PrimaryParty: str(PrimaryPartyBoth), + DurationValue: 4, + DurationUnit: "months", + Timing: str("after"), + SequenceOrder: 1, + IsActive: true, + LifecycleState: "published", + Priority: "mandatory", + AppliesToTarget: []string{AppealTargetEndentscheidung, AppealTargetSchadensbemessung}, + }, + } + + cat := &stubCatalog{pt: pt, rules: rules} + + t.Run("with appeal_target — synthetic row prepended + appeal_role stamped", func(t *testing.T) { + opts := CalcOptions{AppealTarget: AppealTargetEndentscheidung} + timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{}) + if err != nil { + t.Fatalf("Calculate: %v", err) + } + if len(timeline.Deadlines) < 3 { + t.Fatalf("expected synthetic row + 2 rules, got %d rows", len(timeline.Deadlines)) + } + // Synthetic row first. + first := timeline.Deadlines[0] + if !first.IsTriggerEvent { + t.Errorf("first row IsTriggerEvent=%v, want true", first.IsTriggerEvent) + } + if !first.IsRootEvent { + t.Errorf("first row IsRootEvent=%v, want true", first.IsRootEvent) + } + if first.Name != "Endentscheidung (R.118)" { + t.Errorf("first row Name=%q, want %q", first.Name, "Endentscheidung (R.118)") + } + if first.NameEN != "Final decision (R.118)" { + t.Errorf("first row NameEN=%q, want %q", first.NameEN, "Final decision (R.118)") + } + if first.DueDate != "2026-05-26" { + t.Errorf("first row DueDate=%q, want 2026-05-26", first.DueDate) + } + if first.Party != PrimaryPartyCourt { + t.Errorf("first row Party=%q, want court", first.Party) + } + // Real rules should carry AppealRole. + byCode := map[string]TimelineEntry{} + for _, d := range timeline.Deadlines { + byCode[d.Code] = d + } + if got := byCode[noticeCode].AppealRole; got != AppealRoleAppellant { + t.Errorf("notice AppealRole=%q, want appellant", got) + } + if got := byCode[groundsCode].AppealRole; got != AppealRoleAppellant { + t.Errorf("grounds AppealRole=%q, want appellant", got) + } + }) + + t.Run("without appeal_target — no synthetic row, no appeal_role", func(t *testing.T) { + opts := CalcOptions{} + timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{}) + if err != nil { + t.Fatalf("Calculate: %v", err) + } + for _, d := range timeline.Deadlines { + if d.IsTriggerEvent { + t.Errorf("unexpected synthetic trigger row when appeal_target is unset: %+v", d) + } + if d.AppealRole != "" { + t.Errorf("unexpected AppealRole=%q when appeal_target is unset (rule %q)", d.AppealRole, d.Code) + } + } + }) + + t.Run("unknown appeal_target — short-circuits to no-op", func(t *testing.T) { + opts := CalcOptions{AppealTarget: "bogus"} + timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{}) + if err != nil { + t.Fatalf("Calculate: %v", err) + } + // IsValidAppealTarget("bogus") = false, so the engine skips + // both the rule filter AND the synthetic trigger emission. + for _, d := range timeline.Deadlines { + if d.IsTriggerEvent { + t.Errorf("unexpected synthetic trigger row for unknown target: %+v", d) + } + } + }) +} diff --git a/pkg/litigationplanner/engine.go b/pkg/litigationplanner/engine.go index 16a550d..486fb33 100644 --- a/pkg/litigationplanner/engine.go +++ b/pkg/litigationplanner/engine.go @@ -572,6 +572,21 @@ func Calculate( deadlines = append(deadlines, d) } + // Stamp AppealRole on every entry when an appeal-target filter is + // active so the frontend column-bucketer can route primary_party= + // 'both' rules into the user-perspective columns + // (Berufungskläger vs Berufungsbeklagter). Court events stay empty + // — they route on Party='court' regardless. (t-paliad-307 / + // m/paliad#136 Bug 1) + if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) { + for i := range deadlines { + if deadlines[i].Code == "" { + continue + } + deadlines[i].AppealRole = AppealFilerRole(deadlines[i].Code) + } + } + // Restore sequence_order on the output slice. The compute walk // re-ordered rules topologically (parent-first) so the parent-state // checks resolved correctly; the wire shape and the linear timeline @@ -594,6 +609,31 @@ func Calculate( // same-group rows. Court-set / conditional rows sort LAST. sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID) + // Synthetic trigger-event row for appeal timelines (t-paliad-307 / + // m/paliad#136 Bug 2). The decision being appealed (Endentscheidung + // R.118, Kostenentscheidung, Anordnung, …) isn't a rule in the + // upc.apl catalog — it's the anchor the user picked. Lawyers expect + // it to surface as the first row of the timeline so the chain reads + // decision → appeal filings → next decision. Emitted only when an + // appeal_target is in play and the helper returns a non-empty label. + if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) { + nameDE := TriggerEventLabelForAppealTarget(opts.AppealTarget, "de") + nameEN := TriggerEventLabelForAppealTarget(opts.AppealTarget, "en") + if nameDE != "" || nameEN != "" { + trig := TimelineEntry{ + Name: nameDE, + NameEN: nameEN, + Party: PrimaryPartyCourt, + Priority: "informational", + DueDate: triggerDateStr, + OriginalDate: triggerDateStr, + IsRootEvent: true, + IsTriggerEvent: true, + } + deadlines = append([]TimelineEntry{trig}, deadlines...) + } + } + resp := &Timeline{ ProceedingType: pickedProceeding.Code, ProceedingName: pickedProceeding.Name, diff --git a/pkg/litigationplanner/types.go b/pkg/litigationplanner/types.go index d1523b4..1c715e5 100644 --- a/pkg/litigationplanner/types.go +++ b/pkg/litigationplanner/types.go @@ -441,6 +441,22 @@ type TimelineEntry struct { DurationValue int `json:"durationValue,omitempty"` DurationUnit string `json:"durationUnit,omitempty"` Timing string `json:"timing,omitempty"` + + // AppealRole carries the rule's appeal-filer role (t-paliad-307 / + // m/paliad#136 Bug 1) when the timeline was computed under an + // appeal_target filter. One of AppealRoleAppellant / + // AppealRoleAppellee, or empty for court events / non-appeal + // timelines. The frontend column-bucketer reads this to route + // primary_party='both' rules to Berufungskläger vs + // Berufungsbeklagter columns once the user picks a side. + AppealRole string `json:"appealRole,omitempty"` + + // IsTriggerEvent marks the synthetic root row that represents the + // decision being appealed (t-paliad-307 / m/paliad#136 Bug 2). + // Distinct from IsRootEvent in that the row carries no real rule + // id — it's a UI marker dated to the trigger date with the + // per-appeal-target label from TriggerEventLabelForAppealTarget. + IsTriggerEvent bool `json:"isTriggerEvent,omitempty"` } // RuleCalculation is the single-rule calc response that backs the From 367627af0d91d33529b66b7df5108a9cbd2a9370 Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 26 May 2026 17:56:32 +0200 Subject: [PATCH 2/2] fix(verfahrensablauf): appeal side filter + parent in duration label + notes dedup (t-paliad-307, m/paliad#136) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Frontend half of the four Verfahrensablauf appeal bugs. Bug 1 (frontend half) — Side selector dead on appeal. The column bucketer now reads dl.appealRole (engine-stamped under appeal_target) and routes each "both" appeal rule via the user side: side=claimant maps the user to the appellant, so appellant filings land in 'ours' and appellee filings in 'opponent'; side=defendant mirrors. side=null keeps the legacy mirror so every appeal rule renders in both columns (every-rule-visible behaviour the brief calls out). The new appealAware opt gates the path so non-appeal proceedings keep their existing bucketing untouched. Removed upc.apl.unified from APPELLANT_AXIS_PROCEEDINGS — appeal routing is now per-rule via appealRole, not a page-level appellant collapse. Other role-swap proceedings (EPA opp, DE/DPMA appeals) keep the appellant axis since they have no appeal_target metadata. Bug 3 — Duration label appends parent name. formatDurationLabel now takes an optional parent fallback and renders " ". deadlineCardHtml resolves the parent per-rule (dl.parentRuleName / EN variant), falling back to opts.trigger EventLabel for root rules with a non-zero duration (e.g. Berufungseinlegung 2 mo. after the Endentscheidung). renderColumns Body + renderTimelineBody auto-derive the trigger event label from the response via the new pickTriggerEventLabel helper unless the caller passes one explicitly. Bug 4 — Duration prefix stripped from deadline_notes. New stripLeadingDurationFromNotes regex peels off leading "Frist N …. " (DE) and "- period from …" / "N BEFORE …" / "Period is N from …" (EN) up to the first sentence boundary. Wired into deadlineCardHtml so noteHint + notesBlock both render the deduped text. Per the brief's option (a): conservative regex, composite durations with "ODER" / "whichever is the longer" stay untouched as a follow-up editorial cleanup. deadline_rules DB untouched. Tests: 22 new test cases across appeal-aware bucketing, formatDurationLabel parent append, deadlineCardHtml duration tooltip resolution, and stripLeadingDurationFromNotes regex (positive + negative + composite + EN/DE variants). All 209 frontend tests pass. Engine wire fields added in the preceding commit (AppealRole, IsTriggerEvent). Reads them from CalculatedDeadline without breaking the wire contract for non-appeal callers. --- frontend/src/client/verfahrensablauf.ts | 14 +- .../views/verfahrensablauf-core.test.ts | 286 ++++++++++++++++++ .../src/client/views/verfahrensablauf-core.ts | 199 +++++++++++- 3 files changed, 483 insertions(+), 16 deletions(-) diff --git a/frontend/src/client/verfahrensablauf.ts b/frontend/src/client/verfahrensablauf.ts index 6b03041..860199e 100644 --- a/frontend/src/client/verfahrensablauf.ts +++ b/frontend/src/client/verfahrensablauf.ts @@ -61,8 +61,14 @@ let sidePrefilledFromProject = false; // chosen side's column). For first-instance proceedings (Inf, Rev, // …) the side picker still narrows columns but doesn't collapse // the "both" rows. +// +// upc.apl.unified is NOT in this set since t-paliad-307: appeal +// timelines route via per-rule appealRole (engine-stamped under +// appeal_target) instead of the page-level appellant axis collapse. +// Adding upc.apl.unified here would short-circuit the appealAware +// path and re-introduce the dead side selector on upc.apl.unified +// (m/paliad#136 Bug 1). const APPELLANT_AXIS_PROCEEDINGS = new Set([ - "upc.apl.unified", "de.inf.olg", "de.inf.bgh", "de.null.bgh", @@ -505,6 +511,12 @@ function renderResults(data: DeadlineResponse) { // in the picked side's column). For non-role-swap proceedings, // the appellant axis is irrelevant — pass null. appellant: hasAppellantAxis(selectedType) ? currentSide : null, + // Appeal-target proceedings get per-rule appealRole routing + // instead of the page-level appellant collapse, so the side + // selector actually splits Berufungskläger vs Berufungs- + // beklagter filings across columns. (t-paliad-307 / + // m/paliad#136 Bug 1) + appealAware: hasAppealTarget(selectedType), }) : renderTimelineBody(data, { showParty: true, editable: true, showNotes, showDurations }); diff --git a/frontend/src/client/views/verfahrensablauf-core.test.ts b/frontend/src/client/views/verfahrensablauf-core.test.ts index 7e07870..0f49534 100644 --- a/frontend/src/client/views/verfahrensablauf-core.test.ts +++ b/frontend/src/client/views/verfahrensablauf-core.test.ts @@ -4,7 +4,9 @@ import { type DeadlineResponse, bucketDeadlinesIntoColumns, deadlineCardHtml, + formatDurationLabel, renderColumnsBody, + stripLeadingDurationFromNotes, } from "./verfahrensablauf-core"; // Regression tests for the editable→click-to-edit wiring on timeline date @@ -487,3 +489,287 @@ describe("renderColumnsBody — side-aware column header labels (m/paliad#127)", expect(html).not.toContain(">Reaktiv<"); }); }); + +// t-paliad-307 / m/paliad#136 Bug 1 — appeal-aware column routing. +// All appeal rules carry party='both' (either side could be the +// appellant). With appealAware=true + dl.appealRole set, the bucketer +// routes by (filer matches user) instead of collapsing every 'both' +// row into the user's column. Without a side picked, the bucketer +// keeps the legacy mirror so every appeal rule is visible. +describe("bucketDeadlinesIntoColumns — appeal-aware routing (t-paliad-307)", () => { + const appeal = ( + name: string, + role: "appellant" | "appellee", + due: string, + ): CalculatedDeadline => ({ + code: name, + name, + nameEN: name, + party: "both", + priority: "mandatory", + ruleRef: "", + dueDate: due, + originalDate: due, + wasAdjusted: false, + isRootEvent: false, + isCourtSet: false, + appealRole: role, + }); + + const notice = appeal("Berufungseinlegung", "appellant", "2026-07-26"); + const grounds = appeal("Berufungsbegründung", "appellant", "2026-09-26"); + const response = appeal("Berufungserwiderung", "appellee", "2026-12-26"); + + test("appealAware + side=claimant: appellant rules → ours, appellee rules → opponent", () => { + const rows = bucketDeadlinesIntoColumns([notice, grounds, response], { + side: "claimant", + appealAware: true, + }); + const byKey = new Map(rows.map((r) => [r.key, r])); + expect(byKey.get(notice.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]); + expect(byKey.get(notice.dueDate)?.opponent).toHaveLength(0); + expect(byKey.get(response.dueDate)?.ours).toHaveLength(0); + expect(byKey.get(response.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungserwiderung"]); + }); + + test("appealAware + side=defendant: appellant rules → opponent, appellee rules → ours", () => { + const rows = bucketDeadlinesIntoColumns([notice, response], { + side: "defendant", + appealAware: true, + }); + const byKey = new Map(rows.map((r) => [r.key, r])); + expect(byKey.get(notice.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungseinlegung"]); + expect(byKey.get(notice.dueDate)?.ours).toHaveLength(0); + expect(byKey.get(response.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungserwiderung"]); + expect(byKey.get(response.dueDate)?.opponent).toHaveLength(0); + }); + + test("appealAware + side=null: mirror to both columns (every rule visible)", () => { + const rows = bucketDeadlinesIntoColumns([notice, response], { + side: null, + appealAware: true, + }); + const byKey = new Map(rows.map((r) => [r.key, r])); + expect(byKey.get(notice.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]); + expect(byKey.get(notice.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungseinlegung"]); + expect(byKey.get(response.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungserwiderung"]); + expect(byKey.get(response.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungserwiderung"]); + }); + + test("appealAware off: appealRole is ignored and legacy bucketing applies", () => { + // Regression guard: a stale frontend that drops `appealAware: true` + // must not silently route via appealRole — the side selector + // would visibly change behaviour without a UI control to opt in. + const rows = bucketDeadlinesIntoColumns([notice, response], { side: "defendant" }); + // Legacy "side without appellant" collapse → both rows into ours. + const allOurs = rows.flatMap((r) => r.ours.map((d) => d.name)); + expect(allOurs).toEqual(["Berufungseinlegung", "Berufungserwiderung"]); + rows.forEach((r) => expect(r.opponent).toHaveLength(0)); + }); + + test("appealAware respects court party — court rows always route to court column", () => { + const decision: CalculatedDeadline = { + ...notice, + name: "Entscheidung", + party: "court", + appealRole: "", // court events deliberately stay empty + dueDate: "", + }; + const rows = bucketDeadlinesIntoColumns([decision], { side: "claimant", appealAware: true }); + expect(rows[0].court.map((d) => d.name)).toEqual(["Entscheidung"]); + expect(rows[0].ours).toHaveLength(0); + expect(rows[0].opponent).toHaveLength(0); + }); + + test("appealAware + rule without appealRole falls back to legacy bucketing", () => { + // A future appeal rule we forgot to map: appealRole='' falls + // through the appealAware branch and lands in the legacy + // side-collapse path → ours. + const unmapped: CalculatedDeadline = { ...notice, appealRole: "" }; + const rows = bucketDeadlinesIntoColumns([unmapped], { side: "claimant", appealAware: true }); + expect(rows[0].ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]); + expect(rows[0].opponent).toHaveLength(0); + }); +}); + +// t-paliad-307 / m/paliad#136 Bug 3 — duration label appends the +// parent rule name (or the proceeding's trigger event label for +// root rules) so the chip reads "4 Monate nach Endentscheidung" +// instead of the dangling "4 Monate nach". +describe("formatDurationLabel — appends parent name (t-paliad-307)", () => { + const dl = (overrides: Partial = {}): CalculatedDeadline => ({ + code: "x", + name: "x", + nameEN: "x", + party: "both", + priority: "mandatory", + ruleRef: "", + dueDate: "", + originalDate: "", + wasAdjusted: false, + isRootEvent: false, + isCourtSet: false, + durationValue: 4, + durationUnit: "months", + timing: "after", + ...overrides, + }); + + test("with parent label: appends to head", () => { + expect(formatDurationLabel(dl(), "Endentscheidung (R.118)")) + .toBe("4 Monate nach Endentscheidung (R.118)"); + }); + + test("without parent label: bare head — caller decides whether to render", () => { + expect(formatDurationLabel(dl())).toBe("4 Monate nach"); + }); + + test("without timing: parent is not appended (degenerate phrasing)", () => { + // No timing == we can't form "4 Monate " cleanly, + // so the bare "4 Monate" head stays. Pinned to catch a future + // edit that would emit "4 Monate Endentscheidung" without a + // preposition. + expect(formatDurationLabel(dl({ timing: "" }), "Endentscheidung")).toBe("4 Monate"); + }); + + test("singular value: switches to .one unit key", () => { + expect(formatDurationLabel(dl({ durationValue: 1 }), "X")).toBe("1 Monat nach X"); + }); + + test("zero / missing duration: empty string", () => { + expect(formatDurationLabel(dl({ durationValue: 0 }), "X")).toBe(""); + expect(formatDurationLabel(dl({ durationValue: 0, durationUnit: "" }), "X")).toBe(""); + }); +}); + +describe("deadlineCardHtml — duration tooltip reads parent name (t-paliad-307)", () => { + test("root rule with non-zero duration uses opts.triggerEventLabel as parent fallback", () => { + // upc.apl.merits.notice has no parent_id but a 2-month duration + // off the trigger event (the appealed decision). The duration + // tooltip must read the appeal-target label, not just "2 Monate + // nach". + const dl: CalculatedDeadline = { + code: "upc.apl.merits.notice", + name: "Berufungseinlegung", + nameEN: "Notice of Appeal", + party: "both", + priority: "mandatory", + ruleRef: "", + dueDate: "2026-07-26", + originalDate: "2026-07-26", + wasAdjusted: false, + isRootEvent: false, + isCourtSet: false, + durationValue: 2, + durationUnit: "months", + timing: "after", + }; + const html = deadlineCardHtml(dl, { + showParty: false, + editable: true, + triggerEventLabel: "Endentscheidung (R.118)", + }); + expect(html).toContain("title=\"2 Monate nach Endentscheidung (R.118)\""); + }); + + test("non-root rule prefers parent rule name over triggerEventLabel", () => { + // merits.response chains off merits.grounds; the duration label + // should read "3 Monate nach Berufungsbegründung", not the + // appeal-target fallback. + const dl: CalculatedDeadline = { + code: "upc.apl.merits.response", + name: "Berufungserwiderung", + nameEN: "Response to Appeal", + party: "both", + priority: "mandatory", + ruleRef: "", + dueDate: "2026-12-26", + originalDate: "2026-12-26", + wasAdjusted: false, + isRootEvent: false, + isCourtSet: false, + durationValue: 3, + durationUnit: "months", + timing: "after", + parentRuleCode: "upc.apl.merits.grounds", + parentRuleName: "Berufungsbegründung", + parentRuleNameEN: "Statement of Grounds", + }; + const html = deadlineCardHtml(dl, { + showParty: false, + editable: true, + triggerEventLabel: "Endentscheidung (R.118)", + }); + expect(html).toContain("title=\"3 Monate nach Berufungsbegründung\""); + }); +}); + +// t-paliad-307 / m/paliad#136 Bug 4 — leading "Frist N …" +// substring is stripped before deadline_notes renders so the new +// duration affordance and the legacy free-text don't duplicate. +describe("stripLeadingDurationFromNotes — render-side dedup (t-paliad-307)", () => { + test("DE: strips 'Frist 1 Monat VOR …. ' and keeps the rest", () => { + const out = stripLeadingDurationFromNotes( + "Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag auf Simultanübersetzung.", + "de", + ); + expect(out).toBe("Antrag auf Simultanübersetzung."); + }); + + test("DE: strips 'Frist 15 Tage ab …' when the whole notes is the duration prose", () => { + const out = stripLeadingDurationFromNotes( + "Frist 15 Tage ab Zustellung der Kostenentscheidung", + "de", + ); + expect(out).toBe(""); + }); + + test("DE: strips 'Frist beträgt 2 Monate ab …. ' (Wiedereinsetzung variant)", () => { + const out = stripLeadingDurationFromNotes( + "Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens 1 Jahr.", + "de", + ); + expect(out).toBe("Spätestens 1 Jahr."); + }); + + test("DE: composite 'Frist N … ODER M …' is preserved (option b follow-up)", () => { + const composite = + "Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung der einstweiligen Maßnahme."; + expect(stripLeadingDurationFromNotes(composite, "de")).toBe(composite); + }); + + test("DE: 'Frist vom Gericht' (no number) is preserved", () => { + const out = stripLeadingDurationFromNotes("Frist vom Gericht bestimmt", "de"); + expect(out).toBe("Frist vom Gericht bestimmt"); + }); + + test("EN: strips '1 month BEFORE …. ' and keeps the rest", () => { + const out = stripLeadingDurationFromNotes( + "1 month BEFORE the oral hearing (R.109.1). Request for simultaneous interpretation.", + "en", + ); + expect(out).toBe("Request for simultaneous interpretation."); + }); + + test("EN: strips '15-day period from …'", () => { + const out = stripLeadingDurationFromNotes( + "15-day period from service of the cost decision", + "en", + ); + expect(out).toBe(""); + }); + + test("EN: strips 'Period is N from …'", () => { + const out = stripLeadingDurationFromNotes( + "Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest 12 months.", + "en", + ); + expect(out).toBe("Latest 12 months."); + }); + + test("EN: empty / non-matching notes pass through unchanged", () => { + expect(stripLeadingDurationFromNotes("", "en")).toBe(""); + expect(stripLeadingDurationFromNotes("Time limit set by the court", "en")) + .toBe("Time limit set by the court"); + }); +}); diff --git a/frontend/src/client/views/verfahrensablauf-core.ts b/frontend/src/client/views/verfahrensablauf-core.ts index 04e3174..d128831 100644 --- a/frontend/src/client/views/verfahrensablauf-core.ts +++ b/frontend/src/client/views/verfahrensablauf-core.ts @@ -104,19 +104,92 @@ export interface CalculatedDeadline { durationValue?: number; durationUnit?: string; timing?: string; + // appealRole carries the rule's appeal-filer identity when the + // server computed the timeline under an appeal_target filter: + // "appellant" (Berufungskläger files this rule), "appellee" + // (Berufungsbeklagter files this rule), or empty for court events + // and non-appeal timelines. The column bucketer reads this in + // preference to primary_party='both' so a user-perspective `?side=` + // pick can split appeal filings into the user's column vs the + // opponent's, instead of routing every "both" rule into the + // user's column. (t-paliad-307 / m/paliad#136 Bug 1) + appealRole?: "appellant" | "appellee" | ""; + // isTriggerEvent marks the synthetic row the engine prepends to the + // timeline when computing an appeal: a court-set decision dated to + // the trigger date with the per-appeal-target label + // (Endentscheidung / Kostenentscheidung / Anordnung / …). The row + // carries no real rule_id — it's a UI marker so the timeline reads + // decision → appeal filings → next decision. (t-paliad-307 / + // m/paliad#136 Bug 2) + isTriggerEvent?: boolean; } -// formatDurationLabel renders the per-rule duration ("2 Mo. nach") for -// the Verfahrensablauf card affordance (m/paliad#133, t-paliad-302). -// Returns empty string for rules without a usable duration so the -// caller can skip the tooltip / inline span entirely. +// stripLeadingDurationFromNotes drops the leading +// "Frist N ." (DE) / +// "N ." (EN) prefix from a rule's +// deadline_notes so it doesn't duplicate the new duration affordance +// added in m/paliad#133 (t-paliad-307 Bug 4). // -// Pluralisation key naming mirrors the Fristenrechner event-mode -// renderer (deadlines.event.unit..{one,many}) — the unit and -// timing translations already exist for /tools/fristenrechner's -// "Was kommt nach…" mode and are reused here as the single -// source of truth. -export function formatDurationLabel(dl: CalculatedDeadline): string { +// The duration affordance now renders the same prose as a badge on +// the card ("4 Monate nach Endentscheidung (R.118)"); a free-text +// notes string that opens with the same prose reads as a verbatim +// duplicate. Only the leading-prefix shape is stripped — anything +// after the first sentence is preserved (the editorial commentary +// the lawyers actually want to read). +// +// Conservative: composite-duration prefaces with "ODER" / +// "whichever is the longer" don't match and stay untouched — those +// are the follow-up editorial cleanup (option b in the issue brief). +// +// Examples: +// "Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag …" +// → "Antrag …" +// "Frist 15 Tage ab Zustellung der Kostenentscheidung" +// → "" +// "Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens …" +// → "Spätestens …" +// "1-month period from service of the main decision" +// → "" +// "1 month BEFORE the oral hearing (R.109.1). Request for …" +// → "Request for …" +// "Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest …" +// → "Latest …" +// "Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung …" +// → unchanged (composite — option b follow-up) +export function stripLeadingDurationFromNotes(notes: string, lang: "de" | "en"): string { + if (!notes) return notes; + // Terminator `(?:\.\s+|$)` matches the FIRST sentence boundary + // (period followed by whitespace) OR end of input. Embedded dots + // inside parenthesised citations (R.109.1, § 123(2), Rule 136(1)) + // are skipped because the char right after them isn't whitespace. + // `[^]*?` is the JS-portable form of `.*?` with the dotAll flag — + // any character including newlines, non-greedy. + const re = lang === "en" + ? /^(?:Period\s+is\s+)?\d+(?:[-\s]\S+)?\s+(?:\S+\s+)?(?:before|from|after|since)\b[^]*?(?:\.\s+|$)/i + : /^Frist\s+(?:beträgt\s+)?\d+\s+\S+\s+(?:VOR|vor|nach|ab|seit)\b[^]*?(?:\.\s+|$)/; + return notes.replace(re, ""); +} + +// formatDurationLabel renders the per-rule duration label for the +// Verfahrensablauf card affordance: "2 Monate nach Endentscheidung", +// "1 Monat vor Mündlicher Verhandlung", … +// (m/paliad#133, t-paliad-302; parent-name append: t-paliad-307 / +// m/paliad#136 Bug 3). +// +// Returns empty string for rules without a usable duration so the +// caller can skip the tooltip / inline span entirely. Pluralisation +// key naming mirrors the Fristenrechner event-mode renderer +// (deadlines.event.unit..{one,many}) — the unit and timing +// translations already exist for /tools/fristenrechner's +// "Was kommt nach…" mode and are reused here as the single source +// of truth. +// +// `parentLabel` is the rule's anchor name (parent rule's name when +// the rule has a parent_id; otherwise the proceeding's +// triggerEventLabel from the wire). Empty falls back to bare +// " " — bare phrasing is the pre-fix shape and +// remains the default for fixtures / tests that omit a parent. +export function formatDurationLabel(dl: CalculatedDeadline, parentLabel: string = ""): string { const value = dl.durationValue ?? 0; const unit = dl.durationUnit || ""; if (value <= 0 || !unit) return ""; @@ -124,7 +197,9 @@ export function formatDurationLabel(dl: CalculatedDeadline): string { const unitStr = tDyn(unitKey); const timing = dl.timing || ""; const timingStr = timing ? tDyn(`deadlines.event.timing.${timing}`) : ""; - return timingStr ? `${value} ${unitStr} ${timingStr}` : `${value} ${unitStr}`; + const head = timingStr ? `${value} ${unitStr} ${timingStr}` : `${value} ${unitStr}`; + if (!timingStr || !parentLabel) return head; + return `${head} ${parentLabel}`; } // priorityRendering returns the per-priority UX hints the save-modal @@ -363,16 +438,34 @@ export interface CardOpts { // flips this and re-renders; persisted via the localStorage key // `paliad.verfahrensablauf.durations-show`. Default false. showDurations?: boolean; + // triggerEventLabel: per-language label of the proceeding's anchor + // event ("Endentscheidung (R.118)" for an Endentscheidung appeal; + // "Klageerhebung" for upc.inf.cfi; …). Used by formatDurationLabel + // as the parent-name fallback when a rule is a root rule (no + // parent_id) but carries a non-zero duration — e.g. the + // Berufungseinlegung 2 months after Endentscheidung. Pages pass the + // already-language-resolved string. (t-paliad-307 / m/paliad#136 + // Bug 3) + triggerEventLabel?: string; } export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string { const wantsEditable = !!opts.editable; const editable = wantsEditable && !dl.isRootEvent && dl.code !== ""; const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : ""; + // Parent name for the duration label (t-paliad-307 / m/paliad#136 + // Bug 3): use the rule's parent if set, else fall back to the + // proceeding's trigger event label (e.g. "Endentscheidung (R.118)" + // for an Endentscheidung appeal; "Klageerhebung" for upc.inf.cfi). + // Empty for rules whose anchor isn't surface-able — the duration + // label degrades to the bare " " form in that case. + const parentLabelForDuration = (getLang() === "en" + ? (dl.parentRuleNameEN || dl.parentRuleName) + : (dl.parentRuleName || dl.parentRuleNameEN)) || opts.triggerEventLabel || ""; // Duration affordance (m/paliad#133, t-paliad-302). Computed once so // both the date-span tooltip and the inline meta-row span pull from // the same string. Empty for rules without a usable duration. - const durationLabel = formatDurationLabel(dl); + const durationLabel = formatDurationLabel(dl, parentLabelForDuration); // Hover affordance on the date span: prefer the duration tooltip when // we have one, else fall back to the edit-hint when the cell is // click-to-edit. The edit affordance still works either way — the @@ -478,7 +571,14 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string ruleRef = `${escHtml(dl.ruleRef)}`; } - const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes; + const rawNoteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes; + // Strip the leading-duration prefix so the new duration affordance + // doesn't duplicate what the lawyer wrote verbatim into deadline_notes + // for those legacy rule rows that still carry it. + // (t-paliad-307 / m/paliad#136 Bug 4) + const noteText = rawNoteText + ? stripLeadingDurationFromNotes(rawNoteText, getLang() === "en" ? "en" : "de") + : rawNoteText; const showNotes = opts.showNotes === true; const notesBlock = noteText && showNotes ? `
${noteText}
` @@ -608,7 +708,32 @@ export function wireDateEditClicks( }); } +// pickTriggerEventLabel returns the per-language trigger event label +// from a DeadlineResponse, used as the parent-fallback for root-rule +// duration labels. Mirrors the precedence the page-level +// triggerEventLabelFor uses (curated server label > proceedingName +// fallback). Distinct from the page helper in that it stays language- +// scoped to the current getLang() — root-rule duration labels render +// in the user's current language. (t-paliad-307 / m/paliad#136 Bug 3) +export function pickTriggerEventLabel(data: DeadlineResponse): string { + const lang = getLang(); + const curated = lang === "en" + ? (data.triggerEventLabelEN || data.triggerEventLabel || "") + : (data.triggerEventLabel || data.triggerEventLabelEN || ""); + if (curated) return curated; + return lang === "en" + ? (data.proceedingNameEN || data.proceedingName || "") + : (data.proceedingName || data.proceedingNameEN || ""); +} + export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string { + // Resolve the trigger event label once so the duration affordance on + // root rules (no parent) can read it as the anchor fallback. Caller- + // provided value wins (lets the page override for sub-track flows). + const cardOpts: CardOpts = { + ...opts, + triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data), + }; let html = '
'; for (const dl of data.deadlines) { const itemClasses = [ @@ -630,7 +755,7 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
- ${deadlineCardHtml(dl, opts)} + ${deadlineCardHtml(dl, cardOpts)}
`; @@ -689,6 +814,15 @@ export interface ColumnsBodyOpts { // (no mirror). Default null = mirror "both" into both cells // (legacy behaviour). Independent of `side`. appellant?: Side; + // appealAware: forwarded to bucketDeadlinesIntoColumns when the + // page is rendering an appeal_target-filtered timeline. Routes + // each rule to its filer-perspective column via dl.appealRole + // instead of the legacy primary_party='both' collapse. + // (t-paliad-307 / m/paliad#136 Bug 1) + appealAware?: boolean; + // triggerEventLabel: forwarded to deadlineCardHtml — see CardOpts. + // (t-paliad-307 / m/paliad#136 Bug 3) + triggerEventLabel?: string; } // ColumnsRow is the per-due-date bucket the renderer consumes. Public @@ -704,6 +838,15 @@ export interface ColumnsRow { export interface BucketingOpts { side?: Side; appellant?: Side; + // appealAware: when true, rules carrying a `dl.appealRole` of + // "appellant" / "appellee" route via the appeal role + user side + // axis instead of the legacy primary_party='both' collapse. With + // `side=null` the bucketer keeps the mirror semantic (both columns + // render every appeal rule); with `side` set, "appellant" rules + // land in the user's column when the user IS the appellant, in + // the opponent's column otherwise — mirror for "appellee" rules. + // (t-paliad-307 / m/paliad#136 Bug 1) + appealAware?: boolean; } // bucketDeadlinesIntoColumns is the pure routing primitive that @@ -738,6 +881,8 @@ export function bucketDeadlinesIntoColumns( return r; }; + const appealAware = opts.appealAware === true; + deadlines.forEach((dl, idx) => { const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`; const row = ensureRow(key); @@ -760,6 +905,25 @@ export function bucketDeadlinesIntoColumns( if (dl.appellantContext === "claimant" || dl.appellantContext === "defendant") { const perCardCol = dl.appellantContext === "claimant" ? claimantColumn : defendantColumn; row[perCardCol].push(dl); + } else if ( + appealAware && + (dl.appealRole === "appellant" || dl.appealRole === "appellee") + ) { + // Appeal-aware routing (t-paliad-307 / m/paliad#136 Bug 1). + // With no side picked, mirror to both columns so every rule + // is visible regardless of which side the user is on. With + // a side picked, route by (filer matches user) → ours + // column, else opponent column. side=claimant maps the + // user to "appellant" (Berufungskläger); side=defendant + // maps the user to "appellee" (Berufungsbeklagter). + if (userSide === null) { + row.ours.push(dl); + row.opponent.push(dl); + } else { + const userIsAppellant = userSide === "claimant"; + const filerIsAppellant = dl.appealRole === "appellant"; + row[filerIsAppellant === userIsAppellant ? "ours" : "opponent"].push(dl); + } } else if (appellantColumn !== null) { // Role-swap collapse: appellant initiated → both → one row // in appellant's column. Mirror suppressed. @@ -798,7 +962,11 @@ export function bucketDeadlinesIntoColumns( export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string { const userSide: Side = opts.side ?? null; - const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant }); + const rows = bucketDeadlinesIntoColumns(data.deadlines, { + side: userSide, + appellant: opts.appellant, + appealAware: opts.appealAware, + }); const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant"; const cardOpts: CardOpts = { @@ -806,6 +974,7 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts editable: opts.editable, showNotes: opts.showNotes, showDurations: opts.showDurations, + triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data), }; // Collapsed "both" rows lose their mirror tag — there's no longer