> = {
"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) =>
``;
- // 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);
}