// /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= 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 = { "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( "input[type=radio][name=side][value=claimant] + span" ); const defendantSpan = document.querySelector( "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(); 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 = { upc: "UPC", de: "DE", epa: "EPA", dpma: "DPMA", }; function jurisdictionFor(btn: HTMLButtonElement): string { const group = btn.closest(".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(".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 | 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 = {}; 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 = `
${procName} ${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}
`; // 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 ? `
${escHtml(noteText)}
` : ""; 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(".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(`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= 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 { 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("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("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("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(".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 '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(".proceeding-btn"); if (firstBtn) selectProceeding(firstBtn); });