diff --git a/frontend/src/client/views/verfahrensablauf-core.test.ts b/frontend/src/client/views/verfahrensablauf-core.test.ts index de63f7a..7e07870 100644 --- a/frontend/src/client/views/verfahrensablauf-core.test.ts +++ b/frontend/src/client/views/verfahrensablauf-core.test.ts @@ -327,6 +327,29 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad expect(rows[0].ours).toHaveLength(0); }); + test("side=defendant collapses 'both' rules into ours (no mirror) — m/paliad#135", () => { + // When the user has committed to a perspective via `?side=`, the + // mirror is visual noise: the same card renders twice on one row, + // once in 'Unsere Seite' and once in 'Gegnerseite'. The card's + // '↔ beide Seiten' indicator already conveys the both-parties + // semantic, so collapsing into ours is sufficient. + const rows = bucketDeadlinesIntoColumns( + [both("Antrag auf Simultanübersetzung", "2026-04-27")], + { side: "defendant" }, + ); + expect(rows[0].ours.map((d) => d.name)).toEqual(["Antrag auf Simultanübersetzung"]); + expect(rows[0].opponent).toHaveLength(0); + }); + + test("side=claimant collapses 'both' rules into ours (no mirror) — m/paliad#135", () => { + const rows = bucketDeadlinesIntoColumns( + [both("Antrag auf Simultanübersetzung", "2026-04-27")], + { side: "claimant" }, + ); + expect(rows[0].ours.map((d) => d.name)).toEqual(["Antrag auf Simultanübersetzung"]); + expect(rows[0].opponent).toHaveLength(0); + }); + test("rows align across columns by dueDate so same-day events stay on one grid row", () => { const sameDate = "2026-07-23"; const rows = bucketDeadlinesIntoColumns([ diff --git a/frontend/src/client/views/verfahrensablauf-core.ts b/frontend/src/client/views/verfahrensablauf-core.ts index 1727ee4..04e3174 100644 --- a/frontend/src/client/views/verfahrensablauf-core.ts +++ b/frontend/src/client/views/verfahrensablauf-core.ts @@ -764,7 +764,18 @@ export function bucketDeadlinesIntoColumns( // Role-swap collapse: appellant initiated → both → one row // in appellant's column. Mirror suppressed. row[appellantColumn].push(dl); + } else if (userSide !== null) { + // Side picked but no appellant axis (first-instance Inf, Rev, + // …): the user has committed to a perspective, so the mirror + // is visual noise — the same card appears twice on the same + // row, once in "Unsere Seite" and once in "Gegnerseite". + // Collapse into ours; the "↔ beide Seiten" indicator on the + // card already conveys that the rule applies to both parties. + // (m/paliad#135 / t-paliad-304) + row.ours.push(dl); } else { + // No perspective picked → keep the legacy mirror so neither + // axis is privileged. Pinned by the "default (no opts)" test. row.ours.push(dl); row.opponent.push(dl); } @@ -799,8 +810,14 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts // Collapsed "both" rows lose their mirror tag — there's no longer // a sibling row to mirror to, so the "↔ beide Seiten" hint would - // be misleading. Keep it for the legacy mirror path. - const showMirrorTag = !appellantPinned; + // be misleading. Both collapse paths suppress it: + // - appellantPinned: role-swap collapse into appellant's column + // - userSide !== null without appellantPinned: perspective-locked + // collapse into ours (m/paliad#135 / t-paliad-304). + // Legacy mirror path (no side, no appellant) keeps the tag — both + // sibling rows still render so the tag has a visual referent. + const sideCollapse = userSide !== null; + const showMirrorTag = !appellantPinned && !sideCollapse; const renderCell = (items: CalculatedDeadline[]): string => { if (items.length === 0) { diff --git a/pkg/litigationplanner/before_court_set_anchor_test.go b/pkg/litigationplanner/before_court_set_anchor_test.go new file mode 100644 index 0000000..5e92d8f --- /dev/null +++ b/pkg/litigationplanner/before_court_set_anchor_test.go @@ -0,0 +1,322 @@ +package litigationplanner + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/google/uuid" +) + +// Regression test for t-paliad-304 / m/paliad#135. +// +// Reproduces the R.109.1 / R.109.4 anchor bug on upc.inf.cfi: +// - Trigger (Klageerhebung): parent_id=nil, duration=0, !IsCourtSet, sequence_order=0 +// - Translation request: parent_id=oral, duration=1mo before, sequence_order=45 +// - Interpreter cost: parent_id=oral, duration=2w before, sequence_order=46 +// - Oral hearing: parent_id=nil, duration=0, IsCourtSet, sequence_order=50 +// +// The "before" children are listed BEFORE the oral hearing in sequence +// order (because chronologically they happen before it). The engine walks +// rules in sequence_order, so when it processes the translation/ +// interpreter rows, the oral hearing has not yet been processed → +// courtSet[oral.ID] is not yet set → parentIsCourtSet is false → the +// engine falls back to the trigger date as the base. Result: the timing= +// 'before' arithmetic produces 27.04.2026 (1mo before SoC) instead of +// the conditional-no-date treatment that a court-set parent should +// trigger. +// +// Expected post-fix: translation_request + interpreter_cost render as +// IsConditional (no concrete date) because their parent's date is +// court-set and the proceeding does not yet have an explicit override. + +// stubCatalog implements lp.Catalog backed by an in-memory rule slice. +// Only LoadProceeding is needed for the engine path under test; the +// other interface methods return errors so an unintended call surfaces +// immediately. +type stubCatalog struct { + pt ProceedingType + rules []Rule +} + +func (s *stubCatalog) LoadProceeding(_ context.Context, code string, _ ProjectHint) (*ProceedingType, []Rule, error) { + if code != s.pt.Code { + return nil, nil, ErrUnknownProceedingType + } + rules := make([]Rule, len(s.rules)) + copy(rules, s.rules) + pt := s.pt + return &pt, rules, nil +} +func (s *stubCatalog) LoadProceedingByID(_ context.Context, _ int) (*ProceedingType, error) { + return nil, errors.New("stubCatalog.LoadProceedingByID: not implemented") +} +func (s *stubCatalog) LoadRuleByID(_ context.Context, _ string) (*Rule, error) { + return nil, errors.New("stubCatalog.LoadRuleByID: not implemented") +} +func (s *stubCatalog) LoadRuleByCode(_ context.Context, _, _ string) (*Rule, *ProceedingType, error) { + return nil, nil, errors.New("stubCatalog.LoadRuleByCode: not implemented") +} +func (s *stubCatalog) LoadRulesByTriggerEvent(_ context.Context, _ int64) ([]Rule, error) { + return nil, nil +} +func (s *stubCatalog) LoadTriggerEventsByIDs(_ context.Context, _ []int64) (map[int64]TriggerEvent, error) { + return map[int64]TriggerEvent{}, nil +} +func (s *stubCatalog) LookupEvents(_ context.Context, _ EventLookupAxes, _ EventLookupDepth) ([]EventMatch, error) { + return nil, nil +} + +// noOpHolidays never adjusts dates — the test fixture doesn't care about +// weekends or holidays, only about which base date the engine resolves. +type noOpHolidays struct{} + +func (noOpHolidays) IsNonWorkingDay(_ time.Time, _, _ string) bool { return false } +func (noOpHolidays) AdjustForNonWorkingDays(d time.Time, _, _ string) (time.Time, time.Time, bool) { + return d, d, false +} +func (noOpHolidays) AdjustForNonWorkingDaysBackward(d time.Time, _, _ string) (time.Time, time.Time, bool) { + return d, d, false +} +func (noOpHolidays) AdjustForNonWorkingDaysWithReason(d time.Time, _, _ string) (time.Time, time.Time, bool, *AdjustmentReason) { + return d, d, false, nil +} + +type fixedCourts struct{} + +func (fixedCourts) CountryRegime(_, _, _ string) (string, string, error) { + return CountryDE, RegimeUPC, nil +} + +func TestCalculate_BeforeChildOfCourtSetParent_OutOfOrderSequence(t *testing.T) { + ctx := context.Background() + + // proceeding metadata + jurisdiction := "UPC" + procID := 1 + pt := ProceedingType{ + ID: procID, + Code: "upc.inf.cfi", + Name: "Verletzungsverfahren", + NameEN: "Infringement", + Jurisdiction: &jurisdiction, + IsActive: true, + } + + mkID := func() uuid.UUID { + id, _ := uuid.NewRandom() + return id + } + str := func(s string) *string { return &s } + procIDPtr := &procID + + socID := mkID() + oralID := mkID() + transID := mkID() + interpID := mkID() + + socCode := "upc.inf.cfi.soc" + oralCode := "upc.inf.cfi.oral" + transCode := "upc.inf.cfi.translation_request" + interpCode := "upc.inf.cfi.interpreter_cost" + + rules := []Rule{ + { + ID: socID, + ProceedingTypeID: procIDPtr, + ParentID: nil, + SubmissionCode: &socCode, + Name: "Klageerhebung", + NameEN: "Statement of Claim", + PrimaryParty: str("claimant"), + DurationValue: 0, + DurationUnit: "months", + Timing: str("after"), + SequenceOrder: 0, + IsCourtSet: false, + IsActive: true, + LifecycleState: "published", + Priority: "mandatory", + }, + // Translation request: sequence_order BEFORE the oral hearing. + // Reproduces the real corpus ordering (DB rows 45 < 50). + { + ID: transID, + ProceedingTypeID: procIDPtr, + ParentID: &oralID, + SubmissionCode: &transCode, + Name: "Antrag auf Simultanübersetzung", + NameEN: "Translation request", + PrimaryParty: str("both"), + DurationValue: 1, + DurationUnit: "months", + Timing: str("before"), + SequenceOrder: 45, + IsCourtSet: false, + IsActive: true, + LifecycleState: "published", + Priority: "optional", + }, + // Interpreter cost notice: sequence_order BEFORE the oral hearing. + { + ID: interpID, + ProceedingTypeID: procIDPtr, + ParentID: &oralID, + SubmissionCode: &interpCode, + Name: "Mitteilung Dolmetscherkosten", + NameEN: "Interpreter cost notice", + PrimaryParty: str("court"), + DurationValue: 2, + DurationUnit: "weeks", + Timing: str("before"), + SequenceOrder: 46, + IsCourtSet: false, + IsActive: true, + LifecycleState: "published", + Priority: "mandatory", + }, + // Oral hearing: court-set, no calculable date. Listed AFTER its + // "before"-timed children in sequence_order. + { + ID: oralID, + ProceedingTypeID: procIDPtr, + ParentID: nil, + SubmissionCode: &oralCode, + Name: "Mündliche Verhandlung", + NameEN: "Oral hearing", + PrimaryParty: str("court"), + DurationValue: 0, + DurationUnit: "months", + Timing: str("after"), + SequenceOrder: 50, + IsCourtSet: true, + IsActive: true, + LifecycleState: "published", + Priority: "mandatory", + }, + } + + cat := &stubCatalog{pt: pt, rules: rules} + + timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{}) + if err != nil { + t.Fatalf("Calculate: %v", err) + } + + byCode := make(map[string]TimelineEntry, len(timeline.Deadlines)) + for _, d := range timeline.Deadlines { + byCode[d.Code] = d + } + + // The trigger event itself is unambiguous. + if got := byCode[socCode]; got.DueDate != "2026-05-26" || !got.IsRootEvent { + t.Errorf("SoC: DueDate=%q IsRootEvent=%v, want 2026-05-26 + IsRootEvent=true", got.DueDate, got.IsRootEvent) + } + + // Oral hearing must surface as IsCourtSet (no date). + oral := byCode[oralCode] + if oral.DueDate != "" || !oral.IsCourtSet { + t.Errorf("oral: DueDate=%q IsCourtSet=%v, want empty + IsCourtSet=true", oral.DueDate, oral.IsCourtSet) + } + + // The two "before" children of the court-set oral hearing MUST surface + // as conditional rows (no date, no fabricated arithmetic off the + // trigger date). The buggy behaviour produces 2026-04-27 and 2026-05-12. + trans := byCode[transCode] + if trans.DueDate != "" { + t.Errorf("translation_request: DueDate=%q, want empty (parent oral is court-set, no anchor known yet)", trans.DueDate) + } + if !trans.IsConditional && !trans.IsCourtSet { + t.Errorf("translation_request: IsConditional=%v IsCourtSet=%v, want at least one true", trans.IsConditional, trans.IsCourtSet) + } + + interp := byCode[interpCode] + if interp.DueDate != "" { + t.Errorf("interpreter_cost: DueDate=%q, want empty (parent oral is court-set, no anchor known yet)", interp.DueDate) + } + if !interp.IsConditional && !interp.IsCourtSet { + t.Errorf("interpreter_cost: IsConditional=%v IsCourtSet=%v, want at least one true", interp.IsConditional, interp.IsCourtSet) + } +} + +// TestCalculate_BeforeChildOfCourtSetParent_WithOverride pins the +// override semantics: when the user supplies an anchor override for +// the court-set parent, the "before" children should compute against +// that override date instead of remaining conditional. +func TestCalculate_BeforeChildOfCourtSetParent_WithOverride(t *testing.T) { + ctx := context.Background() + + jurisdiction := "UPC" + procID := 1 + pt := ProceedingType{ + ID: procID, + Code: "upc.inf.cfi", + Name: "Verletzungsverfahren", + Jurisdiction: &jurisdiction, + IsActive: true, + } + + mkID := func() uuid.UUID { + id, _ := uuid.NewRandom() + return id + } + str := func(s string) *string { return &s } + procIDPtr := &procID + + socID := mkID() + oralID := mkID() + transID := mkID() + + socCode := "upc.inf.cfi.soc" + oralCode := "upc.inf.cfi.oral" + transCode := "upc.inf.cfi.translation_request" + + rules := []Rule{ + { + ID: socID, ProceedingTypeID: procIDPtr, ParentID: nil, + SubmissionCode: &socCode, Name: "Klageerhebung", NameEN: "SoC", + PrimaryParty: str("claimant"), DurationValue: 0, DurationUnit: "months", Timing: str("after"), + SequenceOrder: 0, IsActive: true, LifecycleState: "published", Priority: "mandatory", + }, + { + ID: transID, ProceedingTypeID: procIDPtr, ParentID: &oralID, + SubmissionCode: &transCode, Name: "Antrag auf Simultanübersetzung", NameEN: "Translation request", + PrimaryParty: str("both"), DurationValue: 1, DurationUnit: "months", Timing: str("before"), + SequenceOrder: 45, IsActive: true, LifecycleState: "published", Priority: "optional", + }, + { + ID: oralID, ProceedingTypeID: procIDPtr, ParentID: nil, + SubmissionCode: &oralCode, Name: "Mündliche Verhandlung", NameEN: "Oral hearing", + PrimaryParty: str("court"), DurationValue: 0, DurationUnit: "months", Timing: str("after"), + SequenceOrder: 50, IsCourtSet: true, IsActive: true, LifecycleState: "published", Priority: "mandatory", + }, + } + + cat := &stubCatalog{pt: pt, rules: rules} + + // User pins the oral hearing to 2026-10-15. + opts := CalcOptions{ + AnchorOverrides: map[string]string{ + oralCode: "2026-10-15", + }, + } + timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{}) + if err != nil { + t.Fatalf("Calculate: %v", err) + } + + byCode := make(map[string]TimelineEntry, len(timeline.Deadlines)) + for _, d := range timeline.Deadlines { + byCode[d.Code] = d + } + + if got := byCode[oralCode].DueDate; got != "2026-10-15" { + t.Errorf("oral: DueDate=%q, want 2026-10-15 (user override)", got) + } + + // 1 month before 2026-10-15 = 2026-09-15 + if got := byCode[transCode].DueDate; got != "2026-09-15" { + t.Errorf("translation_request: DueDate=%q, want 2026-09-15 (1 month before oral override)", got) + } +} diff --git a/pkg/litigationplanner/engine.go b/pkg/litigationplanner/engine.go index 16905e5..16a550d 100644 --- a/pkg/litigationplanner/engine.go +++ b/pkg/litigationplanner/engine.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "sort" "time" "github.com/google/uuid" @@ -183,10 +184,27 @@ func Calculate( return nil, fmt.Errorf("load trigger events for conditional labels: %w", err) } - // Walk the rule list in sequence_order (already sorted by the - // catalog query) and compute each entry, keeping a code→date map so - // RelativeTo / parent_id references resolve to the adjusted - // predecessor date. + // Walk the rule list in TOPOLOGICAL order (parents before children), + // not the raw sequence_order order from the catalog. The catalog + // returns rules sorted by sequence_order, which is the chronological/ + // display order. That order is parent-first for the common + // timing='after' case but parent-LAST for timing='before' children + // (e.g. upc.inf.cfi.translation_request at seq=45 vs its parent + // upc.inf.cfi.oral at seq=50 — m/paliad#135). Without topological + // ordering the parent-state checks below (courtSet[parent] / + // computed[parent_code]) read stale empty maps when a child appears + // before its parent, and the engine falls back to the trigger date + // → fabricates dates before the SoC. + // + // Original sequence_order is restored at the end of the walk so the + // wire shape and the timeline view's render order stay identical to + // the legacy behaviour modulo the bug fix. + sequenceIndex := make(map[uuid.UUID]int, len(rules)) + for i, r := range rules { + sequenceIndex[r.ID] = i + } + walkRules := topoSortByParentDepth(rules) + computed := make(map[string]time.Time, len(rules)) courtSet := make(map[uuid.UUID]bool, len(rules)) deadlines := make([]TimelineEntry, 0, len(rules)) @@ -197,7 +215,7 @@ func Calculate( hiddenCount := 0 appellantContext := make(map[uuid.UUID]string, len(rules)) - for _, r := range rules { + for _, r := range walkRules { // Phase-3 unified gate: evaluate condition_expr (jsonb). // Suppression semantic preserved: when the gate fires false // AND no alt_* values exist, the rule is dropped from the @@ -554,6 +572,20 @@ func Calculate( deadlines = append(deadlines, d) } + // 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 + // view both rely on sequence_order being the surface render order. + // (m/paliad#135) + sort.SliceStable(deadlines, func(i, j int) bool { + a, errA := uuid.Parse(deadlines[i].RuleID) + b, errB := uuid.Parse(deadlines[j].RuleID) + if errA != nil || errB != nil { + return false + } + return sequenceIndex[a] < sequenceIndex[b] + }) + // t-paliad-296: within consecutive runs of rules sharing the same // trigger group (parent_id + trigger_event_id), reorder by duration // ascending so optional events following the same anchor render in @@ -950,3 +982,60 @@ func AllFlagsSet(required []string, set map[string]struct{}) bool { func WireFlagsFromPriority(priority string) (isMandatory, isOptional bool) { return wireFlagsFromPriority(priority) } + +// topoSortByParentDepth returns a copy of `rules` ordered so every rule +// appears after its parent_id ancestor. Ties (rules at the same depth) +// preserve their input order — which the catalog returns in +// sequence_order. Used by Calculate to ensure the parent-state checks +// (courtSet[parent], computed[parent_code]) see populated entries even +// when sequence_order lists a "before"-timed child BEFORE its parent +// (e.g. upc.inf.cfi.translation_request at seq=45 with parent +// upc.inf.cfi.oral at seq=50 — m/paliad#135). +// +// Rules whose parent_id is missing from the rule slice (cross-tree +// references that the per-proceeding filter dropped) are treated as +// depth 0 — they walk in their original sequence position. +// +// The algorithm is depth-via-memoised-recursion. Cycle protection: a +// rule chain that revisits a node is broken at depth 0; production +// data shouldn't contain cycles, but a corrupted catalog mustn't hang +// the calculator. +func topoSortByParentDepth(rules []Rule) []Rule { + byID := make(map[uuid.UUID]Rule, len(rules)) + inSlice := make(map[uuid.UUID]bool, len(rules)) + for _, r := range rules { + byID[r.ID] = r + inSlice[r.ID] = true + } + + depth := make(map[uuid.UUID]int, len(rules)) + var resolve func(id uuid.UUID, seen map[uuid.UUID]bool) int + resolve = func(id uuid.UUID, seen map[uuid.UUID]bool) int { + if d, ok := depth[id]; ok { + return d + } + if seen[id] { + depth[id] = 0 + return 0 + } + seen[id] = true + r, ok := byID[id] + if !ok || r.ParentID == nil || !inSlice[*r.ParentID] { + depth[id] = 0 + return 0 + } + d := resolve(*r.ParentID, seen) + 1 + depth[id] = d + return d + } + for _, r := range rules { + resolve(r.ID, map[uuid.UUID]bool{}) + } + + out := make([]Rule, len(rules)) + copy(out, rules) + sort.SliceStable(out, func(i, j int) bool { + return depth[out[i].ID] < depth[out[j].ID] + }) + return out +}