Merge: t-paliad-301 — Berufung tile UX: collapse side selectors + appeal-target trigger labels (mig 137) (m/paliad#132)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

This commit is contained in:
mAi
2026-05-26 15:37:51 +02:00
10 changed files with 377 additions and 93 deletions

View File

@@ -462,10 +462,6 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.side.from_project": "Aus Akte:", "deadlines.side.from_project": "Aus Akte:",
"deadlines.side.override": "Andere Seite wählen", "deadlines.side.override": "Andere Seite wählen",
"deadlines.side.hint": "Wählen Sie eine Seite, um die Spalten zu fokussieren.", "deadlines.side.hint": "Wählen Sie eine Seite, um die Spalten zu fokussieren.",
"deadlines.appellant.label": "Berufung durch:",
"deadlines.appellant.claimant": "Klägerseite",
"deadlines.appellant.defendant": "Beklagtenseite",
"deadlines.appellant.none": "—",
"deadlines.event.composite.label": "Zusammengesetzt:", "deadlines.event.composite.label": "Zusammengesetzt:",
"deadlines.event.unit.days.one": "Tag", "deadlines.event.unit.days.one": "Tag",
"deadlines.event.unit.days.many": "Tage", "deadlines.event.unit.days.many": "Tage",
@@ -3567,10 +3563,6 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.side.from_project": "From case:", "deadlines.side.from_project": "From case:",
"deadlines.side.override": "Choose other side", "deadlines.side.override": "Choose other side",
"deadlines.side.hint": "Pick a side to focus the columns.", "deadlines.side.hint": "Pick a side to focus the columns.",
"deadlines.appellant.label": "Appeal filed by:",
"deadlines.appellant.claimant": "Claimant",
"deadlines.appellant.defendant": "Defendant",
"deadlines.appellant.none": "—",
"deadlines.event.composite.label": "Composite:", "deadlines.event.composite.label": "Composite:",
"deadlines.event.unit.days.one": "day", "deadlines.event.unit.days.one": "day",
"deadlines.event.unit.days.many": "days", "deadlines.event.unit.days.many": "days",

View File

@@ -32,18 +32,20 @@ import {
let selectedType = ""; let selectedType = "";
let lastResponse: DeadlineResponse | null = null; let lastResponse: DeadlineResponse | null = null;
// Perspective state (t-paliad-250 / m/paliad#81). URL-driven so the // Perspective state. URL-driven so the view is shareable + survives
// view is shareable and survives reload: // reload:
// ?side=claimant|defendant swaps which column owns the user's // ?side=claimant|defendant swaps which column owns the user's
// side (proactive vs reactive label). // side (proactive vs reactive label).
// Default null = claimant-on-the-left. // Default null = claimant-on-the-left.
// ?appellant=claimant|defendant → collapses party=both rows into the //
// appellant's column (no mirror). // t-paliad-301 / m/paliad#132 collapsed the duplicate ?side= +
// Only meaningful for role-swap // ?appellant= selectors into the single proactive-side picker above.
// proceedings (Appeal etc.). Default // For role-swap proceedings (Appeal / EPA Opposition / DE Revision /
// null = legacy mirror behaviour. // DPMA Appeal) the picker's labels swap to per-proceeding role
// strings (Berufungskläger / Berufungsbeklagter, …) via ROLE_LABELS
// below — but the underlying claimant/defendant value the engine
// consumes is unchanged.
let currentSide: Side = null; let currentSide: Side = null;
let currentAppellant: Side = null;
// Project-driven auto-fill state (t-paliad-279 / m/paliad#111). When the // Project-driven auto-fill state (t-paliad-279 / m/paliad#111). When the
// page is opened with ?project=<id> and that project has our_side set, // page is opened with ?project=<id> and that project has our_side set,
@@ -52,17 +54,13 @@ let currentAppellant: Side = null;
// link, which clears this flag (radio cluster takes over again). // link, which clears this flag (radio cluster takes over again).
let sidePrefilledFromProject = false; let sidePrefilledFromProject = false;
// Proceedings where one party initiates and "both" rows are role-swap // Role-swap proceedings — the side picker doubles as the appellant
// (i.e. either party files depending on who acted at the lower // axis. After t-paliad-301 collapsed the duplicate selectors, the
// instance). For these proceedings the appellant selector is meaningful // engine reads "appellant" from the single side value for these
// — when set, "both" rows collapse to a single row in the appellant's // proceedings (so a row with primary_party=both renders only in the
// column. For first-instance proceedings (Inf, Rev, …) the selector is // chosen side's column). For first-instance proceedings (Inf, Rev,
// hidden because there's no appellant axis. // …) the side picker still narrows columns but doesn't collapse
// // the "both" rows.
// Today: every upc.apl.* family member plus dpma.appeal.* and
// de.inf.olg / de.inf.bgh / de.null.bgh (DE Berufung / Revision).
// Conservative — false negatives just hide a control; false positives
// would show an irrelevant control.
const APPELLANT_AXIS_PROCEEDINGS = new Set([ const APPELLANT_AXIS_PROCEEDINGS = new Set([
"upc.apl.unified", "upc.apl.unified",
"de.inf.olg", "de.inf.olg",
@@ -73,6 +71,44 @@ const APPELLANT_AXIS_PROCEEDINGS = new Set([
"epa.opp.boa", "epa.opp.boa",
]); ]);
// Per-proceeding role labels (t-paliad-301 / m/paliad#132 Bug A).
// Mirrors paliad.proceeding_types.role_*_label_* — the canonical
// definition lives in the DB; this map is the frontend's view of
// it. Proceedings absent from the map fall back to the generic
// "deadlines.side.claimant" / "deadlines.side.defendant" i18n keys.
//
// Keep in sync with mig 137's backfill. Adding a row here without a
// matching DB row is fine (the DB col is NULL → still falls back to
// default; UI shows the override). Adding to the DB without here
// means the UI uses defaults — harmless but inconsistent.
type RoleLabels = { proDE: string; reDE: string; proEN: string; reEN: string };
const ROLE_LABELS: Record<string, RoleLabels> = {
"upc.apl.unified": {
proDE: "Berufungskläger",
reDE: "Berufungsbeklagter",
proEN: "Appellant",
reEN: "Appellee",
},
"upc.rev.cfi": {
proDE: "Antragsteller (Nichtigkeit)",
reDE: "Antragsgegner (Nichtigkeit)",
proEN: "Revocation claimant",
reEN: "Revocation defendant",
},
"epa.opp.opd": {
proDE: "Einsprechende(r)",
reDE: "Patentinhaber(in)",
proEN: "Opponent",
reEN: "Patentee",
},
"epa.opp.boa": {
proDE: "Einsprechende(r)",
reDE: "Patentinhaber(in)",
proEN: "Opponent",
reEN: "Patentee",
},
};
// Slice B1 (m/paliad#124 §18.1) — Berufung unification. // Slice B1 (m/paliad#124 §18.1) — Berufung unification.
// Proceedings that surface the appeal-target chip group. Currently // Proceedings that surface the appeal-target chip group. Currently
// only the unified upc.apl proceeding; future variants (e.g. de.apl) // only the unified upc.apl proceeding; future variants (e.g. de.apl)
@@ -105,11 +141,6 @@ function readSideFromURL(): Side {
return raw === "claimant" || raw === "defendant" ? raw : null; return raw === "claimant" || raw === "defendant" ? raw : null;
} }
function readAppellantFromURL(): Side {
const raw = new URLSearchParams(window.location.search).get("appellant");
return raw === "claimant" || raw === "defendant" ? raw : null;
}
function writeSideToURL(s: Side) { function writeSideToURL(s: Side) {
const url = new URL(window.location.href); const url = new URL(window.location.href);
if (s === null) url.searchParams.delete("side"); if (s === null) url.searchParams.delete("side");
@@ -117,11 +148,31 @@ function writeSideToURL(s: Side) {
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash); window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
} }
function writeAppellantToURL(a: Side) { // t-paliad-301 / m/paliad#132: applies ROLE_LABELS to the side-row
const url = new URL(window.location.href); // radio labels for the currently selected proceeding. Proceedings
if (a === null) url.searchParams.delete("appellant"); // without an entry fall back to the existing
else url.searchParams.set("appellant", a); // "deadlines.side.claimant" / "deadlines.side.defendant" i18n keys.
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash); function applyRoleLabels(proceedingType: string) {
const lang = getLang() === "en" ? "en" : "de";
const claimantSpan = document.querySelector<HTMLElement>(
"input[type=radio][name=side][value=claimant] + span"
);
const defendantSpan = document.querySelector<HTMLElement>(
"input[type=radio][name=side][value=defendant] + span"
);
if (!claimantSpan || !defendantSpan) return;
const labels = ROLE_LABELS[proceedingType];
if (labels) {
claimantSpan.textContent = lang === "en" ? labels.proEN : labels.proDE;
defendantSpan.textContent = lang === "en" ? labels.reEN : labels.reDE;
} else {
// Default — let i18n drive via data-i18n attribute. Reset to the
// canonical i18n value so a previous override doesn't stick when
// switching from upc.apl.unified back to upc.inf.cfi.
claimantSpan.textContent = t("deadlines.side.claimant");
defendantSpan.textContent = t("deadlines.side.defendant");
}
} }
// Slice B1 — appeal-target URL state. Empty string = no target picked // Slice B1 — appeal-target URL state. Empty string = no target picked
@@ -432,7 +483,12 @@ function renderResults(data: DeadlineResponse) {
editable: true, editable: true,
showNotes, showNotes,
side: currentSide, side: currentSide,
appellant: hasAppellantAxis(selectedType) ? currentAppellant : null, // t-paliad-301: the appellant axis collapses into the single
// side picker. For role-swap proceedings, currentSide IS the
// appellant pick (so a row with primary_party=both renders only
// in the picked side's column). For non-role-swap proceedings,
// the appellant axis is irrelevant — pass null.
appellant: hasAppellantAxis(selectedType) ? currentSide : null,
}) })
: renderTimelineBody(data, { showParty: true, editable: true, showNotes }); : renderTimelineBody(data, { showParty: true, editable: true, showNotes });
@@ -501,8 +557,8 @@ function selectProceeding(btn: HTMLButtonElement) {
void populateCourtPicker("court-picker-row", "court-picker", selectedType); void populateCourtPicker("court-picker-row", "court-picker", selectedType);
syncFlagRows(); syncFlagRows();
syncAppellantRowVisibility();
syncAppealTargetRowVisibility(); syncAppealTargetRowVisibility();
applyRoleLabels(selectedType);
setProceedingPickerCollapsed(true, proceedingDisplayName(btn)); setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
@@ -510,23 +566,6 @@ function selectProceeding(btn: HTMLButtonElement) {
scheduleCalc(0); scheduleCalc(0);
} }
// syncAppellantRowVisibility hides the appellant selector for
// proceedings that have no appellant axis (first-instance Inf, Rev,
// …). Clears the in-memory state and the URL param when hidden so a
// shared link with ?appellant= doesn't leak into an unrelated
// proceeding's render.
function syncAppellantRowVisibility() {
const row = document.getElementById("appellant-row");
if (!row) return;
const visible = hasAppellantAxis(selectedType);
row.style.display = visible ? "" : "none";
if (!visible && currentAppellant !== null) {
currentAppellant = null;
writeAppellantToURL(null);
syncRadioGroup("appellant", "");
}
}
// Slice B1 (m/paliad#124 §18.1) — Berufung unification. // Slice B1 (m/paliad#124 §18.1) — Berufung unification.
// syncAppealTargetRowVisibility shows the appeal-target chip group // syncAppealTargetRowVisibility shows the appeal-target chip group
// when the unified upc.apl Berufung tile is selected, hides it // when the unified upc.apl Berufung tile is selected, hides it
@@ -727,10 +766,8 @@ function initViewToggle() {
// projection of the last response, no backend involved. // projection of the last response, no backend involved.
function initPerspectiveControls() { function initPerspectiveControls() {
currentSide = readSideFromURL(); currentSide = readSideFromURL();
currentAppellant = readAppellantFromURL();
currentAppealTarget = readAppealTargetFromURL(); currentAppealTarget = readAppealTargetFromURL();
syncRadioGroup("side", currentSide ?? ""); syncRadioGroup("side", currentSide ?? "");
syncRadioGroup("appellant", currentAppellant ?? "");
syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung"); syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung");
syncSideHintVisibility(); syncSideHintVisibility();
@@ -745,16 +782,6 @@ function initPerspectiveControls() {
}); });
}); });
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appellant]").forEach((input) => {
input.addEventListener("change", () => {
if (!input.checked) return;
const v = input.value;
currentAppellant = (v === "claimant" || v === "defendant") ? v : null;
writeAppellantToURL(currentAppellant);
if (lastResponse) renderResults(lastResponse);
});
});
// Slice B1 (m/paliad#124 §18.1) — appeal-target chip handler. // Slice B1 (m/paliad#124 §18.1) — appeal-target chip handler.
// Each chip change re-fetches with the new target slug so the // Each chip change re-fetches with the new target slug so the
// timeline re-renders against the matching rule subset. // timeline re-renders against the matching rule subset.

