mAi: #88 - Verfahrensablauf: column axis reframed to user-perspective

Replaces the misleading Proaktiv/Reaktiv column pair with a static
"Unsere Seite" / "Gericht" / "Gegnerseite" axis ("WE always on the
left", per m's t-paliad-257 ask). The side toggle now drives row
PLACEMENT into the ours/opponent buckets — the column labels stay
truthful regardless of which physical party occupies them.

Old framing lied half the time: Klägerseite is sometimes proactive
(filing the claim) and sometimes reactive (responding to a CCR),
so "Proaktiv (Klägerseite)" was wrong whenever the user's perspective
flipped. New axis is purely positional with semantic labels.

Changes:

- frontend/src/client/views/verfahrensablauf-core.ts:
  • ColumnsRow fields proactive/reactive → ours/opponent.
  • renderColumnsBody picks static "Unsere Seite" / "Gegnerseite"
    labels — no more variant-by-side label keys.
  • bucketDeadlinesIntoColumns routes the user's party into `ours`
    when opts.side ∈ {"defendant"}; default (null) keeps the legacy
    "we are claimant" fallback so claimant-on-left layout survives.

- verfahrensablauf-core.test.ts: rewritten expectations on the new
  ours/opponent fields. Added two new tests pinning the WE-on-left
  semantics and the side+appellant interaction (side=defendant +
  appellant=claimant → "both" collapses into opponent).

- fristenrechner.ts: wires currentPerspective into renderColumnsBody
  as `side` so the columns honour the chip-strip perspective.
  Without this, a defendant-perspective user would see claimant
  filings under the "Unsere Seite" header — the old code didn't
  need the wire-up because the labels weren't perspective-aware.

- i18n.ts: replaces deadlines.col.proactive(.defendant) +
  deadlines.col.reactive(.claimant) with deadlines.col.ours +
  deadlines.col.opponent ("Unsere Seite"/"Client Side",
  "Gegnerseite"/"Opponent Side"). Court key unchanged.

- i18n-keys.ts: regenerated key union.

- global.css: .fr-col-proactive/.fr-col-reactive renamed to
  .fr-col-ours/.fr-col-opponent.

Out of scope (kept intact):
- Side and appellant URL-state plumbing.
- Appellant selector for Appeal-type proceedings (separate axis).
- Project-default side-from-our_side wiring — /tools/verfahrensablauf
  has no project context, and /tools/fristenrechner already does this
  via applyOurSidePredefine().

Build: bun run build clean (2794 keys), go build ./... clean.
Tests: 112 frontend tests pass (was 110, +2 new); all Go tests
cached green.
This commit is contained in:
mAi
2026-05-25 14:32:57 +02:00
parent 50cd80a4a6
commit a9a9adbd2a
6 changed files with 124 additions and 101 deletions

View File

@@ -429,8 +429,13 @@ function renderProcedureResults(data: DeadlineResponse) {
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
</div>`;
// 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;

View File

@@ -302,11 +302,9 @@ const translations: Record<Lang, Record<string, string>> = {
"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<Lang, Record<string, string>> = {
"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",

View File

@@ -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"],

View File

@@ -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) =>
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
// 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 = '<div class="fr-columns-view">';
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 += "</div>";
return html;

View File

@@ -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"

View File

@@ -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);
}