fix(litigationplanner): R.109.1/R.109.4 mis-anchor + duplicate 'both' row in columns view (t-paliad-304, m/paliad#135)

Two bugs surfaced on /tools/verfahrensablauf?side=defendant for upc.inf.cfi:

1. Anchor regression for timing='before' children of court-set parents.
   Rules R.109.1 (translation_request) and R.109.4 (interpreter_cost)
   anchor on the oral hearing (parent_id=upc.inf.cfi.oral, IsCourtSet)
   but were computing dates BEFORE the Statement of Claim — 1 month
   resp. 2 weeks before the SoC instead of before the oral hearing.

   Root cause: engine walked rules in sequence_order, and the two
   "before"-timed children carry sequence_order 45/46 (their chronological
   position, before the oral hearing at 50). Their parent had therefore
   not been processed yet when the children were, so courtSet[oral.ID]
   was still empty → parentIsCourtSet=false → the engine fell back to
   the trigger date as the base.

   Fix: walk rules in topological order (parent-first) during the
   compute pass, then restore sequence_order on the output slice so
   the wire shape and the linear timeline view's render order stay
   identical to the legacy behaviour modulo the bug fix.

2. Duplicate "Antrag auf Simultanübersetzung" row in columns view.
   With primary_party='both' and an explicit side pick (?side=defendant),
   the bucketing mirrored the card into both 'Unsere Seite' and
   'Gegnerseite' — the same card on the same row, visible as a
   duplicate.

   Fix: when the user has committed to a perspective (side picked)
   but no appellant axis applies, collapse 'both' rows into ours.
   The '↔ beide Seiten' indicator is suppressed in that path to match
   the existing appellant-collapse semantics (no sibling row to mirror
   to). Legacy mirror behaviour is preserved when side is null.

DB audit ruled out a data-level duplicate: exactly one published+active
row per submission_code in paliad.deadline_rules.

Tests:
  - pkg/litigationplanner/before_court_set_anchor_test.go: synthetic
    rules pinning the conditional-on-court-set-parent contract plus
    the override path (1mo before user-pinned oral).
  - frontend/src/client/views/verfahrensablauf-core.test.ts: two new
    cases pinning the side-collapse routing for party='both'.
This commit is contained in:
mAi
2026-05-26 15:54:02 +02:00
parent 16ec8c490a
commit 1d704f6e04
4 changed files with 458 additions and 7 deletions

View File

@@ -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([

View File

@@ -698,7 +698,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);
}
@@ -728,8 +739,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) {