Two bugs from the Slice B1 Berufung rollout, one fix surface:
Bug A — duplicate side selectors collapse into ONE proactive-side
picker with per-proceeding role labels. The Verfahrensablauf used to
show both ?side= (Klägerseite/Beklagtenseite) AND ?appellant= (same
labels in case-form) on the Berufung tile. Now: one side picker, with
labels that swap to Berufungskläger/Berufungsbeklagter on the unified
upc.apl.unified tile (and Antragsteller/Antragsgegner Nichtigkeit on
upc.rev.cfi, Einsprechende(r)/Patentinhaber(in) on epa.opp.*).
Bug B — 'Auslösendes Ereignis' label derives from appeal_target on
the unified Berufung tile (5 target-specific strings) instead of the
proceeding's own trigger_event_label. Endentscheidung (R.118) /
Kostenentscheidung / Anordnung / Entscheidung im
Schadensbemessungsverfahren / Anordnung der Bucheinsicht.
Migration 137 (additive, no triggers on proceeding_types — verified
via mcp__supabase__execute_sql before drafting; no updated_at on the
table — lesson from mig 134 HOTFIX 3; no audit_reason setup needed):
- ADD COLUMN role_proactive_label_de (text NULL)
- ADD COLUMN role_proactive_label_en (text NULL)
- ADD COLUMN role_reactive_label_de (text NULL)
- ADD COLUMN role_reactive_label_en (text NULL)
- Audit-first DO block lists the rows the UPDATE will touch.
- Backfill 4 proceedings (upc.apl.unified + upc.rev.cfi +
epa.opp.opd + epa.opp.boa); every other proceeding stays NULL
and the renderer falls back to default labels.
- Down drops the 4 columns.
Package additions (pkg/litigationplanner):
- ProceedingType gains 4 *string fields (RoleProactive/Reactive
LabelDE/EN) — db tags match the new columns; existing scans pick
them up via the proceedingTypeColumns extension.
- TriggerEventLabelForAppealTarget(target, lang) — Go-side map of
the 5 appeal-target slugs to their DE/EN trigger-event labels.
Empty result on unknown target signals "fall back to proceeding's
own trigger_event_label".
- Engine override: when CalcOptions.AppealTarget is set, the
resulting Timeline.TriggerEventLabel/EN are replaced from the
per-target map.
Frontend:
- Removed #appellant-row div (was a separate 3-radio selector
duplicating side).
- Dropped ?appellant= URL state + the change handler + the init
readback. The engine still consumes "appellant" — sourced from
currentSide for role-swap proceedings; null otherwise.
- applyRoleLabels(proceedingType) swaps the side-row radio labels
from a hardcoded ROLE_LABELS map mirroring mig 137's backfill.
Falls back to deadlines.side.claimant/defendant i18n keys for
proceedings without overrides.
- syncTriggerEventLabel reads data.triggerEventLabel from the calc
response — which the engine override now sets per appeal_target,
so no client-side mapping needed.
- i18n cleanup: removed orphan deadlines.appellant.* keys (label /
claimant / defendant / none) in both DE + EN.
Tests:
- pkg/litigationplanner/appeal_target_label_test.go pins the 5×2
label matrix + a coverage test that fails if a new entry in
AppealTargets is added without populating the label switch.
Acceptance:
- go build + go test all green (incl. new lp test).
- bun run build clean (i18n codegen drops 4 keys, regenerates).
- Live-DB audit before drafting confirmed: 4 target columns don't
exist on proceeding_types, zero triggers on the table, exact
column inventory matches the design.
951 lines
37 KiB
TypeScript
951 lines
37 KiB
TypeScript
// /tools/verfahrensablauf client (t-paliad-179 Slice 1)
|
|
//
|
|
// Abstract-browse surface: pick a proceeding, pick a trigger date,
|
|
// see the typical timeline. No Akte, no save-to-project, no anchor
|
|
// override editing, no Pathway B cascade. Variant chips + lane view
|
|
// (Slice 3) and compare (Slice 4) layer on top of this in later
|
|
// slices. Court picker + view toggle + calc fetch + renderers all
|
|
// come from ./views/verfahrensablauf-core, which fristenrechner.ts
|
|
// shares.
|
|
|
|
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
|
import { initSidebar } from "./sidebar";
|
|
import {
|
|
type DeadlineResponse,
|
|
type Side,
|
|
calculateDeadlines,
|
|
escHtml,
|
|
formatDate,
|
|
populateCourtPicker,
|
|
renderColumnsBody,
|
|
renderTimelineBody,
|
|
wireDateEditClicks,
|
|
} from "./views/verfahrensablauf-core";
|
|
import {
|
|
attachEventCardChoices,
|
|
reseedChips,
|
|
currentChoices,
|
|
type EventChoice,
|
|
type ChoiceKind,
|
|
} from "./views/event-card-choices";
|
|
|
|
let selectedType = "";
|
|
let lastResponse: DeadlineResponse | null = null;
|
|
|
|
// Perspective state. URL-driven so the view is shareable + survives
|
|
// reload:
|
|
// ?side=claimant|defendant — swaps which column owns the user's
|
|
// side (proactive vs reactive label).
|
|
// Default null = claimant-on-the-left.
|
|
//
|
|
// t-paliad-301 / m/paliad#132 collapsed the duplicate ?side= +
|
|
// ?appellant= selectors into the single proactive-side picker above.
|
|
// For role-swap proceedings (Appeal / EPA Opposition / DE Revision /
|
|
// 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;
|
|
|
|
// 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,
|
|
// the side row renders as a read-only chip instead of the radio cluster.
|
|
// The user can flip to free-pick via the "Andere Seite wählen" override
|
|
// link, which clears this flag (radio cluster takes over again).
|
|
let sidePrefilledFromProject = false;
|
|
|
|
// Role-swap proceedings — the side picker doubles as the appellant
|
|
// axis. After t-paliad-301 collapsed the duplicate selectors, the
|
|
// engine reads "appellant" from the single side value for these
|
|
// proceedings (so a row with primary_party=both renders only in the
|
|
// chosen side's column). For first-instance proceedings (Inf, Rev,
|
|
// …) the side picker still narrows columns but doesn't collapse
|
|
// the "both" rows.
|
|
const APPELLANT_AXIS_PROCEEDINGS = new Set([
|
|
"upc.apl.unified",
|
|
"de.inf.olg",
|
|
"de.inf.bgh",
|
|
"de.null.bgh",
|
|
"dpma.appeal.bpatg",
|
|
"dpma.appeal.bgh",
|
|
"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.
|
|
// Proceedings that surface the appeal-target chip group. Currently
|
|
// only the unified upc.apl proceeding; future variants (e.g. de.apl)
|
|
// can opt in by adding the code here.
|
|
const APPEAL_TARGET_PROCEEDINGS = new Set([
|
|
"upc.apl.unified",
|
|
]);
|
|
|
|
// Five canonical appeal-target slugs (lp.AppealTargets — keep ordered
|
|
// in sync with pkg/litigationplanner/types.go AppealTargets).
|
|
const APPEAL_TARGETS = [
|
|
"endentscheidung",
|
|
"kostenentscheidung",
|
|
"anordnung",
|
|
"schadensbemessung",
|
|
"bucheinsicht",
|
|
] as const;
|
|
type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
|
|
|
|
function hasAppealTarget(proceedingType: string): boolean {
|
|
return APPEAL_TARGET_PROCEEDINGS.has(proceedingType);
|
|
}
|
|
|
|
function hasAppellantAxis(proceedingType: string): boolean {
|
|
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
|
|
}
|
|
|
|
function readSideFromURL(): Side {
|
|
const raw = new URLSearchParams(window.location.search).get("side");
|
|
return raw === "claimant" || raw === "defendant" ? raw : null;
|
|
}
|
|
|
|
function writeSideToURL(s: Side) {
|
|
const url = new URL(window.location.href);
|
|
if (s === null) url.searchParams.delete("side");
|
|
else url.searchParams.set("side", s);
|
|
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
|
}
|
|
|
|
// t-paliad-301 / m/paliad#132: applies ROLE_LABELS to the side-row
|
|
// radio labels for the currently selected proceeding. Proceedings
|
|
// without an entry fall back to the existing
|
|
// "deadlines.side.claimant" / "deadlines.side.defendant" i18n keys.
|
|
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
|
|
// (the row is hidden because the proceeding isn't an appeal). Any
|
|
// other value must be one of APPEAL_TARGETS; unknown values are
|
|
// rejected by readAppealTargetFromURL so a stale link can't break
|
|
// the engine filter.
|
|
function readAppealTargetFromURL(): AppealTarget {
|
|
const raw = new URLSearchParams(window.location.search).get("target") || "";
|
|
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
|
|
return raw as AppealTarget;
|
|
}
|
|
return "";
|
|
}
|
|
|
|
function writeAppealTargetToURL(t: AppealTarget) {
|
|
const url = new URL(window.location.href);
|
|
if (t === "") url.searchParams.delete("target");
|
|
else url.searchParams.set("target", t);
|
|
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
|
}
|
|
|
|
// Default target on first picker entry into upc.apl. m: Endentscheidung
|
|
// is the most-common appeal target; the chip group also defaults
|
|
// "Endentscheidung" checked in verfahrensablauf.tsx. Keep these two in
|
|
// sync so the URL-less default render hits the same code path.
|
|
let currentAppealTarget: AppealTarget = "";
|
|
|
|
// Per-rule anchor overrides set by the click-to-edit affordance on
|
|
// timeline / column date cells. Posted as `anchorOverrides` to the
|
|
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
|
|
// user's chosen date. Cleared whenever the trigger changes (proceeding,
|
|
// trigger date, flag toggle) so a fresh calc starts unanchored — same
|
|
// semantic as /tools/fristenrechner.
|
|
const anchorOverrides = new Map<string, string>();
|
|
function clearAnchorOverrides() { anchorOverrides.clear(); }
|
|
|
|
// Per-event-card choices (t-paliad-265). Unbound on this page (no
|
|
// project context), so persistence is URL-only via `?event_choices=`.
|
|
// Format: comma-separated `submission_code:kind=value` tuples. Same
|
|
// idiom as `?side=` + `?appellant=`.
|
|
let perCardChoices: EventChoice[] = [];
|
|
|
|
function readChoicesFromURL(): EventChoice[] {
|
|
const raw = new URLSearchParams(window.location.search).get("event_choices");
|
|
if (!raw) return [];
|
|
const out: EventChoice[] = [];
|
|
for (const tuple of raw.split(",")) {
|
|
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
|
|
if (!m) continue;
|
|
const kind = m[2] as ChoiceKind;
|
|
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
|
|
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function writeChoicesToURL(choices: EventChoice[]) {
|
|
const url = new URL(window.location.href);
|
|
if (choices.length === 0) {
|
|
url.searchParams.delete("event_choices");
|
|
} else {
|
|
const enc = choices.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`).join(",");
|
|
url.searchParams.set("event_choices", enc);
|
|
}
|
|
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
|
}
|
|
|
|
// Show-hidden toggle state (t-paliad-290 / m/paliad#122). When ON, the
|
|
// calculator re-surfaces cards whose submission_code is in the active
|
|
// skipRules set; they render faded with a "Wieder einblenden" chip.
|
|
// URL-driven via ?show_hidden=1 so a shared link or reload preserves
|
|
// the visibility. Default OFF — m's not asking to see hidden by
|
|
// default, just to be able to.
|
|
function readShowHiddenFromURL(): boolean {
|
|
return new URLSearchParams(window.location.search).get("show_hidden") === "1";
|
|
}
|
|
|
|
function writeShowHiddenToURL(on: boolean) {
|
|
const url = new URL(window.location.href);
|
|
if (on) url.searchParams.set("show_hidden", "1");
|
|
else url.searchParams.delete("show_hidden");
|
|
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
|
}
|
|
|
|
let showHidden = readShowHiddenFromURL();
|
|
|
|
type ProcedureView = "timeline" | "columns";
|
|
let procedureView: ProcedureView = "columns";
|
|
|
|
// Notes toggle — when off (default), per-rule descriptive notes render
|
|
// as a compact ⓘ icon next to the meta line (hover for full text). When
|
|
// on, the full notes block expands under each card. Choice persists in
|
|
// localStorage so a reload or recalc keeps the user's preference.
|
|
const NOTES_PREF_KEY = "paliad.fristen.notes-show";
|
|
function readNotesPref(): boolean {
|
|
try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; }
|
|
}
|
|
function writeNotesPref(on: boolean): void {
|
|
try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ }
|
|
}
|
|
let showNotes = readNotesPref();
|
|
|
|
// Jurisdiction display prefix for the proceeding-summary chip + the
|
|
// trigger-event placeholder. Same forum slugs the .proceeding-group
|
|
// `data-forum` attribute carries in verfahrensablauf.tsx /
|
|
// fristenrechner.tsx (upc / de / epa / dpma). Disambiguates the
|
|
// 4 redundancies in the corpus (UPC Verletzungsverfahren vs DE
|
|
// Verletzungsklage etc.) once the picker collapses.
|
|
const FORUM_LABEL: Record<string, string> = {
|
|
upc: "UPC",
|
|
de: "DE",
|
|
epa: "EPA",
|
|
dpma: "DPMA",
|
|
};
|
|
|
|
function jurisdictionFor(btn: HTMLButtonElement): string {
|
|
const group = btn.closest<HTMLElement>(".proceeding-group");
|
|
const forum = group?.dataset.forum || "";
|
|
return FORUM_LABEL[forum] || "";
|
|
}
|
|
|
|
function proceedingDisplayName(btn: HTMLButtonElement): string {
|
|
const name = btn.querySelector("strong")?.textContent || "";
|
|
const jur = jurisdictionFor(btn);
|
|
return jur ? `${jur} ${name}` : name;
|
|
}
|
|
|
|
function activeProceedingButton(): HTMLButtonElement | null {
|
|
return document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
|
|
}
|
|
|
|
// Auto-calc plumbing — sequence + debounce mirror /tools/fristenrechner
|
|
// so rapid input changes never let a stale response overwrite a fresh
|
|
// one.
|
|
let calcSeq = 0;
|
|
let calcTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
function scheduleCalc(delayMs = 200) {
|
|
if (calcTimer !== null) clearTimeout(calcTimer);
|
|
calcTimer = setTimeout(() => {
|
|
calcTimer = null;
|
|
void doCalc();
|
|
}, delayMs);
|
|
}
|
|
|
|
function showStep(n: number) {
|
|
for (let i = 1; i <= 3; i++) {
|
|
const el = document.getElementById(`step-${i}`);
|
|
if (el) el.style.display = i <= n ? "block" : "none";
|
|
}
|
|
}
|
|
|
|
// Read the proceeding-specific flag checkboxes and assemble the
|
|
// payload the calculator expects. Mirrors fristenrechner.ts so the
|
|
// gating semantics stay identical: with_amend on upc.inf.cfi is
|
|
// nested under with_ccr (R.30 is only available with a CCR);
|
|
// upc.rev.cfi exposes with_amend + with_cci as two independent
|
|
// gates. R.19 Einspruch is NOT flag-gated (mig 098, m's 2026-05-18
|
|
// call): it's just an always-available optional submission, so it
|
|
// has no checkbox.
|
|
function readFlags(): string[] {
|
|
const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
|
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
|
|
const revAmend = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
|
|
const revCci = document.getElementById("rev-cci-flag") as HTMLInputElement | null;
|
|
const flags: string[] = [];
|
|
if (selectedType === "upc.inf.cfi") {
|
|
if (ccr?.checked) flags.push("with_ccr");
|
|
if (ccr?.checked && infAmend?.checked) flags.push("with_amend");
|
|
}
|
|
if (selectedType === "upc.rev.cfi") {
|
|
if (revAmend?.checked) flags.push("with_amend");
|
|
if (revCci?.checked) flags.push("with_cci");
|
|
}
|
|
return flags;
|
|
}
|
|
|
|
async function doCalc() {
|
|
const seq = ++calcSeq;
|
|
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
|
|
const triggerDate = dateInput?.value || "";
|
|
if (!triggerDate || !selectedType) return;
|
|
|
|
const courtPickerRow = document.getElementById("court-picker-row");
|
|
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
|
const courtId = courtPickerRow && courtPickerRow.style.display !== "none" && courtPicker?.value
|
|
? courtPicker.value
|
|
: "";
|
|
|
|
const overrides: Record<string, string> = {};
|
|
for (const [code, date] of anchorOverrides) overrides[code] = date;
|
|
|
|
// Slice B1 (m/paliad#124 §18.1): for the unified upc.apl Berufung,
|
|
// default to "endentscheidung" when no chip pick is stored in URL.
|
|
// For non-appeal proceedings the engine ignores opts.AppealTarget.
|
|
const appealTarget = hasAppealTarget(selectedType)
|
|
? (currentAppealTarget || "endentscheidung")
|
|
: "";
|
|
|
|
const data = await calculateDeadlines({
|
|
proceedingType: selectedType,
|
|
triggerDate,
|
|
flags: readFlags(),
|
|
anchorOverrides: overrides,
|
|
courtId,
|
|
perCardChoices,
|
|
includeHidden: showHidden,
|
|
appealTarget,
|
|
});
|
|
if (seq !== calcSeq) return;
|
|
if (!data) return;
|
|
lastResponse = data;
|
|
renderResults(data);
|
|
syncHiddenBadge(data.hiddenCount ?? 0);
|
|
showStep(3);
|
|
}
|
|
|
|
// syncHiddenBadge updates the "Ausgeblendete (N)" count next to the
|
|
// toggle. Visible regardless of toggle state so the user knows whether
|
|
// there's anything to re-surface even when the toggle is OFF. Hides the
|
|
// whole row when the projection has zero hidden cards — no clutter on
|
|
// a project that's never used the skip feature. (t-paliad-290)
|
|
function syncHiddenBadge(count: number) {
|
|
const row = document.getElementById("show-hidden-row");
|
|
const badge = document.getElementById("show-hidden-count");
|
|
if (!row || !badge) return;
|
|
if (count <= 0) {
|
|
row.style.display = "none";
|
|
return;
|
|
}
|
|
row.style.display = "";
|
|
badge.textContent = tDyn("choices.show_hidden.count").replace("{n}", String(count));
|
|
}
|
|
|
|
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
|
|
// label from the calc response. Precedence:
|
|
//
|
|
// 1. Server-supplied triggerEventLabel from proceeding_types
|
|
// (mig 121, m/paliad#81). UPC Appeal sets this to
|
|
// "Anfechtbare Entscheidung" / "Appealable Decision" — its rules
|
|
// all carry a non-zero duration off the trigger date so none is
|
|
// the root, and the proceedingName fallback ("Berufungsverfahren")
|
|
// misnamed the input as the proceeding itself.
|
|
// 2. Root rule (isRootEvent=true) — the first event in the
|
|
// proceeding, e.g. Klageerhebung for upc.inf.cfi,
|
|
// Nichtigkeitsklage for upc.rev.cfi.
|
|
// 3. Active proceeding name — last-resort fallback. Language-aware
|
|
// (m/paliad#58: prior code rendered DE on EN for sub-track
|
|
// proceedings like upc.ccr.cfi which had no rules → no root).
|
|
function triggerEventLabelFor(data: DeadlineResponse): string {
|
|
const lang = getLang();
|
|
const curated = lang === "en"
|
|
? (data.triggerEventLabelEN || data.triggerEventLabel)
|
|
: (data.triggerEventLabel || data.triggerEventLabelEN);
|
|
if (curated) return curated;
|
|
const root = data.deadlines.find((d) => d.isRootEvent);
|
|
if (root) {
|
|
return lang === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
|
|
}
|
|
if (lang === "en") {
|
|
return data.proceedingNameEN || data.proceedingName || "";
|
|
}
|
|
return data.proceedingName || data.proceedingNameEN || "";
|
|
}
|
|
|
|
function syncTriggerEventLabel() {
|
|
const triggerEventEl = document.getElementById("trigger-event");
|
|
if (!triggerEventEl) return;
|
|
if (lastResponse) {
|
|
triggerEventEl.textContent = triggerEventLabelFor(lastResponse);
|
|
} else {
|
|
triggerEventEl.textContent = "—";
|
|
}
|
|
}
|
|
|
|
function renderResults(data: DeadlineResponse) {
|
|
const container = document.getElementById("timeline-container");
|
|
if (!container) return;
|
|
const printBtn = document.getElementById("fristen-print-btn");
|
|
const toggle = document.getElementById("fristen-view-toggle");
|
|
|
|
// Header shows the picked proceeding with its jurisdiction prefix
|
|
// so the user can tell UPC Verletzungsverfahren apart from DE
|
|
// Verletzungsklage once the picker collapses.
|
|
const activeBtn = activeProceedingButton();
|
|
const procName = activeBtn ? proceedingDisplayName(activeBtn)
|
|
: tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
|
|
const headerHtml = `<div class="timeline-header">
|
|
<strong>${procName}</strong>
|
|
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
|
</div>`;
|
|
|
|
// Sub-track contextual note (m/paliad#58). Surfaces above the
|
|
// timeline body when the server routed the user-picked proceeding
|
|
// through a parent (e.g. upc.ccr.cfi → upc.inf.cfi with with_ccr).
|
|
// Plain-text banner — server-side copy is plain text per the
|
|
// SubTrackRouting contract.
|
|
const noteText = getLang() === "en"
|
|
? (data.contextualNoteEN || data.contextualNote || "")
|
|
: (data.contextualNote || data.contextualNoteEN || "");
|
|
const noteHtml = noteText
|
|
? `<div class="timeline-context-note" role="note">${escHtml(noteText)}</div>`
|
|
: "";
|
|
|
|
const bodyHtml = procedureView === "columns"
|
|
? renderColumnsBody(data, {
|
|
editable: true,
|
|
showNotes,
|
|
side: currentSide,
|
|
// 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 });
|
|
|
|
container.innerHTML = headerHtml + noteHtml + bodyHtml;
|
|
if (printBtn) printBtn.style.display = "block";
|
|
if (toggle) toggle.style.display = "";
|
|
|
|
syncTriggerEventLabel();
|
|
|
|
// t-paliad-265: rehydrate per-event-card chip indicators after every
|
|
// re-render so the popover-driven active state survives the
|
|
// innerHTML rewrite the timeline body just did.
|
|
reseedChips(container);
|
|
}
|
|
|
|
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
|
|
const groups = document.querySelectorAll<HTMLElement>(".proceeding-group");
|
|
const summary = document.getElementById("proceeding-summary") as HTMLElement | null;
|
|
const summaryName = document.getElementById("proceeding-summary-name");
|
|
groups.forEach((g) => { g.style.display = collapsed ? "none" : ""; });
|
|
if (summary) summary.style.display = collapsed ? "" : "none";
|
|
if (summaryName && displayName) summaryName.textContent = displayName;
|
|
}
|
|
|
|
// syncFlagRows shows/hides the proceeding-specific checkbox rows
|
|
// based on selectedType. Same disposition as fristenrechner.ts —
|
|
// the with_amend nested-under-ccr semantic is enforced via
|
|
// syncInfAmendEnabled().
|
|
function syncFlagRows() {
|
|
const show = (id: string, when: boolean) => {
|
|
const el = document.getElementById(id);
|
|
if (el) el.style.display = when ? "" : "none";
|
|
};
|
|
show("ccr-flag-row", selectedType === "upc.inf.cfi");
|
|
show("inf-amend-flag-row", selectedType === "upc.inf.cfi");
|
|
show("rev-amend-flag-row", selectedType === "upc.rev.cfi");
|
|
show("rev-cci-flag-row", selectedType === "upc.rev.cfi");
|
|
syncInfAmendEnabled();
|
|
}
|
|
|
|
// R.30 amendment-application is only available with a CCR — disable
|
|
// (and clear) the nested inf-amend checkbox while ccr is off so the
|
|
// calc payload stays coherent. Mirrors fristenrechner.ts.
|
|
function syncInfAmendEnabled() {
|
|
const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
|
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
|
|
if (!ccr || !infAmend) return;
|
|
infAmend.disabled = !ccr.checked;
|
|
if (!ccr.checked) infAmend.checked = false;
|
|
}
|
|
|
|
function selectProceeding(btn: HTMLButtonElement) {
|
|
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
|
btn.classList.add("active");
|
|
const nextType = btn.dataset.code || "";
|
|
// Different proceeding tree → previously-set overrides reference
|
|
// rule codes that don't exist in the new tree. Clear before the
|
|
// next calc so the fresh proceeding starts unanchored.
|
|
if (selectedType !== nextType) clearAnchorOverrides();
|
|
selectedType = nextType;
|
|
|
|
// Trigger-event label fires from the calc response (root rule).
|
|
// Until step 3 renders, fall back to an em-dash placeholder.
|
|
lastResponse = null;
|
|
syncTriggerEventLabel();
|
|
|
|
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
|
syncFlagRows();
|
|
syncAppealTargetRowVisibility();
|
|
applyRoleLabels(selectedType);
|
|
|
|
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
|
|
|
|
showStep(2);
|
|
scheduleCalc(0);
|
|
}
|
|
|
|
// Slice B1 (m/paliad#124 §18.1) — Berufung unification.
|
|
// syncAppealTargetRowVisibility shows the appeal-target chip group
|
|
// when the unified upc.apl Berufung tile is selected, hides it
|
|
// otherwise. Mirrors syncAppellantRowVisibility's pattern: clears
|
|
// state + URL when hiding so a stale ?target= can't leak.
|
|
function syncAppealTargetRowVisibility() {
|
|
const row = document.getElementById("appeal-target-row");
|
|
if (!row) return;
|
|
const visible = hasAppealTarget(selectedType);
|
|
row.style.display = visible ? "" : "none";
|
|
if (!visible && currentAppealTarget !== "") {
|
|
currentAppealTarget = "";
|
|
writeAppealTargetToURL("");
|
|
syncRadioGroup("appeal-target", "endentscheidung");
|
|
}
|
|
}
|
|
|
|
function syncRadioGroup(name: string, value: string) {
|
|
document.querySelectorAll<HTMLInputElement>(`input[type=radio][name=${name}]`).forEach((input) => {
|
|
input.checked = input.value === value;
|
|
});
|
|
}
|
|
|
|
// Project context (t-paliad-279 / m/paliad#111). When the page is opened
|
|
// with ?project=<id> and the project carries an our_side value, the side
|
|
// row renders as a read-only chip with an "Andere Seite wählen" override
|
|
// link. The proceeding picker + appellant axis stay untouched — only the
|
|
// side selector pre-fills.
|
|
interface ProjectOurSide {
|
|
id: string;
|
|
our_side?:
|
|
| "claimant"
|
|
| "defendant"
|
|
| "applicant"
|
|
| "appellant"
|
|
| "respondent"
|
|
| "third_party"
|
|
| "other"
|
|
| null;
|
|
}
|
|
|
|
function readProjectFromURL(): string {
|
|
return new URLSearchParams(window.location.search).get("project") || "";
|
|
}
|
|
|
|
// ourSideToSide maps the project-level our_side enum (t-paliad-222) onto
|
|
// the side-selector's two-value axis. Active roles (claimant / applicant /
|
|
// appellant) collapse to "claimant"; reactive roles (defendant /
|
|
// respondent) collapse to "defendant"; everything else (third_party /
|
|
// other / NULL) returns null = no pre-fill. Mirrors fristenrechner.ts
|
|
// ourSideToPerspective() so projects render consistently across both
|
|
// surfaces.
|
|
function ourSideToSide(os: ProjectOurSide["our_side"] | undefined): Side {
|
|
switch (os) {
|
|
case "claimant":
|
|
case "applicant":
|
|
case "appellant":
|
|
return "claimant";
|
|
case "defendant":
|
|
case "respondent":
|
|
return "defendant";
|
|
default:
|
|
return null;
|
|
}
|
|
}
|
|
|
|
async function fetchProjectOurSide(projectID: string): Promise<ProjectOurSide | null> {
|
|
try {
|
|
const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}`, {
|
|
credentials: "same-origin",
|
|
});
|
|
if (!resp.ok) return null;
|
|
return (await resp.json()) as ProjectOurSide;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function sideLabelI18n(s: Side): string {
|
|
if (s === "claimant") return t("deadlines.side.claimant");
|
|
if (s === "defendant") return t("deadlines.side.defendant");
|
|
return t("deadlines.side.undefined");
|
|
}
|
|
|
|
// syncSideHintVisibility shows the "pick a side" hint chip only while
|
|
// currentSide is unset (m/paliad#120). When the user has picked
|
|
// claimant / defendant the columns are already focused, so the prompt
|
|
// would be misleading.
|
|
function syncSideHintVisibility() {
|
|
const hint = document.getElementById("side-hint");
|
|
if (!hint) return;
|
|
hint.style.display = currentSide === null ? "" : "none";
|
|
}
|
|
|
|
// renderSideChip swaps the radio cluster for a read-only chip showing
|
|
// the auto-filled side + an "Andere Seite wählen" override link. Called
|
|
// after fetchProjectOurSide resolves to a side. The override link clears
|
|
// the prefilled flag and swaps back to the radio cluster — the user can
|
|
// then pick any side freely.
|
|
function renderSideChip(side: Side) {
|
|
const cluster = document.getElementById("side-radio-cluster");
|
|
const chip = document.getElementById("side-chip");
|
|
const value = document.getElementById("side-chip-value");
|
|
if (!cluster || !chip || !value) return;
|
|
cluster.style.display = "none";
|
|
chip.style.display = "";
|
|
value.textContent = sideLabelI18n(side);
|
|
}
|
|
|
|
function showSideRadioCluster() {
|
|
const cluster = document.getElementById("side-radio-cluster");
|
|
const chip = document.getElementById("side-chip");
|
|
if (!cluster || !chip) return;
|
|
cluster.style.display = "";
|
|
chip.style.display = "none";
|
|
// Cluster re-appears after override → re-evaluate hint visibility so
|
|
// we don't leave a stale "pick a side" prompt above a checked radio.
|
|
syncSideHintVisibility();
|
|
}
|
|
|
|
// applySidePrefill takes a project's our_side, maps it to the side axis,
|
|
// and locks the side row to a read-only chip if a mapping exists. URL
|
|
// wins — if ?side= is already explicit, the user (or shared link) has
|
|
// already chosen and we never overwrite. When we do prefill, write the
|
|
// derived side to the URL so reload + back/forward round-trip cleanly.
|
|
function applySidePrefill(os: ProjectOurSide["our_side"] | undefined) {
|
|
if (readSideFromURL() !== null) return;
|
|
const next = ourSideToSide(os);
|
|
if (next === null) return;
|
|
currentSide = next;
|
|
writeSideToURL(next);
|
|
syncRadioGroup("side", next);
|
|
sidePrefilledFromProject = true;
|
|
renderSideChip(next);
|
|
if (lastResponse) renderResults(lastResponse);
|
|
}
|
|
|
|
function clearSidePrefill() {
|
|
sidePrefilledFromProject = false;
|
|
showSideRadioCluster();
|
|
// Drop ?project= from the URL so a reload doesn't re-lock the side.
|
|
// ?side= stays — that's the user's last pick at this point.
|
|
const url = new URL(window.location.href);
|
|
url.searchParams.delete("project");
|
|
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
|
}
|
|
|
|
async function initProjectAutofill() {
|
|
const projectID = readProjectFromURL();
|
|
if (!projectID) return;
|
|
const project = await fetchProjectOurSide(projectID);
|
|
if (!project) return;
|
|
applySidePrefill(project.our_side);
|
|
}
|
|
|
|
function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
|
|
// Mirrors the events.ts pattern (body.events-view-*). The print
|
|
// stylesheet keys `body.verfahrensablauf-view-timeline` to
|
|
// `@page paliad-landscape`, so flipping this class is what lets a
|
|
// user print the horizontal timeline in landscape without affecting
|
|
// the columns view (which stays portrait).
|
|
document.body.classList.toggle("verfahrensablauf-view-timeline", view === "timeline");
|
|
document.body.classList.toggle("verfahrensablauf-view-columns", view === "columns");
|
|
}
|
|
|
|
function initViewToggle() {
|
|
const toggle = document.getElementById("fristen-view-toggle");
|
|
if (!toggle) return;
|
|
|
|
const initial = new URLSearchParams(window.location.search).get("view");
|
|
if (initial === "timeline") procedureView = "timeline";
|
|
applyVerfahrensablaufViewBodyClass(procedureView);
|
|
|
|
toggle.querySelectorAll<HTMLInputElement>("input[name=fristen-view]").forEach((input) => {
|
|
input.checked = input.value === procedureView;
|
|
input.addEventListener("change", () => {
|
|
if (!input.checked) return;
|
|
procedureView = input.value === "columns" ? "columns" : "timeline";
|
|
applyVerfahrensablaufViewBodyClass(procedureView);
|
|
const url = new URL(window.location.href);
|
|
if (procedureView === "columns") {
|
|
url.searchParams.delete("view");
|
|
} else {
|
|
url.searchParams.set("view", procedureView);
|
|
}
|
|
history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
|
if (lastResponse) renderResults(lastResponse);
|
|
});
|
|
});
|
|
|
|
toggle.style.display = "none";
|
|
}
|
|
|
|
// initPerspectiveControls hydrates side+appellant from the URL,
|
|
// reflects state into the radio inputs, and wires onchange handlers
|
|
// that update state + URL + re-render. Re-render path skips the
|
|
// /api/tools/fristenrechner round-trip — perspective is a pure
|
|
// projection of the last response, no backend involved.
|
|
function initPerspectiveControls() {
|
|
currentSide = readSideFromURL();
|
|
currentAppealTarget = readAppealTargetFromURL();
|
|
syncRadioGroup("side", currentSide ?? "");
|
|
syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung");
|
|
syncSideHintVisibility();
|
|
|
|
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
|
|
input.addEventListener("change", () => {
|
|
if (!input.checked) return;
|
|
const v = input.value;
|
|
currentSide = (v === "claimant" || v === "defendant") ? v : null;
|
|
writeSideToURL(currentSide);
|
|
syncSideHintVisibility();
|
|
if (lastResponse) renderResults(lastResponse);
|
|
});
|
|
});
|
|
|
|
// Slice B1 (m/paliad#124 §18.1) — appeal-target chip handler.
|
|
// Each chip change re-fetches with the new target slug so the
|
|
// timeline re-renders against the matching rule subset.
|
|
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appeal-target]").forEach((input) => {
|
|
input.addEventListener("change", () => {
|
|
if (!input.checked) return;
|
|
const v = input.value;
|
|
if ((APPEAL_TARGETS as readonly string[]).includes(v)) {
|
|
currentAppealTarget = v as AppealTarget;
|
|
} else {
|
|
currentAppealTarget = "";
|
|
}
|
|
writeAppealTargetToURL(currentAppealTarget);
|
|
scheduleCalc(0);
|
|
});
|
|
});
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", () => {
|
|
initI18n();
|
|
initSidebar();
|
|
|
|
document.querySelectorAll<HTMLButtonElement>(".proceeding-btn").forEach((btn) => {
|
|
btn.addEventListener("click", () => selectProceeding(btn));
|
|
});
|
|
|
|
document.getElementById("proceeding-summary-reselect")?.addEventListener("click", () => {
|
|
setProceedingPickerCollapsed(false);
|
|
});
|
|
|
|
document.getElementById("calculate-btn")?.addEventListener("click", () => scheduleCalc(0));
|
|
|
|
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
|
|
if (dateInput) {
|
|
dateInput.addEventListener("change", () => scheduleCalc());
|
|
dateInput.addEventListener("input", () => scheduleCalc());
|
|
dateInput.addEventListener("keydown", (e) => {
|
|
if ((e as KeyboardEvent).key === "Enter") scheduleCalc(0);
|
|
});
|
|
}
|
|
|
|
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
|
if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0));
|
|
|
|
// Flag-checkbox listeners — each flip triggers a fresh calc so the
|
|
// timeline re-projects with the new gating. ccr-flag additionally
|
|
// enables/disables the nested inf-amend row.
|
|
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
|
if (ccrFlag) ccrFlag.addEventListener("change", () => {
|
|
syncInfAmendEnabled();
|
|
scheduleCalc(0);
|
|
});
|
|
(["inf-amend-flag", "rev-amend-flag", "rev-cci-flag"]).forEach((id) => {
|
|
const cb = document.getElementById(id) as HTMLInputElement | null;
|
|
if (cb) cb.addEventListener("change", () => scheduleCalc(0));
|
|
});
|
|
|
|
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
|
|
|
|
// Click-to-edit on timeline / column date cells — same delegated
|
|
// pattern as /tools/fristenrechner. Survives renderResults()'s
|
|
// innerHTML rewrites because the listener lives on the container.
|
|
const timelineContainer = document.getElementById("timeline-container");
|
|
if (timelineContainer) {
|
|
wireDateEditClicks(timelineContainer, (ruleCode, newValue) => {
|
|
if (newValue === "") {
|
|
anchorOverrides.delete(ruleCode);
|
|
} else {
|
|
anchorOverrides.set(ruleCode, newValue);
|
|
}
|
|
scheduleCalc(0);
|
|
});
|
|
}
|
|
|
|
// Notes toggle — restores last preference on load + re-renders when
|
|
// the user flips it. Lives in the same toggle bar as the view picker.
|
|
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
|
|
if (notesShowCb) {
|
|
notesShowCb.checked = showNotes;
|
|
notesShowCb.addEventListener("change", () => {
|
|
showNotes = notesShowCb.checked;
|
|
writeNotesPref(showNotes);
|
|
if (lastResponse) renderResults(lastResponse);
|
|
});
|
|
}
|
|
|
|
// t-paliad-290 — show-hidden toggle. Hydrate from URL, wire change
|
|
// to URL + recalc (the backend reshapes the response — we can't just
|
|
// re-render lastResponse since the hidden rows aren't in it when the
|
|
// toggle was OFF).
|
|
const showHiddenCb = document.getElementById("show-hidden-toggle") as HTMLInputElement | null;
|
|
if (showHiddenCb) {
|
|
showHiddenCb.checked = showHidden;
|
|
showHiddenCb.addEventListener("change", () => {
|
|
showHidden = showHiddenCb.checked;
|
|
writeShowHiddenToURL(showHidden);
|
|
scheduleCalc(0);
|
|
});
|
|
}
|
|
|
|
initViewToggle();
|
|
initPerspectiveControls();
|
|
|
|
// t-paliad-265 — per-event-card choices. Unbound surface, so commits
|
|
// mutate the in-memory list + URL, then trigger a recalc. The
|
|
// popover module owns the popover lifecycle; this page owns the
|
|
// recalc + URL plumbing.
|
|
perCardChoices = readChoicesFromURL();
|
|
const timelineEl = document.getElementById("timeline-container");
|
|
if (timelineEl) {
|
|
attachEventCardChoices({
|
|
container: timelineEl,
|
|
initial: perCardChoices,
|
|
commit: (choice) => {
|
|
perCardChoices = perCardChoices.filter(
|
|
(c) => !(c.submission_code === choice.submission_code && c.choice_kind === choice.choice_kind),
|
|
);
|
|
perCardChoices.push(choice);
|
|
writeChoicesToURL(perCardChoices);
|
|
scheduleCalc(0);
|
|
},
|
|
remove: (submissionCode, kind) => {
|
|
perCardChoices = perCardChoices.filter(
|
|
(c) => !(c.submission_code === submissionCode && c.choice_kind === kind),
|
|
);
|
|
writeChoicesToURL(perCardChoices);
|
|
scheduleCalc(0);
|
|
},
|
|
});
|
|
}
|
|
|
|
// t-paliad-279 — override link on the prefilled side chip — swaps back
|
|
// to the radio cluster and clears ?project= from the URL.
|
|
document.getElementById("side-chip-override")?.addEventListener("click", clearSidePrefill);
|
|
|
|
// Project autofill — runs after the radio cluster has its URL-driven
|
|
// state so we never clobber an explicit ?side= pick. Fire-and-forget;
|
|
// the chip swap happens once the project resolves.
|
|
void initProjectAutofill();
|
|
|
|
|
|
onLangChange(() => {
|
|
// Active-button name updates with language change (the data-i18n
|
|
// pass swaps the inner <strong>'s text). Re-collapse the summary
|
|
// chip and re-derive the trigger event label from the lang-current
|
|
// calc response.
|
|
const activeBtn = activeProceedingButton();
|
|
if (activeBtn) {
|
|
const summary = document.getElementById("proceeding-summary-name");
|
|
if (summary) summary.textContent = proceedingDisplayName(activeBtn);
|
|
}
|
|
// Side-chip label tracks language so a DE/EN flip while the chip is
|
|
// visible re-renders the inferred side in the active language.
|
|
if (sidePrefilledFromProject) {
|
|
const value = document.getElementById("side-chip-value");
|
|
if (value) value.textContent = sideLabelI18n(currentSide);
|
|
}
|
|
if (lastResponse) renderResults(lastResponse);
|
|
syncTriggerEventLabel();
|
|
});
|
|
|
|
// Pre-select the first proceeding tile so users see a timeline
|
|
// immediately on landing — matches /tools/fristenrechner behaviour.
|
|
const firstBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
|
|
if (firstBtn) selectProceeding(firstBtn);
|
|
});
|