diff --git a/frontend/src/client/fristenrechner.ts b/frontend/src/client/fristenrechner.ts index 9e13f53..2a08b55 100644 --- a/frontend/src/client/fristenrechner.ts +++ b/frontend/src/client/fristenrechner.ts @@ -429,8 +429,13 @@ function renderProcedureResults(data: DeadlineResponse) { ${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)} `; + // Pass the chip-strip perspective through as `side` so the column + // bucketer keeps the user's own party on the left (Unsere Seite) — + // t-paliad-257: the old Proaktiv/Reaktiv labels lied when the user + // was on the defendant side, the new labels demand we route the + // user's party into the `ours` column. const bodyHtml = procedureView === "columns" - ? renderColumnsBody(data, { editable: true, showNotes }) + ? renderColumnsBody(data, { editable: true, showNotes, side: currentPerspective }) : renderTimelineBody(data, { showParty: true, editable: true, showNotes }); container.innerHTML = headerHtml + bodyHtml; diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 3a502b1..5937979 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -302,11 +302,9 @@ const translations: Record> = { "deadlines.view.timeline": "Zeitstrahl", "deadlines.view.columns": "Spalten", "deadlines.notes.show": "Hinweise anzeigen", - "deadlines.col.proactive": "Proaktiv (Klägerseite)", - "deadlines.col.proactive.defendant": "Proaktiv (Beklagtenseite)", + "deadlines.col.ours": "Unsere Seite", "deadlines.col.court": "Gericht", - "deadlines.col.reactive": "Reaktiv (Beklagtenseite)", - "deadlines.col.reactive.claimant": "Reaktiv (Klägerseite)", + "deadlines.col.opponent": "Gegnerseite", "deadlines.col.both": "Beide Parteien", // Trigger-event mode (PR-2 \u2014 youpc-parity) "deadlines.mode.procedure": "Verfahrensablauf", @@ -3268,11 +3266,9 @@ const translations: Record> = { "deadlines.view.timeline": "Timeline", "deadlines.view.columns": "Columns", "deadlines.notes.show": "Show details", - "deadlines.col.proactive": "Proactive (Claimant side)", - "deadlines.col.proactive.defendant": "Proactive (Defendant side)", + "deadlines.col.ours": "Client Side", "deadlines.col.court": "Court", - "deadlines.col.reactive": "Reactive (Defendant side)", - "deadlines.col.reactive.claimant": "Reactive (Claimant side)", + "deadlines.col.opponent": "Opponent Side", "deadlines.col.both": "Both parties", "deadlines.adjusted": "Adjusted", "deadlines.adjusted.reason": "weekend/holiday", diff --git a/frontend/src/client/views/verfahrensablauf-core.test.ts b/frontend/src/client/views/verfahrensablauf-core.test.ts index 5c31e15..3e2c430 100644 --- a/frontend/src/client/views/verfahrensablauf-core.test.ts +++ b/frontend/src/client/views/verfahrensablauf-core.test.ts @@ -67,17 +67,20 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => { }); }); -// Pure column-routing behaviour pinned by m/paliad#81. Hits -// bucketDeadlinesIntoColumns directly so the assertions stay in -// pure-Node territory (renderColumnsBody goes through escHtml -> +// Pure column-routing behaviour. Originally pinned by m/paliad#81 +// (side + appellant axes), re-framed by m/paliad#88: the column +// axis is now "Unsere Seite vs Gegnerseite" ("WE always on the +// left") instead of the misleading Proaktiv/Reaktiv pair. +// Hits bucketDeadlinesIntoColumns directly so the assertions stay +// in pure-Node territory (renderColumnsBody goes through escHtml -> // document.createElement which isn't available in plain bun test). // // Scenario fixture mirrors the UPC Appeal "both parties" case m // pasted into #81: every filing rule carries party='both' so the -// legacy mirror path duplicates every row across proactive + -// reactive. With ?appellant= set, the duplicate must collapse to a -// single row in the appellant's column. -describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad#81)", () => { +// legacy mirror path duplicates every row across both columns. +// With ?appellant= set, the duplicate must collapse to a single +// row in the appellant's column. +describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad#81, #88)", () => { const both = (name: string, due: string): CalculatedDeadline => ({ code: name, name, @@ -96,39 +99,48 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad party, }); - test("default (no opts) mirrors 'both' rules into proactive AND reactive — legacy behaviour preserved", () => { + test("default (no opts) mirrors 'both' rules into ours AND opponent — legacy behaviour preserved", () => { const rows = bucketDeadlinesIntoColumns([both("Notice of Appeal", "2026-07-23")]); expect(rows).toHaveLength(1); - expect(rows[0].proactive.map((d) => d.name)).toEqual(["Notice of Appeal"]); - expect(rows[0].reactive.map((d) => d.name)).toEqual(["Notice of Appeal"]); + expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]); + expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]); expect(rows[0].court).toHaveLength(0); }); - test("appellant=claimant collapses 'both' rules into proactive only — no mirror", () => { + test("default (no side) places claimant on the left (ours) — 'we are claimant' fallback", () => { + const rows = bucketDeadlinesIntoColumns([ + partySpecific("claimant", "Klageschrift", "2026-01-01"), + partySpecific("defendant", "Klageerwiderung", "2026-04-01"), + ]); + expect(rows[0].ours.map((d) => d.name)).toEqual(["Klageschrift"]); + expect(rows[1].opponent.map((d) => d.name)).toEqual(["Klageerwiderung"]); + }); + + test("appellant=claimant collapses 'both' rules into ours when side=claimant (or default)", () => { const rows = bucketDeadlinesIntoColumns( [both("Notice of Appeal", "2026-07-23"), both("Statement of Grounds", "2026-09-23")], { appellant: "claimant" }, ); - expect(rows.map((r) => r.proactive.map((d) => d.name))).toEqual([ + expect(rows.map((r) => r.ours.map((d) => d.name))).toEqual([ ["Notice of Appeal"], ["Statement of Grounds"], ]); - rows.forEach((r) => expect(r.reactive).toHaveLength(0)); + rows.forEach((r) => expect(r.opponent).toHaveLength(0)); }); - test("appellant=defendant collapses 'both' rules into reactive only", () => { + test("appellant=defendant collapses 'both' rules into opponent when side=null/claimant", () => { const rows = bucketDeadlinesIntoColumns( [both("Notice of Appeal", "2026-07-23")], { appellant: "defendant" }, ); - expect(rows[0].proactive).toHaveLength(0); - expect(rows[0].reactive.map((d) => d.name)).toEqual(["Notice of Appeal"]); + expect(rows[0].ours).toHaveLength(0); + expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]); }); - test("side=defendant swaps which column owns claimant vs defendant rules", () => { - // claimant filing must land in REACTIVE (claimant is the opposing - // side from the defendant user's perspective), defendant filing in - // PROACTIVE. Court rules always go to court. + test("side=defendant flips which party owns 'ours' vs 'opponent' — WE always on the left", () => { + // User is on the defendant side: defendant filings land in 'ours' + // (left), claimant filings land in 'opponent' (right). Court rules + // stay in court regardless of side. const rows = bucketDeadlinesIntoColumns( [ partySpecific("claimant", "Klageschrift", "2026-01-01"), @@ -137,20 +149,33 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad ], { side: "defendant" }, ); - expect(rows[0].reactive.map((d) => d.name)).toEqual(["Klageschrift"]); - expect(rows[1].proactive.map((d) => d.name)).toEqual(["Klageerwiderung"]); + expect(rows[0].opponent.map((d) => d.name)).toEqual(["Klageschrift"]); + expect(rows[1].ours.map((d) => d.name)).toEqual(["Klageerwiderung"]); expect(rows[2].court.map((d) => d.name)).toEqual(["Urteil"]); }); - test("side=defendant + appellant=defendant routes 'both' into PROACTIVE (user's own column)", () => { + test("side=defendant + appellant=defendant routes 'both' into 'ours' (user's own column)", () => { // The user is the defendant AND the appellant, so the appellant's - // column == the user's own column == proactive after the swap. + // column == the user's own column == ours after the swap. const rows = bucketDeadlinesIntoColumns( [both("Notice of Appeal", "2026-07-23")], { side: "defendant", appellant: "defendant" }, ); - expect(rows[0].proactive.map((d) => d.name)).toEqual(["Notice of Appeal"]); - expect(rows[0].reactive).toHaveLength(0); + expect(rows[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]); + expect(rows[0].opponent).toHaveLength(0); + }); + + test("side=defendant + appellant=claimant routes 'both' into opponent (claimant ≠ us)", () => { + // Side flip + appellant axis combined: the claimant is the appellant + // but NOT us, so the collapsed 'both' row lands in the opponent + // column (right). This is the UPC Appeal "they appealed, we + // respond" scenario. + const rows = bucketDeadlinesIntoColumns( + [both("Notice of Appeal", "2026-07-23")], + { side: "defendant", appellant: "claimant" }, + ); + expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]); + expect(rows[0].ours).toHaveLength(0); }); test("rows align across columns by dueDate so same-day events stay on one grid row", () => { @@ -161,8 +186,8 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad partySpecific("court", "C", sameDate), ]); expect(rows).toHaveLength(1); - expect(rows[0].proactive.map((d) => d.name)).toEqual(["A"]); - expect(rows[0].reactive.map((d) => d.name)).toEqual(["B"]); + expect(rows[0].ours.map((d) => d.name)).toEqual(["A"]); + expect(rows[0].opponent.map((d) => d.name)).toEqual(["B"]); expect(rows[0].court.map((d) => d.name)).toEqual(["C"]); }); @@ -172,7 +197,7 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad partySpecific("claimant", "Statement of Claim", "2026-01-01"), partySpecific("court", "Decision", ""), ]); - expect(rows.map((r) => [r.proactive, r.court, r.reactive].flat().map((d) => d.name))).toEqual([ + expect(rows.map((r) => [r.ours, r.court, r.opponent].flat().map((d) => d.name))).toEqual([ ["Statement of Claim"], ["Oral Hearing"], ["Decision"], diff --git a/frontend/src/client/views/verfahrensablauf-core.ts b/frontend/src/client/views/verfahrensablauf-core.ts index f2f7e82..f6403ef 100644 --- a/frontend/src/client/views/verfahrensablauf-core.ts +++ b/frontend/src/client/views/verfahrensablauf-core.ts @@ -422,42 +422,47 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh return html; } -// Three-column timeline layout: Proactive | Court | Reactive. +// Three-column timeline layout: Unsere Seite | Gericht | Gegnerseite. // -// Column assignment per deadline (see m/paliad#81): +// The columns are user-perspective ("WE are always on the left", per +// t-paliad-257 / m/paliad#88). The old Proaktiv/Reaktiv axis lied: +// Klägerseite is sometimes proactive (filing the claim) and sometimes +// reactive (responding to a counterclaim), so the static "Proaktiv = +// Klägerseite" label-pair was wrong half the time. The new axis is +// "ours vs opponent" — the side toggle picks who WE are in this +// proceeding (Klägerseite vs Beklagtenseite, i.e. patentee vs alleged +// infringer / Einsprechender vs Patentinhaber, etc.), and rule +// placement re-resolves around that pick. // -// - party=claimant → proactive -// - party=defendant → reactive -// - party=court → court -// - party=both → BOTH proactive AND reactive (mirror). +// Column assignment per deadline (default opts.side === null keeps +// the legacy claimant-on-the-left layout — i.e. "we are claimant"): +// +// - party=claimant → ours when side ∈ {null,"claimant"}, else opponent +// - party=defendant → opponent when side ∈ {null,"claimant"}, else ours +// - party=court → court (independent of side) +// - party=both → BOTH ours AND opponent (mirror) // // When `opts.appellant` is set (claimant|defendant), "both" rows -// collapse to a single row in the appellant's column. The intent is -// role-swap proceedings (UPC Appeal, Counterclaim, …) where the -// "both" tag really means "either party files, depending on who -// initiated" — once you pick the initiator, the duplicate goes away. -// Hard rule from the issue: "When set, 'both parties' rows collapse -// to one row in the appellant's column." This is a UI projection -// only; the deadline_rules schema is unchanged. A follow-up issue -// can enrich per-rule role tagging so respondent-side filings -// (Response to Appeal, Cross-Appeal) land in the respondent's -// column — out of scope for #81. -// -// `opts.side` controls the column LABELS: side=defendant swaps the -// "Proactive (Klägerseite)" / "Reactive (Beklagtenseite)" headers -// so the user's own side is the proactive (= "your filings") column. -// It does NOT filter deadlines — the user still sees all deadlines -// in the proceeding. Default `side=null` keeps the legacy -// claimant-on-the-left layout. Unscheduled (court-set) rows trail -// the dated tail, each keyed by sequence-order so e.g. Urteil -// precedes Berufungseinlegung. +// collapse to a single row in the appellant's column — the intent is +// role-swap proceedings (UPC Appeal, Counterclaim, …) where "both" +// really means "either party files, depending on who initiated". +// Appellant axis is independent of `side`: in an Appeal CoA, the +// appellant selector pins which party appealed; the side toggle +// still picks which of those is us. export type Side = "claimant" | "defendant" | null; +// Internal column-position alias. "ours" is always rendered in the +// left grid column ("Unsere Seite"); "opponent" is always the right +// column ("Gegnerseite"). Field names mirror the labels so the +// bucketing primitive reads as a direct mapping. +type ColumnPosition = "ours" | "opponent"; + export interface ColumnsBodyOpts { editable?: boolean; showNotes?: boolean; - // side: which side the user is on. Drives column-label swap; - // does NOT filter rows. Default null = claimant-on-the-left. + // side: which side the user is on. Drives column placement; + // does NOT filter rows. Default null = claimant-on-the-left + // (i.e. "ours = claimant", legacy default). side?: Side; // appellant: which side initiated the appeal / counterclaim. // When set, party=both rows go to the appellant's column ONLY @@ -471,9 +476,9 @@ export interface ColumnsBodyOpts { // document.createElement (no jsdom in this repo). export interface ColumnsRow { key: string; - proactive: CalculatedDeadline[]; + ours: CalculatedDeadline[]; court: CalculatedDeadline[]; - reactive: CalculatedDeadline[]; + opponent: CalculatedDeadline[]; } export interface BucketingOpts { @@ -484,17 +489,20 @@ export interface BucketingOpts { // bucketDeadlinesIntoColumns is the pure routing primitive that // renderColumnsBody uses. Extracted as its own export so the per-row // column placement (including the side-swap + appellant-collapse -// logic from m/paliad#81) is unit-testable without a DOM. The -// returned rows are sorted: dated rows ascending by dueDate, then -// unscheduled rows in declaration order (each keyed by sequence). +// logic from m/paliad#81 and the user-perspective re-frame from +// m/paliad#88) is unit-testable without a DOM. The returned rows are +// sorted: dated rows ascending by dueDate, then unscheduled rows in +// declaration order (each keyed by sequence). export function bucketDeadlinesIntoColumns( deadlines: CalculatedDeadline[], opts: BucketingOpts = {}, ): ColumnsRow[] { const userSide: Side = opts.side ?? null; - const claimantColumn: "proactive" | "reactive" = userSide === "defendant" ? "reactive" : "proactive"; - const defendantColumn: "proactive" | "reactive" = claimantColumn === "proactive" ? "reactive" : "proactive"; - const appellantColumn: "proactive" | "reactive" | null = + // Default (side=null) treats the user as claimant — keeps the + // legacy claimant-on-the-left layout when no perspective is picked. + const claimantColumn: ColumnPosition = userSide === "defendant" ? "opponent" : "ours"; + const defendantColumn: ColumnPosition = claimantColumn === "ours" ? "opponent" : "ours"; + const appellantColumn: ColumnPosition | null = opts.appellant === "claimant" ? claimantColumn : opts.appellant === "defendant" ? defendantColumn : null; @@ -504,7 +512,7 @@ export function bucketDeadlinesIntoColumns( const ensureRow = (key: string): ColumnsRow => { let r = rowsMap.get(key); if (!r) { - r = { key, proactive: [], court: [], reactive: [] }; + r = { key, ours: [], court: [], opponent: [] }; rowsMap.set(key, r); } return r; @@ -529,8 +537,8 @@ export function bucketDeadlinesIntoColumns( // in appellant's column. Mirror suppressed. row[appellantColumn].push(dl); } else { - row.proactive.push(dl); - row.reactive.push(dl); + row.ours.push(dl); + row.opponent.push(dl); } break; default: @@ -552,17 +560,14 @@ 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 appellantColumn: "proactive" | "reactive" | null = - opts.appellant === "claimant" ? (userSide === "defendant" ? "reactive" : "proactive") - : opts.appellant === "defendant" ? (userSide === "defendant" ? "proactive" : "reactive") - : null; + const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant"; const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes }; // 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 = appellantColumn === null; + const showMirrorTag = !appellantPinned; const renderCell = (items: CalculatedDeadline[]): string => { if (items.length === 0) { @@ -585,25 +590,19 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts const headerCell = (label: string, cls: string) => `
${escHtml(label)}
`; - // Column-label swap when side=defendant: the user's own side stays - // labelled "Proaktiv" (their filings) and the opposing side is - // "Reaktiv". Default keeps the legacy claimant=proactive labels. - const proactiveLabel = userSide === "defendant" - ? t("deadlines.col.proactive.defendant") - : t("deadlines.col.proactive"); - const reactiveLabel = userSide === "defendant" - ? t("deadlines.col.reactive.claimant") - : t("deadlines.col.reactive"); - + // Static labels — "Unsere Seite" is always the left column, regardless + // of which physical party (claimant vs defendant) occupies it. The + // bucketing primitive already routes the user's side into the `ours` + // bucket, so the header truth-fully describes the column contents. let html = '
'; - html += headerCell(proactiveLabel, "fr-col-proactive"); + html += headerCell(t("deadlines.col.ours"), "fr-col-ours"); html += headerCell(t("deadlines.col.court"), "fr-col-court"); - html += headerCell(reactiveLabel, "fr-col-reactive"); + html += headerCell(t("deadlines.col.opponent"), "fr-col-opponent"); for (const row of rows) { - html += renderCell(row.proactive); + html += renderCell(row.ours); html += renderCell(row.court); - html += renderCell(row.reactive); + html += renderCell(row.opponent); } html += "
"; return html; diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 6ea860b..962e920 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -1142,10 +1142,8 @@ export type I18nKey = | "deadlines.col.court" | "deadlines.col.due" | "deadlines.col.event_type" - | "deadlines.col.proactive" - | "deadlines.col.proactive.defendant" - | "deadlines.col.reactive" - | "deadlines.col.reactive.claimant" + | "deadlines.col.opponent" + | "deadlines.col.ours" | "deadlines.col.rule" | "deadlines.col.status" | "deadlines.col.title" diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 345102f..c34ccf2 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -3629,7 +3629,7 @@ input[type="range"]::-moz-range-thumb { z-index: 1; } -.fr-col-header.fr-col-proactive { +.fr-col-header.fr-col-ours { background: var(--status-blue-bg); color: var(--status-blue-fg); } @@ -3639,7 +3639,7 @@ input[type="range"]::-moz-range-thumb { color: var(--status-blue-soft-fg); } -.fr-col-header.fr-col-reactive { +.fr-col-header.fr-col-opponent { background: var(--status-amber-bg); color: var(--status-amber-fg); }