View File

@@ -1190,10 +1190,6 @@ export type I18nKey =
| "deadlines.appeal_target.kostenentscheidung" | "deadlines.appeal_target.kostenentscheidung"
| "deadlines.appeal_target.label" | "deadlines.appeal_target.label"
| "deadlines.appeal_target.schadensbemessung" | "deadlines.appeal_target.schadensbemessung"
| "deadlines.appellant.claimant"
| "deadlines.appellant.defendant"
| "deadlines.appellant.label"
| "deadlines.appellant.none"
| "deadlines.calculate" | "deadlines.calculate"
| "deadlines.card.calc.add_to_project" | "deadlines.card.calc.add_to_project"
| "deadlines.card.calc.add_to_project.disabled" | "deadlines.card.calc.add_to_project.disabled"

View File

@@ -250,23 +250,6 @@ export function renderVerfahrensablauf(): string {
</label> </label>
</div> </div>
</div> </div>
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
<label className="fristen-view-option">
<input type="radio" name="appellant" value="claimant" />
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appellant" value="defendant" />
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appellant" value="" checked />
<span data-i18n="deadlines.appellant.none"></span>
</label>
</div>
</div>
{/* Show-hidden toggle (t-paliad-290 / m/paliad#122). {/* Show-hidden toggle (t-paliad-290 / m/paliad#122).
Re-surfaces optional cards the user has previously Re-surfaces optional cards the user has previously
marked "Überspringen" via the per-card popover. marked "Überspringen" via the per-card popover.

View File

@@ -0,0 +1,18 @@
-- 137_proceeding_role_labels — DOWN
--
-- Drops the 4 role-label columns. Backfilled data is lost on
-- down-migration; that's acceptable because the frontend renderer
-- falls back to the default labels ("Klägerseite" / "Beklagtenseite")
-- when the columns are absent.
ALTER TABLE paliad.proceeding_types
DROP COLUMN IF EXISTS role_reactive_label_en;
ALTER TABLE paliad.proceeding_types
DROP COLUMN IF EXISTS role_reactive_label_de;
ALTER TABLE paliad.proceeding_types
DROP COLUMN IF EXISTS role_proactive_label_en;
ALTER TABLE paliad.proceeding_types
DROP COLUMN IF EXISTS role_proactive_label_de;

View File

@@ -0,0 +1,137 @@
-- 137_proceeding_role_labels — t-paliad-301, m/paliad#132
--
-- Bug A fix: per-proceeding role labels so the Verfahrensablauf side
-- selector can render "Berufungskläger / Berufungsbeklagter" for the
-- unified UPC Berufung tile instead of the generic "Klägerseite /
-- Beklagtenseite".
--
-- Four new optional columns on paliad.proceeding_types. NULL on a
-- column falls back to the language-default ("Klägerseite" / "Claimant
-- side" / "Beklagtenseite" / "Defendant side") in the frontend renderer.
-- Only the proceedings whose role-naming actually differs get a backfill.
--
-- Live-DB audit (mcp__supabase__execute_sql) before drafting:
-- - paliad.proceeding_types has 14 columns; the 4 target columns do
-- NOT exist (zero name collisions).
-- - Zero triggers on paliad.proceeding_types. No audit_reason
-- setup needed.
-- - No updated_at / created_at on the table — DO NOT include
-- timestamp UPDATEs (lesson from mig 134 HOTFIX 3).
--
-- ADDITIVE ONLY. ALTER + UPDATE statements; no CHECK constraints
-- (the columns are free-text labels, validated at the application layer).
-- Down migration drops the 4 columns.
--
-- See m/paliad#132 for the full design rationale + the role-label
-- matrix per proceeding code.
-- ---------------------------------------------------------------
-- 1. Schema additions
-- ---------------------------------------------------------------
ALTER TABLE paliad.proceeding_types
ADD COLUMN role_proactive_label_de text NULL;
ALTER TABLE paliad.proceeding_types
ADD COLUMN role_proactive_label_en text NULL;
ALTER TABLE paliad.proceeding_types
ADD COLUMN role_reactive_label_de text NULL;
ALTER TABLE paliad.proceeding_types
ADD COLUMN role_reactive_label_en text NULL;
COMMENT ON COLUMN paliad.proceeding_types.role_proactive_label_de IS
'DE label for the proactive (claimant-equivalent) side of this '
'proceeding. NULL = renderer falls back to "Klägerseite". '
't-paliad-301 / m/paliad#132 Bug A.';
COMMENT ON COLUMN paliad.proceeding_types.role_proactive_label_en IS
'EN label for the proactive side. NULL = "Claimant side".';
COMMENT ON COLUMN paliad.proceeding_types.role_reactive_label_de IS
'DE label for the reactive (defendant-equivalent) side. NULL = '
'"Beklagtenseite".';
COMMENT ON COLUMN paliad.proceeding_types.role_reactive_label_en IS
'EN label for the reactive side. NULL = "Defendant side".';
-- ---------------------------------------------------------------
-- 2. Audit-first NOTICE pass.
--
-- Lists which proceeding_types are about to receive a backfill so
-- the operator sees the scope before the UPDATE fires. NULL columns
-- on every other row stay NULL (the frontend falls back to defaults).
-- ---------------------------------------------------------------
DO $$
DECLARE
rec record;
backfill_count int := 0;
BEGIN
RAISE NOTICE '[mig 137] Proceedings that will receive role-label backfill:';
FOR rec IN
SELECT code, name
FROM paliad.proceeding_types
WHERE code IN ('upc.apl.unified', 'upc.rev.cfi', 'epa.opp.opd', 'epa.opp.boa')
ORDER BY code
LOOP
RAISE NOTICE '[mig 137] % %', rec.code, rec.name;
backfill_count := backfill_count + 1;
END LOOP;
RAISE NOTICE '[mig 137] Total: % proceedings (others stay NULL → renderer default)', backfill_count;
END $$;
-- ---------------------------------------------------------------
-- 3. Backfill.
--
-- Per the design matrix in m/paliad#132:
-- - upc.apl.unified → Berufungskläger / Berufungsbeklagter / Appellant / Appellee
-- - upc.rev.cfi → Antragsteller (Nichtigkeit) / Antragsgegner (Nichtigkeit) /
-- Revocation claimant / Revocation defendant
-- - epa.opp.opd → Einsprechende(r) / Patentinhaber(in) /
-- Opponent / Patentee
-- - epa.opp.boa → Einsprechende(r) / Patentinhaber(in) /
-- Opponent / Patentee
-- - (others) → stay NULL → frontend defaults
-- ---------------------------------------------------------------
UPDATE paliad.proceeding_types
SET role_proactive_label_de = 'Berufungskläger',
role_reactive_label_de = 'Berufungsbeklagter',
role_proactive_label_en = 'Appellant',
role_reactive_label_en = 'Appellee'
WHERE code = 'upc.apl.unified';
UPDATE paliad.proceeding_types
SET role_proactive_label_de = 'Antragsteller (Nichtigkeit)',
role_reactive_label_de = 'Antragsgegner (Nichtigkeit)',
role_proactive_label_en = 'Revocation claimant',
role_reactive_label_en = 'Revocation defendant'
WHERE code = 'upc.rev.cfi';
UPDATE paliad.proceeding_types
SET role_proactive_label_de = 'Einsprechende(r)',
role_reactive_label_de = 'Patentinhaber(in)',
role_proactive_label_en = 'Opponent',
role_reactive_label_en = 'Patentee'
WHERE code IN ('epa.opp.opd', 'epa.opp.boa');
-- ---------------------------------------------------------------
-- 4. Post-migration NOTICE — informational only.
-- ---------------------------------------------------------------
DO $$
DECLARE
rec record;
BEGIN
RAISE NOTICE '[mig 137] post: backfilled role-label distribution:';
FOR rec IN
SELECT code,
role_proactive_label_de,
role_reactive_label_de
FROM paliad.proceeding_types
WHERE role_proactive_label_de IS NOT NULL
ORDER BY code
LOOP
RAISE NOTICE '[mig 137] % proactive=% reactive=%',
rec.code, rec.role_proactive_label_de, rec.role_reactive_label_de;
END LOOP;
END $$;

View File

@@ -40,7 +40,9 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction, const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
category, default_color, sort_order, is_active, category, default_color, sort_order, is_active,
trigger_event_label_de, trigger_event_label_en, trigger_event_label_de, trigger_event_label_en,
appeal_target` appeal_target,
role_proactive_label_de, role_proactive_label_en,
role_reactive_label_de, role_reactive_label_en`
// List returns active rules, optionally filtered by proceeding type. // List returns active rules, optionally filtered by proceeding type.
// Each row has ConceptDefaultEventTypeID hydrated from // Each row has ConceptDefaultEventTypeID hydrated from

View File

@@ -0,0 +1,55 @@
package litigationplanner
import "testing"
// TestTriggerEventLabelForAppealTarget pins the per-target trigger-
// event label matrix (t-paliad-301 / m/paliad#132 Bug B). The 5
// canonical AppealTargets each have a DE + EN label; unknown targets
// return empty so the caller can fall back to the proceeding's own
// trigger_event_label.
func TestTriggerEventLabelForAppealTarget(t *testing.T) {
cases := []struct {
target string
lang string
want string
}{
{AppealTargetEndentscheidung, "de", "Endentscheidung (R.118)"},
{AppealTargetEndentscheidung, "en", "Final decision (R.118)"},
{AppealTargetKostenentscheidung, "de", "Kostenentscheidung"},
{AppealTargetKostenentscheidung, "en", "Cost decision"},
{AppealTargetAnordnung, "de", "Anordnung"},
{AppealTargetAnordnung, "en", "Order"},
{AppealTargetSchadensbemessung, "de", "Entscheidung im Schadensbemessungsverfahren"},
{AppealTargetSchadensbemessung, "en", "Damages-assessment decision"},
{AppealTargetBucheinsicht, "de", "Anordnung der Bucheinsicht"},
{AppealTargetBucheinsicht, "en", "Book-inspection order"},
// Unknown lang falls through to DE so the caller never gets
// an empty string for a known target.
{AppealTargetEndentscheidung, "fr", "Endentscheidung (R.118)"},
// Unknown target → empty so caller falls back to proceeding's
// trigger_event_label.
{"", "de", ""},
{"foo", "en", ""},
}
for _, c := range cases {
if got := TriggerEventLabelForAppealTarget(c.target, c.lang); got != c.want {
t.Errorf("TriggerEventLabelForAppealTarget(%q, %q) = %q, want %q",
c.target, c.lang, got, c.want)
}
}
}
// TestAppealTargetsCoverage ensures every entry in AppealTargets has
// a non-empty label in both languages. Adding a target to the slice
// without populating the switch would silently emit empty labels —
// this test catches that.
func TestAppealTargetsCoverage(t *testing.T) {
for _, target := range AppealTargets {
for _, lang := range []string{"de", "en"} {
if got := TriggerEventLabelForAppealTarget(target, lang); got == "" {
t.Errorf("AppealTarget %q has empty label for lang %q — add it to the switch",
target, lang)
}
}
}
}

View File

@@ -571,6 +571,21 @@ func Calculate(
if pickedProceeding.TriggerEventLabelEN != nil { if pickedProceeding.TriggerEventLabelEN != nil {
resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN
} }
// t-paliad-301 / m/paliad#132 Bug B — appeal_target-driven trigger
// label. When the request narrows to a specific appeal target, the
// "Auslösendes Ereignis" label describes the underlying decision
// (Endentscheidung / Kostenentscheidung / Anordnung /
// Schadensbemessung / Bucheinsicht) rather than the appeal
// proceeding itself. Overrides the proceeding's own
// trigger_event_label set above.
if opts.AppealTarget != "" {
if de := TriggerEventLabelForAppealTarget(opts.AppealTarget, "de"); de != "" {
resp.TriggerEventLabel = de
}
if en := TriggerEventLabelForAppealTarget(opts.AppealTarget, "en"); en != "" {
resp.TriggerEventLabelEN = en
}
}
if hasSubTrackNote { if hasSubTrackNote {
resp.ContextualNote = subTrackNote.NoteDE resp.ContextualNote = subTrackNote.NoteDE
resp.ContextualNoteEN = subTrackNote.NoteEN resp.ContextualNoteEN = subTrackNote.NoteEN

View File

@@ -185,6 +185,65 @@ type ProceedingType struct {
// — today the unified upc.apl row has this NULL (per-rule targets // — today the unified upc.apl row has this NULL (per-rule targets
// live on Rule.AppliesToTarget). // live on Rule.AppliesToTarget).
AppealTarget *string `db:"appeal_target" json:"appeal_target,omitempty"` AppealTarget *string `db:"appeal_target" json:"appeal_target,omitempty"`
// Role label overrides (t-paliad-301 / m/paliad#132, mig 137).
// NULL = renderer falls back to the language-default labels
// ("Klägerseite" / "Beklagtenseite" / "Claimant side" / "Defendant side").
// Set on proceedings where the role-naming diverges from the
// claimant/defendant default (Appeal → Berufungskläger /
// Berufungsbeklagter; Revocation → Antragsteller /
// Antragsgegner Nichtigkeit; EPA Opposition → Einsprechende(r) /
// Patentinhaber(in)).
RoleProactiveLabelDE *string `db:"role_proactive_label_de" json:"role_proactive_label_de,omitempty"`
RoleProactiveLabelEN *string `db:"role_proactive_label_en" json:"role_proactive_label_en,omitempty"`
RoleReactiveLabelDE *string `db:"role_reactive_label_de" json:"role_reactive_label_de,omitempty"`
RoleReactiveLabelEN *string `db:"role_reactive_label_en" json:"role_reactive_label_en,omitempty"`
}
// TriggerEventLabelForAppealTarget returns the per-target
// "Auslösendes Ereignis" label for the unified UPC Berufung
// proceeding (t-paliad-301 / m/paliad#132 Bug B). The trigger event
// for an appeal is the underlying decision, not the appeal
// proceeding itself — these labels override the proceeding's own
// trigger_event_label when appeal_target is set.
//
// lang ∈ {"de", "en"}; any other value falls through to "de" so the
// caller never gets an empty string.
//
// Returns empty when target is empty / unknown (caller must fall
// back to the proceeding's own trigger_event_label).
func TriggerEventLabelForAppealTarget(target, lang string) string {
if lang != "en" {
lang = "de"
}
switch target {
case AppealTargetEndentscheidung:
if lang == "en" {
return "Final decision (R.118)"
}
return "Endentscheidung (R.118)"
case AppealTargetKostenentscheidung:
if lang == "en" {
return "Cost decision"
}
return "Kostenentscheidung"
case AppealTargetAnordnung:
if lang == "en" {
return "Order"
}
return "Anordnung"
case AppealTargetSchadensbemessung:
if lang == "en" {
return "Damages-assessment decision"
}
return "Entscheidung im Schadensbemessungsverfahren"
case AppealTargetBucheinsicht:
if lang == "en" {
return "Book-inspection order"
}
return "Anordnung der Bucheinsicht"
}
return ""
} }
// AdjustmentReason describes why a date was rolled forward / backward // AdjustmentReason describes why a date was rolled forward / backward