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