Files
paliad/frontend/src/client/fristenrechner.ts
mAi 5dea0a703b wip(projects): t-paliad-222 — backend + frontend changes (pre-merge checkpoint)
Backend: mig 110/111 (will be renumbered after merging main),
validators + helpers widened, BuildProjectCode helper + projection
populator wired into List/GetByID/ListAncestors/GetTree/CCR. All
internal Go tests pass.

Frontend: ProjectFormFields conditional render — opponent_code on
litigation, our_side renamed to Client Role on case with grouped
optgroups. i18n keys for both DE and EN. fristenrechner perspective
mapping widened. project-form.ts payload reader/writer + showFieldsForType
toggle for new litigation block.

Migration slots about to be bumped (mig 110 was claimed by euler's
project_type_other on main).
2026-05-20 14:55:55 +02:00

3935 lines
162 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Fristenrechner client-side logic
// 3-step wizard: select proceeding -> enter date -> view timeline
//
// Rendering primitives (renderTimelineBody / renderColumnsBody /
// deadlineCardHtml / formatDate / partyBadge / court picker / inline
// date editor) live in `./views/verfahrensablauf-core` and are shared
// with /tools/verfahrensablauf. This module owns the Step1/2/3a
// wizard, Pathway A/B, Akte save flow — none of which Verfahrensablauf
// wants.
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import { projectIndent } from "./project-indent";
import {
type CalculatedDeadline,
type DeadlineResponse,
calculateDeadlines,
escAttr,
escHtml,
formatDate,
populateCourtPicker as populateCourtPickerCore,
priorityRendering,
renderColumnsBody,
renderTimelineBody,
wireDateEditClicks,
} from "./views/verfahrensablauf-core";
let lastResponse: DeadlineResponse | null = null;
// User overrides for individual rule due-dates (rule.code → YYYY-MM-DD).
// Set by the click-to-edit affordance on each timeline / column row;
// posted as `anchorOverrides` to /api/tools/fristenrechner so downstream
// rules re-anchor on the user's date instead of the calculator's
// projection. Cleared whenever the trigger changes (proceeding type,
// trigger date, flag toggle) so a fresh calc starts unanchored.
const anchorOverrides = new Map<string, string>();
function clearAnchorOverrides() {
anchorOverrides.clear();
}
// Auto-calc plumbing: a sequence counter prevents stale fetches from clobbering
// fresher results, and a single timer debounces rapid input changes.
let procCalcSeq = 0;
let procCalcTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleProcCalc(delayMs = 200) {
if (procCalcTimer !== null) clearTimeout(procCalcTimer);
procCalcTimer = setTimeout(() => {
procCalcTimer = null;
void calculate();
}, delayMs);
}
type ProcedureView = "timeline" | "columns";
// m's 2026-05-08 18:26 ask: Column view by default, Timeline opt-in.
// The proactive / court / reactive grid reads more naturally for the
// HLC team than the single vertical line.
let procedureView: ProcedureView = "columns";
// Notes toggle — off by default; per-rule notes render as a compact
// ⓘ hover icon. Flipped on, they expand under each card. Choice is
// localStorage-persisted (paliad.fristen.notes-show key shared with
// /tools/verfahrensablauf so the preference carries across both).
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();
onLangChange(() => {
if (lastResponse) renderProcedureResults(lastResponse);
// Update trigger event name if a proceeding is selected
const activeBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
if (activeBtn) {
const name = activeBtn.querySelector("strong")?.textContent || "";
document.getElementById("trigger-event")!.textContent = name;
}
});
// formatDate / partyBadge / formatDateSpan / localizeVacationName /
// localizeWeekday / renderAdjustmentReason / formatAdjustedNote moved to
// ./views/verfahrensablauf-core so /tools/verfahrensablauf can share them.
// (t-paliad-179 Slice 1)
// Short date span like "27.7.28.8." (DE) or "27 Jul 28 Aug" (EN). Used in
// the vacation adjustment label, where the explicit weekday + year would
// just be noise — the surrounding sentence carries the full year via the
// dueDate / originalDate that the note brackets.
// Vacation names come straight from paliad.holidays (e.g. "UPC judicial
// vacation"). The Fristenrechner doesn't translate them: they're proper
// names of court-set closures, not generic strings, and rotating them via
// i18n.ts duplicates state that should live in the DB. Rename in the seed
// if the wording needs to change.
// Backend-shaped reason → human-readable phrase ("UPC-Gerichtsferien
// (27.7.28.8.)" / "Karfreitag holiday" / "Wochenende"). See t-paliad-119.
// "Verschoben wegen X: A → B" (DE) / "Shifted (X): A → B" (EN). Falls back
// to the legacy "Wochenende/Feiertag" string when the backend hasn't sent a
// structured reason — keeps older API responses readable.
let selectedType = "";
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";
}
const resetBtn = document.getElementById("reset-btn")!;
resetBtn.style.display = n > 1 ? "block" : "none";
}
async function calculate() {
const seq = ++procCalcSeq;
const dateInput = document.getElementById("trigger-date") as HTMLInputElement;
const triggerDate = dateInput.value;
if (!triggerDate || !selectedType) return;
// Priority date — only meaningful for epa.grant.exa (Art. 93 EPÜ publish-anchor).
const priorityInput = document.getElementById("priority-date") as HTMLInputElement | null;
const priorityDate = selectedType === "epa.grant.exa" && priorityInput?.value ? priorityInput.value : "";
// Flags — proceeding-specific checkboxes:
// upc.inf.cfi: with_ccr (always available); with_amend (nested under
// with_ccr — R.30 application is only available with a CCR).
// upc.rev.cfi: with_amend (R.49.2.a) and with_cci (R.49.2.b) as two
// independent gates; both can be on simultaneously.
// R.19 Einspruch is NOT flag-gated (mig 098, m's 2026-05-18 call): it's
// an always-available optional submission, surfaced as priority='optional'
// without a separate checkbox.
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
const infAmendFlag = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
const revAmendFlag = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
const revCciFlag = document.getElementById("rev-cci-flag") as HTMLInputElement | null;
const flags: string[] = [];
if (selectedType === "upc.inf.cfi") {
if (ccrFlag?.checked) flags.push("with_ccr");
if (ccrFlag?.checked && infAmendFlag?.checked) flags.push("with_amend");
}
if (selectedType === "upc.rev.cfi") {
if (revAmendFlag?.checked) flags.push("with_amend");
if (revCciFlag?.checked) flags.push("with_cci");
}
// Forward any user-set per-rule date overrides so downstream rules
// re-anchor off them. Empty map → omitted from the payload.
const overrides: Record<string, string> = {};
for (const [code, date] of anchorOverrides) overrides[code] = date;
// Court picker — only meaningful when the picker row is visible
// (multi-court proceeding types). When hidden, server resolves the
// default for the proceeding's jurisdiction.
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 data = await calculateDeadlines({
proceedingType: selectedType,
triggerDate,
priorityDate,
flags,
anchorOverrides: overrides,
courtId,
});
if (seq !== procCalcSeq) return;
if (!data) return;
lastResponse = data;
renderProcedureResults(data);
showStep(3);
}
interface ProjectOption {
id: string;
reference?: string | null;
title: string;
path: string;
// proceeding_type_id is on every paliad.projects row; the JSON
// already includes it. We just declare the field so the Determinator
// (Slice 3b) can scope the cascade by the project's jurisdiction
// without an extra fetch.
proceeding_type_id?: number | null;
// our_side carries which side the firm represents on this case
// project (Client Role; t-paliad-164, widened in t-paliad-222).
// When a user selects an Akte, the perspective chip pre-locks via
// ourSideToPerspective(); a small hint above the strip flags the
// pre-selection and the user can still click another chip to
// override. NULL/undefined leaves the chip unset (free-pick).
our_side?:
| "claimant"
| "defendant"
| "applicant"
| "appellant"
| "respondent"
| "third_party"
| "other"
| null;
}
async function fetchProjects(): Promise<ProjectOption[]> {
try {
const resp = await fetch("/api/projects");
if (!resp.ok) return [];
return (await resp.json()) as ProjectOption[];
} catch {
return [];
}
}
function ensureSaveModal() {
if (document.getElementById("frist-save-modal")) return;
const modal = document.createElement("div");
modal.id = "frist-save-modal";
modal.className = "modal-overlay";
modal.style.display = "none";
modal.innerHTML = `
<div class="modal-card">
<div class="modal-header">
<h2 data-i18n="deadlines.save.modal.title">${escHtml(t("deadlines.save.modal.title"))}</h2>
<button class="modal-close" id="frist-save-modal-close" type="button">&times;</button>
</div>
<div class="form-field">
<label for="frist-save-project" data-i18n="deadlines.save.modal.akte">${escHtml(t("deadlines.save.modal.akte"))}</label>
<select id="frist-save-project"></select>
<p class="form-hint" id="frist-save-no-akten" style="display:none">
<span data-i18n="deadlines.save.modal.no_akten">${escHtml(t("deadlines.save.modal.no_akten"))}</span>
<a href="/projects/new" data-i18n="deadlines.save.modal.no_akten.link">${escHtml(t("deadlines.save.modal.no_akten.link"))}</a>
</p>
</div>
<div class="form-field">
<p data-i18n="deadlines.save.modal.choose">${escHtml(t("deadlines.save.modal.choose"))}</p>
<ul class="frist-save-list" id="frist-save-list"></ul>
<p class="form-hint" data-i18n="deadlines.save.skip_court_set">${escHtml(t("deadlines.save.skip_court_set"))}</p>
</div>
<p class="form-msg" id="frist-save-msg"></p>
<div class="form-actions">
<button type="button" class="btn-cancel" id="frist-save-cancel" data-i18n="deadlines.save.modal.cancel">${escHtml(t("deadlines.save.modal.cancel"))}</button>
<button type="button" class="btn-primary btn-cta-lime" id="frist-save-submit" data-i18n="deadlines.save.modal.submit">${escHtml(t("deadlines.save.modal.submit"))}</button>
</div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeSaveModal();
});
document.getElementById("frist-save-modal-close")!.addEventListener("click", closeSaveModal);
document.getElementById("frist-save-cancel")!.addEventListener("click", closeSaveModal);
document.getElementById("frist-save-submit")!.addEventListener("click", submitSave);
}
function closeSaveModal() {
const modal = document.getElementById("frist-save-modal");
if (modal) modal.style.display = "none";
}
// preselectedProjectId returns the project the user picked in Step 1
// (if any) so the various save/add flows can default their project
// pickers to it. Carries through anywhere a "save to Akte" pop-out
// renders \u2014 preselection is *only* a default; the picker still
// renders every available project and the user can override.
// m/paliad#57 part 1: 2026-05-20 user complaint \u2014 "the pre-selected
// project should be pre-selected" on Add.
function preselectedProjectId(): string {
return currentStep1Context.kind === "project" && currentStep1Context.projectId
? currentStep1Context.projectId
: "";
}
async function openSaveModal() {
if (!lastResponse) return;
ensureSaveModal();
const projects = await fetchProjects();
const sel = document.getElementById("frist-save-project") as HTMLSelectElement;
const noProjects = document.getElementById("frist-save-no-akten")!;
const submit = document.getElementById("frist-save-submit") as HTMLButtonElement;
if (projects.length === 0) {
sel.style.display = "none";
noProjects.style.display = "";
submit.disabled = true;
} else {
sel.style.display = "";
noProjects.style.display = "none";
submit.disabled = false;
const preselected = preselectedProjectId();
sel.innerHTML = projects
.map((p) => {
const ref = (p.reference || "").trim();
const indent = projectIndent(p.path);
const label = ref
? `${indent}${escHtml(ref)} \u2014 ${escHtml(p.title)}`
: `${indent}${escHtml(p.title)}`;
const selected = p.id === preselected ? " selected" : "";
return `<option value="${escAttr(p.id)}"${selected}>${label}</option>`;
})
.join("");
if (preselected) sel.value = preselected;
}
const list = document.getElementById("frist-save-list")!;
list.innerHTML = lastResponse.deadlines
.map((dl, idx) => {
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
// Court-determined entries (interim conference, oral hearing, decision,
// any party=court row) have no calculable date — disable + pre-uncheck
// so users don't save the trigger-date placeholder as a real deadline.
const isCourtDetermined = dl.isCourtSet || dl.party === "court";
// Phase 3 Slice 8 (t-paliad-189) wire-shape swap: priority drives
// the save-modal pre-check + the "no save action" notice-card
// render. priorityRendering falls back to the legacy
// (isMandatory, isOptional) pair semantic for pre-Slice-8
// responses; new responses carry `priority` directly.
const pr = priorityRendering(dl);
if (pr.hideSave) {
// informational rules render as notice cards — no checkbox, no
// save button, distinct visual tier. The 18 F/F filing rules
// (Berufungserwiderung, Replik, Duplik, R.19, R.116 EPÜ, etc.)
// currently fall here once they're flipped to 'informational' by
// editorial review; today they're 'recommended' so this branch
// remains exercised only by future rule edits.
return `<li class="frist-save-row frist-save-row--notice">
<span class="frist-save-notice-label">${escHtml(t("deadlines.priority.informational.notice_label"))}</span>
<span class="frist-save-title">${escHtml(dlName)}</span>
<span class="frist-save-meta">${escHtml(t("deadlines.priority.informational"))}</span>
</li>`;
}
const disabled = isCourtDetermined || !dl.dueDate;
const checked = !disabled && pr.preChecked;
// Same direct-vs-indirect split as the timeline date cell —
// chained court-set rules read as "unbestimmt" rather than
// "wird vom Gericht bestimmt".
const courtLabelKey = dl.isCourtSetIndirect ? "deadlines.court.indirect" : "deadlines.court.set";
const optionalBadge = dl.priority === "optional" && !isCourtDetermined
? `<span class="frist-save-optional">${escHtml(t("deadlines.optional.badge"))}</span>`
: "";
const meta = isCourtDetermined
? `<span class="frist-save-meta">${escHtml(t(courtLabelKey))}</span>`
: `<span class="frist-save-meta">${escHtml(formatDate(dl.dueDate))}${optionalBadge}</span>`;
return `<li class="frist-save-row">
<label>
<input type="checkbox" data-idx="${idx}" ${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
<span class="frist-save-title">${escHtml(dlName)}</span>
${meta}
</label>
</li>`;
})
.join("");
document.getElementById("frist-save-msg")!.textContent = "";
document.getElementById("frist-save-modal")!.style.display = "flex";
}
async function submitSave() {
if (!lastResponse) return;
const sel = document.getElementById("frist-save-project") as HTMLSelectElement;
const projectID = sel.value;
const submit = document.getElementById("frist-save-submit") as HTMLButtonElement;
const msg = document.getElementById("frist-save-msg")!;
if (!projectID) return;
const checks = document.querySelectorAll<HTMLInputElement>("#frist-save-list input[type=checkbox]");
const deadlinesPayload: Array<Record<string, unknown>> = [];
checks.forEach((cb) => {
if (!cb.checked || cb.disabled) return;
const idx = Number(cb.dataset.idx);
const dl = lastResponse!.deadlines[idx];
if (!dl || !dl.dueDate) return;
const isEN = getLang() === "en";
const dlName = isEN ? dl.nameEN : dl.name;
const dlNotes = isEN ? (dl.notesEN || dl.notes) : dl.notes;
deadlinesPayload.push({
title: dlName,
rule_code: dl.ruleRef || undefined,
due_date: dl.dueDate,
original_due_date: dl.originalDate || undefined,
source: "fristenrechner",
notes: dlNotes || undefined,
});
});
if (deadlinesPayload.length === 0) return;
submit.disabled = true;
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}/deadlines/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deadlines: deadlinesPayload }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = data.error || t("deadlines.save.error");
msg.className = "form-msg form-msg-error";
submit.disabled = false;
return;
}
msg.innerHTML = `${escHtml(t("deadlines.save.success"))} <a href="/deadlines?project_id=${encodeURIComponent(projectID)}">${escHtml(t("deadlines.save.success.link"))}</a>`;
msg.className = "form-msg form-msg-ok";
// Re-enable after a short delay so user can read it; modal stays open with the link.
setTimeout(() => {
submit.disabled = false;
}, 1500);
} catch {
msg.textContent = t("deadlines.save.error");
msg.className = "form-msg form-msg-error";
submit.disabled = false;
}
}
// Render the result panel using whichever view is currently active. The
// timeline view is the historical default; the columns view (t-paliad-127)
// arranges deadlines into Proactive / Court / Reactive vertical lanes so the
// reader can see who acts when across the whole proceeding.
function renderProcedureResults(data: DeadlineResponse) {
const container = document.getElementById("timeline-container")!;
const printBtn = document.getElementById("fristen-print-btn")!;
const saveBtn = document.getElementById("fristen-save-cta") as HTMLButtonElement | null;
const toggle = document.getElementById("fristen-view-toggle");
const procName = 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>`;
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, { editable: true, showNotes })
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
container.innerHTML = headerHtml + bodyHtml;
printBtn.style.display = "block";
if (saveBtn) {
// Ad-hoc explore-mode has no project to save against — show the
// CTA disabled with a hint so the user understands why the action
// is blocked (m's 2026-05-08 Slice 1 lock #2). Hiding it would
// leave the user wondering where the save went.
saveBtn.style.display = "block";
if (isAdhocMode()) {
saveBtn.disabled = true;
saveBtn.title = t("deadlines.save.cta.adhoc.hint");
saveBtn.dataset.adhocDisabled = "true";
} else {
saveBtn.disabled = false;
saveBtn.removeAttribute("title");
delete saveBtn.dataset.adhocDisabled;
}
}
if (toggle) toggle.style.display = "";
applyPendingFocus();
}
// onDateEditCommit is the click-to-edit callback handed to the shared
// wireDateEditClicks() helper: persist the per-rule override (empty value
// clears it) then recompute so downstream rules re-anchor.
function onDateEditCommit(ruleCode: string, newValue: string) {
if (newValue === "") {
anchorOverrides.delete(ruleCode);
} else {
anchorOverrides.set(ruleCode, newValue);
}
void calculate();
}
// deadlineCardHtml / renderTimelineBody / renderColumnsBody /
// openInlineDateEditor / wireDateEditClicks moved to
// ./views/verfahrensablauf-core.
function reset() {
selectedType = "";
lastResponse = null;
clearAnchorOverrides();
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
document.getElementById("timeline-container")!.innerHTML = "";
document.getElementById("fristen-print-btn")!.style.display = "none";
const saveBtn = document.getElementById("fristen-save-cta");
if (saveBtn) saveBtn.style.display = "none";
const toggle = document.getElementById("fristen-view-toggle");
if (toggle) toggle.style.display = "none";
setProceedingPickerCollapsed(false);
showStep(1);
}
// Collapses / expands the four-group proceeding-picker block. Once the
// user picks, the picker collapses to a one-line summary with a
// Reselect button so the wizard's vertical real-estate goes to the
// trigger date + flags + result. m's 2026-05-08 18:26 ask.
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;
}
function selectProceeding(btn: HTMLButtonElement) {
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
// Different proceeding tree → previous overrides reference codes that
// don't exist in the new tree. Clear before the next calc.
if (selectedType !== btn.dataset.code) clearAnchorOverrides();
selectedType = btn.dataset.code!;
// Update trigger event name
const name = btn.querySelector("strong")?.textContent || "";
document.getElementById("trigger-event")!.textContent = name;
// Conditional inputs:
// priority-date → epa.grant.exa
// ccr-flag → upc.inf.cfi only
// inf-amend-flag → upc.inf.cfi only, but disabled until ccr-flag is on
// (R.30 amend only available with a CCR)
// rev-amend-flag → upc.rev.cfi only
// rev-cci-flag → upc.rev.cfi only
const priorityRow = document.getElementById("priority-date-row");
if (priorityRow) priorityRow.style.display = selectedType === "epa.grant.exa" ? "" : "none";
const ccrRow = document.getElementById("ccr-flag-row");
if (ccrRow) ccrRow.style.display = selectedType === "upc.inf.cfi" ? "" : "none";
const infAmendRow = document.getElementById("inf-amend-flag-row");
if (infAmendRow) infAmendRow.style.display = selectedType === "upc.inf.cfi" ? "" : "none";
const revAmendRow = document.getElementById("rev-amend-flag-row");
if (revAmendRow) revAmendRow.style.display = selectedType === "upc.rev.cfi" ? "" : "none";
const revCciRow = document.getElementById("rev-cci-flag-row");
if (revCciRow) revCciRow.style.display = selectedType === "upc.rev.cfi" ? "" : "none";
syncInfAmendEnabled();
populateCourtPickerCore("court-picker-row", "court-picker", selectedType);
// Hide the four group blocks; show the compact summary in their place.
setProceedingPickerCollapsed(true, name);
showStep(2);
scheduleProcCalc(0);
}
// Court-picker primitives (CourtRow / courtCache / courtTypesFor /
// defaultCourtFor / fetchCourts / populateCourtPicker) moved to
// ./views/verfahrensablauf-core (t-paliad-179 Slice 1).
// is filed within the Defence to CCR). When ccr-flag flips off, also
// untick inf-amend-flag so the calc payload stays coherent.
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;
}
// View toggle wiring. Persist the choice in `?view=…` so reload / share-link
// restores the same layout.
function initViewToggle() {
const toggle = document.getElementById("fristen-view-toggle");
if (!toggle) return;
// Read initial state from URL. Default flipped (m 2026-05-08 18:26):
// Column is the default; ?view=timeline opts back into the legacy
// single-column layout. URL only carries the value when it differs
// from the default so share links stay clean.
const initial = new URLSearchParams(window.location.search).get("view");
if (initial === "timeline") procedureView = "timeline";
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";
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) renderProcedureResults(lastResponse);
});
});
// Hidden until step 3 renders.
toggle.style.display = "none";
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
// Proceeding type selection
document.querySelectorAll<HTMLButtonElement>(".proceeding-btn").forEach((btn) => {
btn.addEventListener("click", () => selectProceeding(btn));
});
// Reselect: re-expand the picker without throwing away the rest of
// the wizard state. Trigger date / flags / result stay visible until
// the user actually picks a new proceeding type.
document.getElementById("proceeding-summary-reselect")?.addEventListener("click", () => {
setProceedingPickerCollapsed(false);
});
// Calculate button — manual force-recalc affordance.
document.getElementById("calculate-btn")!.addEventListener("click", () => scheduleProcCalc(0));
// Auto-recalc on input changes. Date `change` covers picker and blur;
// `input` covers manual typing. The Enter key on the date field bypasses
// debounce for keyboard-savvy users.
const dateInput = document.getElementById("trigger-date") as HTMLInputElement;
dateInput.addEventListener("change", () => scheduleProcCalc());
dateInput.addEventListener("input", () => scheduleProcCalc());
dateInput.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") scheduleProcCalc(0);
});
const priorityInput = document.getElementById("priority-date") as HTMLInputElement | null;
if (priorityInput) {
priorityInput.addEventListener("change", () => scheduleProcCalc());
priorityInput.addEventListener("input", () => scheduleProcCalc());
}
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
if (ccrFlag) ccrFlag.addEventListener("change", () => {
syncInfAmendEnabled();
scheduleProcCalc(0);
});
const infAmendFlag = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
if (infAmendFlag) infAmendFlag.addEventListener("change", () => scheduleProcCalc(0));
const revAmendFlag = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
if (revAmendFlag) revAmendFlag.addEventListener("change", () => scheduleProcCalc(0));
const revCciFlag = document.getElementById("rev-cci-flag") as HTMLInputElement | null;
if (revCciFlag) revCciFlag.addEventListener("change", () => scheduleProcCalc(0));
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
if (courtPicker) courtPicker.addEventListener("change", () => scheduleProcCalc(0));
// Click-to-edit on timeline / column dates: open an inline date input
// and persist the user's choice as an anchor override so downstream
// rules re-anchor on the user's date. Delegated on the container so
// it survives renderProcedureResults() innerHTML rewrites.
const timelineContainer = document.getElementById("timeline-container");
if (timelineContainer) wireDateEditClicks(timelineContainer, onDateEditCommit);
// Reset button
document.getElementById("reset-btn")!.addEventListener("click", reset);
// Print button
document.getElementById("fristen-print-btn")!.addEventListener("click", () => window.print());
// Save-to-Project CTA (Phase E)
const saveBtn = document.getElementById("fristen-save-cta");
if (saveBtn) saveBtn.addEventListener("click", openSaveModal);
// 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) renderProcedureResults(lastResponse);
});
}
// View toggle (timeline vs. columns layout) for procedure mode.
initViewToggle();
// Tab switching between "Verfahrensablauf" and "Was kommt nach…" modes.
initModeTabs();
// Event-mode wiring (PR-2: youpc-parity trigger-event lookup)
initEventMode();
// Pre-select the first proceeding button so the deadline list renders
// immediately on page load — no click on "Fristen berechnen" required.
// This also fires the first auto-calc via scheduleProcCalc().
const firstBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
if (firstBtn) selectProceeding(firstBtn);
});
// ============================================================================
// Mode tabs (procedure vs event)
// ============================================================================
function initModeTabs() {
const tabs = document.querySelectorAll<HTMLButtonElement>(".mode-tab");
if (tabs.length === 0) return;
tabs.forEach((tab) => {
tab.addEventListener("click", () => {
const mode = tab.dataset.mode;
if (!mode) return;
tabs.forEach((t) => {
const isActive = t === tab;
t.classList.toggle("is-active", isActive);
t.setAttribute("aria-selected", String(isActive));
});
document.querySelectorAll<HTMLElement>(".mode-panel").forEach((panel) => {
panel.hidden = panel.dataset.mode !== mode;
});
// Auto-calc on tab activation. Procedure mode self-bootstraps via the
// pre-selected proceeding button on init, so the only special case is
// event mode: pick a default trigger event the first time the tab is
// shown so step 3 isn't empty.
if (mode === "event") ensureDefaultTriggerEvent();
});
});
}
// ============================================================================
// Event mode: pick a trigger event, see all deadlines that flow from it.
// Mirrors youpc.org's deadline calculator. Uses /api/tools/trigger-events
// (list) and /api/tools/event-deadlines (compute).
// ============================================================================
interface TriggerEventSummary {
id: number;
code: string;
name: string;
name_de: string;
}
interface EventDeadlineResult {
id: number;
title: string;
titleDE: string;
durationValue: number;
durationUnit: string;
timing: string;
notes?: string;
notesEN?: string;
ruleCodes: string[];
dueDate: string;
originalDueDate: string;
wasAdjusted: boolean;
isComposite?: boolean;
compositeNote?: string;
}
interface EventCalculateResponse {
triggerEvent: TriggerEventSummary;
triggerDate: string;
deadlines: EventDeadlineResult[];
}
let triggerEvents: TriggerEventSummary[] = [];
let selectedTrigger: TriggerEventSummary | null = null;
let lastEventResponse: EventCalculateResponse | null = null;
// Auto-calc plumbing for event mode — same shape as procedure mode.
let eventCalcSeq = 0;
let eventCalcTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleEventCalc(delayMs = 200) {
if (eventCalcTimer !== null) clearTimeout(eventCalcTimer);
eventCalcTimer = setTimeout(() => {
eventCalcTimer = null;
void calculateEvent();
}, delayMs);
}
function showEventStep(n: number) {
for (let i = 1; i <= 3; i++) {
const el = document.getElementById(`event-step-${i}`);
if (el) el.style.display = i <= n ? "block" : "none";
}
const resetBtn = document.getElementById("event-reset-btn");
if (resetBtn) resetBtn.style.display = n > 1 ? "block" : "none";
}
function eventName(ev: TriggerEventSummary): string {
// Fall back to English when name_de is empty (default seed has empty DE).
return getLang() === "de" && ev.name_de ? ev.name_de : ev.name;
}
function deadlineTitle(d: EventDeadlineResult): string {
return getLang() === "de" && d.titleDE ? d.titleDE : d.title;
}
function unitLabel(unit: string, value: number): string {
const key = `deadlines.event.unit.${unit}` + (value === 1 ? ".one" : ".many");
return tDyn(key);
}
function timingLabel(timing: string): string {
return tDyn(`deadlines.event.timing.${timing}`);
}
function renderEventList(query: string) {
const list = document.getElementById("event-list");
if (!list) return;
const q = query.trim().toLowerCase();
const matches = q
? triggerEvents.filter(
(ev) =>
ev.name.toLowerCase().includes(q) ||
(ev.name_de && ev.name_de.toLowerCase().includes(q)) ||
ev.code.toLowerCase().includes(q),
)
: triggerEvents;
if (matches.length === 0) {
list.innerHTML = `<li class="event-list-empty">${escHtml(t("deadlines.event.empty"))}</li>`;
return;
}
list.innerHTML = matches
.map(
(ev) =>
`<li class="event-list-item" role="option" data-id="${ev.id}" tabindex="0">${escHtml(eventName(ev))}</li>`,
)
.join("");
}
async function loadTriggerEvents() {
const list = document.getElementById("event-list");
if (!list) return;
list.innerHTML = `<li class="event-list-empty">${escHtml(t("deadlines.event.loading"))}</li>`;
try {
const resp = await fetch("/api/tools/trigger-events");
if (!resp.ok) {
list.innerHTML = `<li class="event-list-empty">${escHtml(t("deadlines.event.error"))}</li>`;
return;
}
triggerEvents = (await resp.json()) as TriggerEventSummary[];
renderEventList("");
// If the user already switched to the event tab while the list was
// loading, pre-select now so they don't see an empty step 1.
const eventTab = document.getElementById("mode-event-tab");
if (eventTab?.classList.contains("is-active")) ensureDefaultTriggerEvent();
} catch {
list.innerHTML = `<li class="event-list-empty">${escHtml(t("deadlines.event.error"))}</li>`;
}
}
function selectTriggerEvent(id: number) {
const ev = triggerEvents.find((e) => e.id === id);
if (!ev) return;
selectedTrigger = ev;
const nameEl = document.getElementById("event-selected-name");
if (nameEl) nameEl.textContent = eventName(ev);
showEventStep(2);
scheduleEventCalc(0);
}
async function calculateEvent() {
const seq = ++eventCalcSeq;
if (!selectedTrigger) return;
const dateInput = document.getElementById("event-date") as HTMLInputElement | null;
const triggerDate = dateInput?.value;
if (!triggerDate) return;
try {
const resp = await fetch("/api/tools/event-deadlines", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ triggerEventId: selectedTrigger.id, triggerDate }),
});
if (seq !== eventCalcSeq) return;
if (!resp.ok) {
const err = await resp.json().catch(() => ({}));
console.error("event API error:", err);
return;
}
const data = (await resp.json()) as EventCalculateResponse;
if (seq !== eventCalcSeq) return;
lastEventResponse = data;
renderEventResults(data);
showEventStep(3);
} catch (e) {
console.error("event fetch error:", e);
}
}
function renderEventResults(data: EventCalculateResponse) {
const container = document.getElementById("event-results-container");
const printBtn = document.getElementById("event-print-btn");
if (!container) return;
if (data.deadlines.length === 0) {
container.innerHTML = `<p class="event-results-empty">${escHtml(t("deadlines.event.noresults"))}</p>`;
if (printBtn) printBtn.style.display = "none";
return;
}
const triggerName = eventName(data.triggerEvent);
const triggerDate = formatDate(data.triggerDate);
const rowsHtml = data.deadlines
.map((d) => {
const codes = d.ruleCodes
.map((c) => `<span class="event-rule-code">${escHtml(c)}</span>`)
.join(" ");
const original = d.wasAdjusted
? `<div class="event-result-adjusted">${escHtml(
tDyn("deadlines.event.adjusted") + " " + formatDate(d.originalDueDate),
)}</div>`
: "";
const composite = d.isComposite && d.compositeNote
? `<div class="event-result-composite" title="${escAttr(d.compositeNote)}">${escHtml(t("deadlines.event.composite.label"))} ${escHtml(d.compositeNote)}</div>`
: "";
const noteText = getLang() === "en" ? (d.notesEN || d.notes) : d.notes;
const notes = noteText
? `<div class="event-result-notes">${escHtml(noteText)}</div>`
: "";
return `<li class="event-result-row">
<div class="event-result-header">
<span class="event-result-title">${escHtml(deadlineTitle(d))}</span>
<span class="event-result-date">${escHtml(formatDate(d.dueDate))}</span>
</div>
<div class="event-result-meta">
<span class="event-result-duration">${d.durationValue} ${escHtml(unitLabel(d.durationUnit, d.durationValue))} ${escHtml(timingLabel(d.timing))}</span>
${codes}
</div>
${composite}
${original}
${notes}
</li>`;
})
.join("");
container.innerHTML = `
<div class="event-results-header">
<div><strong>${escHtml(t("deadlines.event.results.trigger"))}</strong> ${escHtml(triggerName)}</div>
<div><strong>${escHtml(t("deadlines.event.results.date"))}</strong> ${escHtml(triggerDate)}</div>
</div>
<ul class="event-result-list">${rowsHtml}</ul>`;
if (printBtn) printBtn.style.display = "block";
}
function resetEventMode() {
selectedTrigger = null;
lastEventResponse = null;
const search = document.getElementById("event-search") as HTMLInputElement | null;
if (search) search.value = "";
renderEventList("");
showEventStep(1);
}
function initEventMode() {
const search = document.getElementById("event-search") as HTMLInputElement | null;
if (!search) return; // page didn't render the event mode (older bundle)
loadTriggerEvents();
search.addEventListener("input", () => renderEventList(search.value));
const list = document.getElementById("event-list");
if (list) {
list.addEventListener("click", (e) => {
const target = (e.target as HTMLElement).closest<HTMLLIElement>(".event-list-item");
if (!target) return;
const id = Number(target.dataset.id);
if (!Number.isFinite(id)) return;
selectTriggerEvent(id);
});
list.addEventListener("keydown", (e) => {
const ke = e as KeyboardEvent;
if (ke.key !== "Enter" && ke.key !== " ") return;
const target = (ke.target as HTMLElement).closest<HTMLLIElement>(".event-list-item");
if (!target) return;
ke.preventDefault();
const id = Number(target.dataset.id);
if (Number.isFinite(id)) selectTriggerEvent(id);
});
}
// Event-tab calculate button — manual force-recalc affordance.
document.getElementById("event-calculate-btn")?.addEventListener("click", () => scheduleEventCalc(0));
// Auto-recalc when the user changes the event date.
const eventDate = document.getElementById("event-date") as HTMLInputElement | null;
if (eventDate) {
eventDate.addEventListener("change", () => scheduleEventCalc());
eventDate.addEventListener("input", () => scheduleEventCalc());
eventDate.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") scheduleEventCalc(0);
});
}
document.getElementById("event-reset-btn")?.addEventListener("click", resetEventMode);
document.getElementById("event-print-btn")?.addEventListener("click", () => window.print());
}
// Pre-select the first trigger event so the "Was kommt nach" tab renders
// immediately with default selection + today's date. Idempotent — only fires
// when there's no existing selection. Called both after the event list loads
// and when the user first activates the event tab, since either one can be
// the trigger depending on which finishes first.
function ensureDefaultTriggerEvent() {
if (selectedTrigger || triggerEvents.length === 0) return;
selectTriggerEvent(triggerEvents[0].id);
}
// Re-render event results when language flips (titles/notes are bilingual).
onLangChange(() => {
if (lastEventResponse) renderEventResults(lastEventResponse);
if (selectedTrigger) {
const nameEl = document.getElementById("event-selected-name");
if (nameEl) nameEl.textContent = eventName(selectedTrigger);
}
if (triggerEvents.length > 0) {
const search = document.getElementById("event-search") as HTMLInputElement | null;
renderEventList(search?.value || "");
}
});
// ============================================================================
// Search bar (t-paliad-131 Phase D) — concept-card UI on top of /api/tools/
// fristenrechner/search. Augments the proceeding tile grid: type a phrase
// (Klageerwiderung, RoP 23, § 82, Wiedereinsetzung), see ranked concept
// cards with one pill per (proceeding × rule) or per cross-cutting trigger.
// Click a pill → drill into the right calculator mode pre-selected.
// ============================================================================
interface SearchProceeding {
code: string;
name_de: string;
name_en: string;
jurisdiction: string;
}
interface SearchPillDuration {
value: number;
unit: string;
timing?: string;
}
interface SearchPill {
kind: "rule" | "trigger";
rule_id?: string;
trigger_event_id?: number;
proceeding?: SearchProceeding;
rule_local_code: string;
rule_name_de: string;
rule_name_en: string;
legal_source?: string;
legal_source_display?: string;
duration?: SearchPillDuration;
party: string;
drill_url: string;
}
interface SearchConcept {
id: string;
slug: string;
name_de: string;
name_en: string;
description?: string;
party?: string;
category: string;
}
interface SearchCard {
concept: SearchConcept;
matched_aliases?: string[];
score: number;
pills: SearchPill[];
}
interface SearchResponse {
query: string;
filters: { party: string | null; proc: string | null; source: string | null };
cards: SearchCard[];
total_cards: number;
total_pills: number;
}
// Debounced search dispatch — see scheduleProcCalc / scheduleEventCalc for
// the same shape used by the existing modes.
let searchSeq = 0;
let searchDebounce: number | undefined;
function scheduleSearch(delayMs = 180) {
if (searchDebounce !== undefined) clearTimeout(searchDebounce);
searchDebounce = window.setTimeout(runSearch, delayMs);
}
async function runSearch() {
const input = document.getElementById("fristen-search-input") as HTMLInputElement | null;
const results = document.getElementById("fristen-search-results") as HTMLDivElement | null;
const clearBtn = document.getElementById("fristen-search-clear") as HTMLButtonElement | null;
if (!input || !results) return;
const q = input.value.trim();
// URL state — single source of truth for shareable searches. Strip the
// param when the input is empty so the URL stays clean.
syncSearchURL(q);
if (clearBtn) clearBtn.hidden = q.length === 0;
if (q === "") {
results.innerHTML = "";
results.classList.remove("is-loading", "is-empty", "is-no-hits");
return;
}
results.classList.remove("is-empty", "is-no-hits");
results.classList.add("is-loading");
results.innerHTML = `<div class="fristen-search-status">${escHtml(t("deadlines.search.loading"))}</div>`;
const seq = ++searchSeq;
let resp: Response;
try {
{
const searchURL = new URL("/api/tools/fristenrechner/search", window.location.origin);
searchURL.searchParams.set("q", q);
searchURL.searchParams.set("limit", "12");
const forums = getActiveForumsParam();
if (forums) searchURL.searchParams.set("forum", forums);
resp = await fetch(searchURL.toString(), { credentials: "same-origin" });
}
} catch {
if (seq !== searchSeq) return;
results.classList.remove("is-loading");
results.innerHTML = `<div class="fristen-search-status fristen-search-error">${escHtml(t("deadlines.search.no_hits"))}</div>`;
return;
}
if (seq !== searchSeq) return;
results.classList.remove("is-loading");
if (!resp.ok) {
results.innerHTML = `<div class="fristen-search-status fristen-search-error">${escHtml(t("deadlines.search.no_hits"))}</div>`;
return;
}
const data = (await resp.json()) as SearchResponse;
if (seq !== searchSeq) return;
renderSearchResults(data);
}
function renderSearchResults(data: SearchResponse) {
renderSearchResultsInto("fristen-search-results", data);
}
// renderSearchResultsInto writes a SearchResponse into the named
// container. Used both by the B2 search bar (target: fristen-search-results)
// and by the B1 decision tree (target: fristen-b1-results, t-paliad-134).
function renderSearchResultsInto(containerId: string, data: SearchResponse) {
const results = document.getElementById(containerId);
if (!results) return;
if (data.cards.length === 0) {
results.classList.add("is-no-hits");
results.innerHTML = `<div class="fristen-search-status">${escHtml(t("deadlines.search.no_hits"))}</div>`;
return;
}
results.classList.remove("is-no-hits");
const lang = getLang();
const countLabel = data.total_cards === 1
? t("deadlines.search.results.count_one")
: t("deadlines.search.results.count").replace("{n}", String(data.total_cards));
const cardsHtml = data.cards.map((c) => renderConceptCard(c, lang)).join("");
results.innerHTML = `
<div class="fristen-search-summary">${escHtml(countLabel)}</div>
<div class="fristen-search-cards">${cardsHtml}</div>`;
}
// wirePillClicks attaches the v4 card-click → inline calc-panel handler
// to a results container. Idempotent across re-renders because the
// listener lives on the container, not on individual pill anchors.
//
// v4 (t-paliad-136 Phase B): the primary click action on ANY card —
// header, pill, or body — expands the card with an inline calc panel
// (trigger-date input + flag checkboxes + computed deadline + add-to-
// project CTA). Modifier-key clicks (Cmd/Ctrl/Shift/middle) preserve
// the legacy drill-to-Pathway-A semantics via the `<a href="…">`
// fallback (browser default new-tab behaviour). Trigger pills still
// drill to the youpc-style trigger-event picker on plain click — those
// concepts (Wiedereinsetzung, Weiterbehandlung) don't have a single
// rule to compute against, so the inline calc panel doesn't apply.
function wirePillClicks(container: HTMLElement) {
if (container.dataset.pillClicksWired === "1") return;
container.dataset.pillClicksWired = "1";
container.addEventListener("click", (e) => {
// Clicks inside an open calc panel are handled by their own
// listeners — do not re-trigger the expand/collapse logic.
if ((e.target as HTMLElement).closest(".fristen-card-calc")) return;
const pill = (e.target as HTMLElement).closest<HTMLAnchorElement>(".fristen-pill");
const card = (e.target as HTMLElement).closest<HTMLElement>(".fristen-card");
if (!card) return;
const me = e as MouseEvent;
// Modifier-key fallback: let `<a href>` open in a new tab / new
// window for users who want to deep-link into Pathway A. Don't
// interfere with normal text selection (no expand on shift-drag).
if (me.metaKey || me.ctrlKey || me.shiftKey || me.button === 1) return;
// Trigger pills drill to the trigger-event picker (legacy youpc
// pathway). The inline calc panel only applies to rule pills.
if (pill && pill.dataset.kind === "trigger") {
e.preventDefault();
const id = Number(pill.dataset.triggerId);
if (Number.isFinite(id)) drillToTrigger(id);
return;
}
e.preventDefault();
expandCardCalc(card, pill);
});
}
// ============================================================================
// v4 card-click → inline calc panel (t-paliad-136 Phase B)
// ============================================================================
// Click a result card → expand inline → user enters trigger date + flags →
// server computes one deadline → user can add it to a project.
//
// Only one card may be expanded at a time (multiple panels would confuse
// "which trigger date am I looking at?"). Collapsing happens automatically
// when another card is clicked, when × is pressed, or when the user clicks
// outside the panel.
interface RuleCalcResponse {
rule: {
id: string;
localCode?: string;
nameDE: string;
nameEN: string;
ruleRef?: string;
legalSource?: string;
legalSourceDisplay?: string;
durationValue: number;
durationUnit: string;
party?: string;
isMandatory: boolean;
notesDE?: string;
notesEN?: string;
};
proceeding: { code: string; nameDE: string; nameEN: string };
triggerDate: string;
originalDate: string;
dueDate: string;
wasAdjusted: boolean;
adjustmentReason?: {
holidays?: Array<{ name: string; date: string }>;
upcVacation?: boolean;
moveToNextWorkday?: boolean;
};
isCourtSet: boolean;
flagsApplied?: string[];
flagsRequired?: string[];
}
let lastCalcByCard: WeakMap<HTMLElement, RuleCalcResponse> = new WeakMap();
let calcDebounce: number | undefined;
let calcSeq = 0;
function collapseAnyExpandedCard() {
document.querySelectorAll<HTMLElement>(".fristen-card.is-expanded").forEach((c) => {
c.classList.remove("is-expanded");
c.setAttribute("aria-expanded", "false");
const panel = c.querySelector<HTMLElement>(".fristen-card-calc");
if (panel) panel.remove();
});
}
function expandCardCalc(card: HTMLElement, autoSelectPill: HTMLElement | null) {
// Click a different card → collapse the current one first.
if (!card.classList.contains("is-expanded")) {
collapseAnyExpandedCard();
} else {
// Already expanded; if the user clicked a different pill, switch
// selection. If they clicked the body again, do nothing.
if (autoSelectPill) selectCalcPill(card, autoSelectPill.dataset.proc, autoSelectPill.dataset.focus);
return;
}
const payload = card.dataset.cardPayload;
if (!payload) return;
let cardData: SearchCard;
try {
cardData = JSON.parse(payload) as SearchCard;
} catch {
return;
}
// Only rule pills are computable. Drop trigger pills from the picker.
const rulePills = cardData.pills.filter((p) => p.kind === "rule");
if (rulePills.length === 0) return;
card.classList.add("is-expanded");
card.setAttribute("aria-expanded", "true");
// m/paliad#57 part 4: when the user clicked a specific rule pill, the
// context is already known — the calc panel renders with that pill
// locked in and no "Which context?" picker. The card's pill list is
// hidden via CSS while is-expanded so the rules aren't listed twice.
// When the user clicked the card body (no autoSelectPill), the picker
// is the primary surface — still no duplicate pill list above it.
const lockedPill = (autoSelectPill && autoSelectPill.dataset.kind === "rule")
? rulePills.find((p) =>
p.proceeding?.code === autoSelectPill.dataset.proc
&& (autoSelectPill.dataset.focus
? p.rule_local_code === autoSelectPill.dataset.focus
: true))
: undefined;
const panel = buildCalcPanel(cardData, rulePills, lockedPill || null);
card.appendChild(panel);
scheduleCardCalc(card);
}
function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[], lockedPill: SearchPill | null = null): HTMLElement {
const panel = document.createElement("div");
panel.className = "fristen-card-calc";
// stopPropagation so clicks inside the panel don't bubble to the
// card-level expand handler.
panel.addEventListener("click", (e) => e.stopPropagation());
panel.addEventListener("keydown", (e) => e.stopPropagation());
const lang = getLang();
const today = new Date().toISOString().split("T")[0];
// Picker semantics (m/paliad#57 part 4):
// - lockedPill set → context known (user clicked a specific
// rule pill on the card). Render as a
// hidden input only; the calc panel shows
// no "Which context?" question. A small
// "ändern" link reopens the picker fieldset.
// - rulePills.length <= 1 → only one possible context, never a
// picker (hidden input carries the data).
// - otherwise → show the picker as primary surface; the
// card's pill list is hidden via CSS while
// the panel is open, so the user isn't
// asked the same thing twice.
let pickerHtml: string;
if (lockedPill) {
const procName = lockedPill.proceeding
? (lang === "en" && lockedPill.proceeding.name_en ? lockedPill.proceeding.name_en : lockedPill.proceeding.name_de)
: "";
const ruleName = lang === "en" && lockedPill.rule_name_en ? lockedPill.rule_name_en : lockedPill.rule_name_de;
const src = lockedPill.legal_source_display || lockedPill.legal_source || "";
const reopenLabel = t("deadlines.card.calc.pill_picker.change");
pickerHtml = `<div class="fristen-card-calc-pill-locked">
<span class="fristen-card-calc-pill-locked-label">${escHtml(t("deadlines.card.calc.pill_picker.locked_label"))}</span>
<span class="fristen-card-calc-pill-locked-proc">${escHtml(procName)}</span>
<span class="fristen-card-calc-pill-locked-rule">${escHtml(ruleName)}</span>
${src ? `<span class="fristen-card-calc-pill-locked-source">${escHtml(src)}</span>` : ""}
${rulePills.length > 1 ? `<button type="button" class="fristen-card-calc-pill-change">${escHtml(reopenLabel)}</button>` : ""}
<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(lockedPill.proceeding?.code || "")}" data-focus="${escAttr(lockedPill.rule_local_code || "")}" />
</div>`;
} else if (rulePills.length <= 1) {
pickerHtml = `<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(rulePills[0].proceeding?.code || "")}" data-focus="${escAttr(rulePills[0].rule_local_code || "")}" />`;
} else {
pickerHtml = `<fieldset class="fristen-card-calc-pill-picker" role="radiogroup">
<legend class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.pill_picker.label"))}</legend>
${rulePills.map((p, i) => {
const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : "";
const ruleName = lang === "en" && p.rule_name_en ? p.rule_name_en : p.rule_name_de;
const src = p.legal_source_display || p.legal_source || "";
return `<label class="fristen-card-calc-pill-option">
<input type="radio" name="fristen-card-calc-pill" value="${i}" ${i === 0 ? "checked" : ""} data-proc="${escAttr(p.proceeding?.code || "")}" data-focus="${escAttr(p.rule_local_code || "")}" />
<span class="fristen-card-calc-pill-option-proc">${escHtml(procName)}</span>
<span class="fristen-card-calc-pill-option-rule">${escHtml(ruleName)}</span>
${src ? `<span class="fristen-card-calc-pill-option-source">${escHtml(src)}</span>` : ""}
</label>`;
}).join("")}
</fieldset>`;
}
panel.innerHTML = `
<button type="button" class="fristen-card-calc-close" aria-label="${escAttr(t("deadlines.card.calc.close"))}">×</button>
${pickerHtml}
<div class="fristen-card-calc-inputs">
<label class="fristen-card-calc-trigger">
<span class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.trigger.label"))}</span>
<input type="date" class="fristen-card-calc-trigger-input" value="${escAttr(today)}" />
</label>
<div class="fristen-card-calc-flags" hidden></div>
</div>
<div class="fristen-card-calc-result" aria-live="polite">
<div class="fristen-card-calc-result-status">${escHtml(t("deadlines.card.calc.result.calculating"))}</div>
</div>
<div class="fristen-card-calc-actions">
<button type="button" class="btn-primary btn-cta-lime fristen-card-calc-add" disabled>${escHtml(t("deadlines.card.calc.add_to_project"))}</button>
</div>
<div class="fristen-card-calc-msg" aria-live="polite"></div>
`;
// Wire interactions.
const close = panel.querySelector<HTMLButtonElement>(".fristen-card-calc-close")!;
close.addEventListener("click", () => {
const card = panel.closest<HTMLElement>(".fristen-card");
if (card) {
card.classList.remove("is-expanded");
card.setAttribute("aria-expanded", "false");
panel.remove();
}
});
const dateInput = panel.querySelector<HTMLInputElement>(".fristen-card-calc-trigger-input")!;
dateInput.addEventListener("input", () => {
const card = panel.closest<HTMLElement>(".fristen-card");
if (card) scheduleCardCalc(card);
});
panel.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]').forEach((r) => {
r.addEventListener("change", () => {
const card = panel.closest<HTMLElement>(".fristen-card");
if (card) scheduleCardCalc(card, 0);
});
});
panel.querySelector<HTMLButtonElement>(".fristen-card-calc-add")!.addEventListener("click", () => {
const card = panel.closest<HTMLElement>(".fristen-card");
if (!card) return;
const last = lastCalcByCard.get(card);
if (!last) return;
void addCalcToProject(card, last);
});
// "ändern" — swap the locked-context caption for the full radio
// picker so the user can change context without collapsing the panel.
panel.querySelector<HTMLButtonElement>(".fristen-card-calc-pill-change")?.addEventListener("click", () => {
const card = panel.closest<HTMLElement>(".fristen-card");
const locked = panel.querySelector<HTMLElement>(".fristen-card-calc-pill-locked");
if (!card || !locked) return;
const fieldset = document.createElement("fieldset");
fieldset.className = "fristen-card-calc-pill-picker";
fieldset.setAttribute("role", "radiogroup");
const lockedProc = locked.querySelector<HTMLInputElement>("input.fristen-card-calc-pill-picker")?.dataset.proc || "";
const lockedFocus = locked.querySelector<HTMLInputElement>("input.fristen-card-calc-pill-picker")?.dataset.focus || "";
fieldset.innerHTML = `
<legend class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.pill_picker.label"))}</legend>
${rulePills.map((p, i) => {
const procName = p.proceeding ? (lang === "en" && p.proceeding.name_en ? p.proceeding.name_en : p.proceeding.name_de) : "";
const ruleName = lang === "en" && p.rule_name_en ? p.rule_name_en : p.rule_name_de;
const src = p.legal_source_display || p.legal_source || "";
const isChecked = (p.proceeding?.code || "") === lockedProc
&& (p.rule_local_code || "") === lockedFocus;
return `<label class="fristen-card-calc-pill-option">
<input type="radio" name="fristen-card-calc-pill" value="${i}" ${isChecked ? "checked" : ""} data-proc="${escAttr(p.proceeding?.code || "")}" data-focus="${escAttr(p.rule_local_code || "")}" />
<span class="fristen-card-calc-pill-option-proc">${escHtml(procName)}</span>
<span class="fristen-card-calc-pill-option-rule">${escHtml(ruleName)}</span>
${src ? `<span class="fristen-card-calc-pill-option-source">${escHtml(src)}</span>` : ""}
</label>`;
}).join("")}`;
locked.replaceWith(fieldset);
fieldset.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]').forEach((r) => {
r.addEventListener("change", () => scheduleCardCalc(card, 0));
});
});
return panel;
}
function selectCalcPill(card: HTMLElement, proc?: string | null, focus?: string | null) {
if (!proc) return;
const radios = card.querySelectorAll<HTMLInputElement>('input[name="fristen-card-calc-pill"]');
radios.forEach((r) => {
if (r.dataset.proc === proc && (!focus || r.dataset.focus === focus)) {
r.checked = true;
r.dispatchEvent(new Event("change", { bubbles: true }));
}
});
}
function scheduleCardCalc(card: HTMLElement, delayMs = 200) {
if (calcDebounce !== undefined) clearTimeout(calcDebounce);
calcDebounce = window.setTimeout(() => void runCardCalc(card), delayMs);
}
async function runCardCalc(card: HTMLElement) {
const panel = card.querySelector<HTMLElement>(".fristen-card-calc");
if (!panel) return;
const result = panel.querySelector<HTMLElement>(".fristen-card-calc-result")!;
const addBtn = panel.querySelector<HTMLButtonElement>(".fristen-card-calc-add")!;
const msgEl = panel.querySelector<HTMLElement>(".fristen-card-calc-msg")!;
const dateInput = panel.querySelector<HTMLInputElement>(".fristen-card-calc-trigger-input")!;
const triggerDate = dateInput.value;
if (!triggerDate) return;
// Resolve currently-selected pill (proc + ruleLocalCode).
let proc = "";
let focus = "";
const checked = panel.querySelector<HTMLInputElement>('input[name="fristen-card-calc-pill"]:checked');
if (checked) {
proc = checked.dataset.proc || "";
focus = checked.dataset.focus || "";
} else {
const hidden = panel.querySelector<HTMLInputElement>(".fristen-card-calc-pill-picker");
if (hidden) {
proc = hidden.dataset.proc || "";
focus = hidden.dataset.focus || "";
}
}
if (!proc || !focus) return;
// Read flag checkboxes.
const flags: string[] = [];
panel.querySelectorAll<HTMLInputElement>('.fristen-card-calc-flags input[type="checkbox"]:checked').forEach((cb) => {
if (cb.value) flags.push(cb.value);
});
result.innerHTML = `<div class="fristen-card-calc-result-status">${escHtml(t("deadlines.card.calc.result.calculating"))}</div>`;
msgEl.textContent = "";
addBtn.disabled = true;
const seq = ++calcSeq;
let resp: Response;
try {
resp = await fetch("/api/tools/fristenrechner/calculate-rule", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
proceedingCode: proc,
ruleLocalCode: focus,
triggerDate,
flags,
}),
});
} catch {
if (seq !== calcSeq) return;
result.innerHTML = `<div class="fristen-card-calc-result-status fristen-card-calc-result-error">${escHtml(t("deadlines.card.calc.result.error"))}</div>`;
return;
}
if (seq !== calcSeq) return;
if (!resp.ok) {
const data = (await resp.json().catch(() => ({} as { error?: string }))) as { error?: string };
result.innerHTML = `<div class="fristen-card-calc-result-status fristen-card-calc-result-error">${escHtml(data.error || t("deadlines.card.calc.result.error"))}</div>`;
return;
}
const calc = (await resp.json()) as RuleCalcResponse;
if (seq !== calcSeq) return;
lastCalcByCard.set(card, calc);
renderCalcResult(card, calc);
syncFlagCheckboxes(card, calc);
addBtn.disabled = calc.isCourtSet || !calc.dueDate;
if (calc.isCourtSet) {
addBtn.textContent = t("deadlines.card.calc.add_to_project.disabled");
} else {
addBtn.textContent = t("deadlines.card.calc.add_to_project");
}
}
function syncFlagCheckboxes(card: HTMLElement, calc: RuleCalcResponse) {
const flagsEl = card.querySelector<HTMLElement>(".fristen-card-calc-flags");
if (!flagsEl) return;
const required = calc.flagsRequired || [];
if (required.length === 0) {
flagsEl.hidden = true;
flagsEl.innerHTML = "";
return;
}
flagsEl.hidden = false;
// Preserve current state when re-rendering: check current DOM for
// existing checkbox values so user input survives a recalc.
const existing = new Map<string, boolean>();
flagsEl.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach((cb) => {
existing.set(cb.value, cb.checked);
});
const labelKey = (flag: string) => {
const k = `deadlines.card.calc.flag.${flag}`;
const localised = tDyn(k);
return localised === k ? flag : localised;
};
flagsEl.innerHTML = `
<span class="fristen-card-calc-label">${escHtml(t("deadlines.card.calc.flags.label"))}</span>
${required.map((f) => {
const checked = existing.get(f) ?? (calc.flagsApplied || []).includes(f);
return `<label class="fristen-card-calc-flag">
<input type="checkbox" value="${escAttr(f)}" ${checked ? "checked" : ""} />
<span>${escHtml(labelKey(f))}</span>
</label>`;
}).join("")}
`;
flagsEl.querySelectorAll<HTMLInputElement>('input[type="checkbox"]').forEach((cb) => {
cb.addEventListener("change", () => scheduleCardCalc(card, 0));
});
}
function renderCalcResult(card: HTMLElement, calc: RuleCalcResponse) {
const result = card.querySelector<HTMLElement>(".fristen-card-calc-result");
if (!result) return;
if (calc.isCourtSet) {
result.innerHTML = `<div class="fristen-card-calc-result-status fristen-card-calc-result-court">${escHtml(t("deadlines.card.calc.result.court_set"))}</div>`;
return;
}
const lang = getLang();
const ruleName = lang === "en" ? calc.rule.nameEN : calc.rule.nameDE;
const durLabel = `${calc.rule.durationValue} ${formatDurationUnit(calc.rule.durationUnit, lang)}`;
const dueLabel = formatDate(calc.dueDate);
const fromLabel = formatDate(calc.triggerDate);
const adjustmentChip = calc.wasAdjusted
? renderAdjustmentChip(calc, lang)
: "";
result.innerHTML = `
<div class="fristen-card-calc-result-row">
<span class="fristen-card-calc-result-arrow" aria-hidden="true">►</span>
<span class="fristen-card-calc-result-due"><strong>${escHtml(dueLabel)}</strong></span>
<span class="fristen-card-calc-result-detail">(${escHtml(durLabel)} ${escHtml(t("deadlines.card.calc.result.from_trigger"))} ${escHtml(fromLabel)})</span>
</div>
${adjustmentChip}
<div class="fristen-card-calc-result-rule">${escHtml(ruleName)}</div>
`;
}
function renderAdjustmentChip(calc: RuleCalcResponse, _lang: "de" | "en"): string {
const reason = calc.adjustmentReason;
let why = "";
if (reason && reason.upcVacation) {
why = "UPC-Sommerferien (27.7.28.8.)";
} else if (reason && reason.holidays && reason.holidays.length > 0) {
why = reason.holidays.map((h) => h.name).join(", ");
} else {
why = "Wochenende / Feiertag";
}
return `<div class="fristen-card-calc-result-shift">
${escHtml(t("deadlines.card.calc.result.shifted_from"))} <strong>${escHtml(formatDate(calc.originalDate))}</strong>
${escHtml(t("deadlines.card.calc.result.shifted_because"))} ${escHtml(why)}.
</div>`;
}
function formatDurationUnit(unit: string, lang: "de" | "en"): string {
const map: Record<string, { de: string; en: string }> = {
days: { de: "Tage", en: "days" },
working_days: { de: "Arbeitstage", en: "working days" },
weeks: { de: "Wochen", en: "weeks" },
months: { de: "Monate", en: "months" },
years: { de: "Jahre", en: "years" },
};
return map[unit] ? map[unit][lang] : unit;
}
async function addCalcToProject(card: HTMLElement, calc: RuleCalcResponse) {
const panel = card.querySelector<HTMLElement>(".fristen-card-calc");
if (!panel) return;
const msgEl = panel.querySelector<HTMLElement>(".fristen-card-calc-msg")!;
const addBtn = panel.querySelector<HTMLButtonElement>(".fristen-card-calc-add")!;
msgEl.textContent = "";
addBtn.disabled = true;
const projects = await fetchProjects();
if (projects.length === 0) {
addBtn.disabled = false;
msgEl.innerHTML = `<span class="form-msg form-msg-error">${escHtml(t("deadlines.save.modal.no_akten"))}</span> <a href="/projects/new">${escHtml(t("deadlines.save.modal.no_akten.link"))}</a>`;
return;
}
// Inline picker — render a compact <select> + Confirm button under
// the result. Keeps the user inside the card; no full modal needed.
const lang = getLang();
const ruleName = lang === "en" ? calc.rule.nameEN : calc.rule.nameDE;
const dueLabel = formatDate(calc.dueDate);
const preselected = preselectedProjectId();
msgEl.innerHTML = `
<div class="fristen-card-calc-add-picker">
<label class="fristen-card-calc-label">${escHtml(t("deadlines.save.modal.akte"))}
<select class="fristen-card-calc-add-select">
${projects.map((p) => {
const ref = (p.reference || "").trim();
const indent = projectIndent(p.path);
const label = ref ? `${indent}${ref}${p.title}` : `${indent}${p.title}`;
const selected = p.id === preselected ? " selected" : "";
return `<option value="${escAttr(p.id)}"${selected}>${escHtml(label)}</option>`;
}).join("")}
</select>
</label>
<button type="button" class="btn-primary btn-cta-lime fristen-card-calc-add-confirm">${escHtml(t("deadlines.save.modal.submit"))}</button>
<button type="button" class="btn-cancel fristen-card-calc-add-cancel">${escHtml(t("deadlines.save.modal.cancel"))}</button>
</div>
`;
const sel = msgEl.querySelector<HTMLSelectElement>(".fristen-card-calc-add-select")!;
if (preselected) sel.value = preselected;
msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-cancel")!.addEventListener("click", () => {
msgEl.innerHTML = "";
addBtn.disabled = false;
});
msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-confirm")!.addEventListener("click", async () => {
const projectID = sel.value;
if (!projectID) return;
const dlNotes = lang === "en"
? (calc.rule.notesEN || calc.rule.notesDE)
: calc.rule.notesDE;
const payload = {
deadlines: [{
title: ruleName,
rule_code: calc.rule.ruleRef || undefined,
due_date: calc.dueDate,
original_due_date: calc.originalDate || undefined,
// m's Q2 (2026-05-05): use 'fristenrechner' (existing tag), not
// 'fristenrechner_card'. Audit-log differentiation is not needed.
source: "fristenrechner",
notes: dlNotes || undefined,
}],
};
const confirm = msgEl.querySelector<HTMLButtonElement>(".fristen-card-calc-add-confirm")!;
confirm.disabled = true;
try {
const r = await fetch(`/api/projects/${encodeURIComponent(projectID)}/deadlines/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!r.ok) {
const data = (await r.json().catch(() => ({}))) as { error?: string };
msgEl.innerHTML = `<span class="form-msg form-msg-error">${escHtml(data.error || t("deadlines.save.error"))}</span>`;
addBtn.disabled = false;
return;
}
msgEl.innerHTML = `<span class="form-msg form-msg-ok">${escHtml(t("deadlines.save.success"))} (${escHtml(dueLabel)}) <a href="/deadlines?project_id=${encodeURIComponent(projectID)}">${escHtml(t("deadlines.save.success.link"))}</a></span>`;
addBtn.disabled = false;
} catch {
msgEl.innerHTML = `<span class="form-msg form-msg-error">${escHtml(t("deadlines.save.error"))}</span>`;
addBtn.disabled = false;
}
});
}
// Collapse the open card on Escape key for quick keyboard exit.
document.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
const open = document.querySelector<HTMLElement>(".fristen-card.is-expanded");
if (open) {
open.classList.remove("is-expanded");
open.setAttribute("aria-expanded", "false");
open.querySelector<HTMLElement>(".fristen-card-calc")?.remove();
}
});
function renderConceptCard(card: SearchCard, lang: "de" | "en"): string {
const name = lang === "en" ? card.concept.name_en : card.concept.name_de;
const altName = lang === "en" ? card.concept.name_de : card.concept.name_en;
const aliasLine = card.matched_aliases && card.matched_aliases.length > 0
? `<div class="fristen-card-aliases"><span class="fristen-card-aliases-label">${escHtml(t("deadlines.search.aliases"))}</span> ${card.matched_aliases.map(escHtml).join(" · ")}</div>`
: "";
const desc = card.concept.description ? `<p class="fristen-card-desc">${escHtml(card.concept.description)}</p>` : "";
// Split rule pills (have a proceeding) from cross-cutting trigger pills.
const rulePills = card.pills.filter((p) => p.kind === "rule");
const triggerPills = card.pills.filter((p) => p.kind === "trigger");
const ruleSection = rulePills.length === 0 ? "" : `
<div class="fristen-card-pills-section fristen-card-pills-section--rules">
<h4 class="fristen-card-pills-heading">${escHtml(t("deadlines.search.pills.heading"))}</h4>
<div class="fristen-card-pills">${rulePills.map((p) => renderPill(p, lang)).join("")}</div>
</div>`;
const triggerSection = triggerPills.length === 0 ? "" : `
<div class="fristen-card-pills-section fristen-card-pills-section--cross">
<h4 class="fristen-card-pills-heading">${escHtml(t("deadlines.search.pills.cross_cutting"))}</h4>
<div class="fristen-card-pills">${triggerPills.map((p) => renderPill(p, lang)).join("")}</div>
</div>`;
// v4 (t-paliad-136 Phase B): stash the card payload on the article so
// expandCardCalc() can read pills + concept name without re-querying.
// JSON.stringify → escAttr survives htmlentity round-trip.
const cardPayload = escAttr(JSON.stringify(card));
return `
<article class="fristen-card" data-concept-slug="${escAttr(card.concept.slug)}" data-card-payload="${cardPayload}" tabindex="0" role="button" aria-expanded="false" title="${escAttr(t("deadlines.card.calc.expand_hint"))}">
<header class="fristen-card-header">
<h3 class="fristen-card-title">${escHtml(name)}</h3>
<span class="fristen-card-altname">${escHtml(altName)}</span>
</header>
${desc}
${aliasLine}
${ruleSection}
${triggerSection}
</article>`;
}
function renderPill(pill: SearchPill, lang: "de" | "en"): string {
const procLabel = pill.proceeding
? (lang === "en" && pill.proceeding.name_en ? pill.proceeding.name_en : pill.proceeding.name_de)
: "";
// t-paliad-134: never fall back to rule_local_code — that's an
// internal slug like "rev.defence" / "inf.decision" and leaks
// implementation detail to the UI when legal_source is unset.
const sourceLabel = pill.legal_source_display || pill.legal_source || "";
const ruleName = lang === "en" && pill.rule_name_en ? pill.rule_name_en : pill.rule_name_de;
const partyLabel = partyLabelFor(pill.party);
const durationHtml = pill.duration
? `<span class="fristen-pill-duration">${escHtml(formatDuration(pill.duration, lang))}</span>`
: "";
const procHtml = procLabel
? `<span class="fristen-pill-proc">${escHtml(procLabel)}</span>`
: `<span class="fristen-pill-proc fristen-pill-proc--cross">${escHtml(t("deadlines.search.pills.cross_cutting"))}</span>`;
const partyHtml = partyLabel ? `<span class="fristen-pill-party fristen-pill-party--${escAttr(pill.party)}">${escHtml(partyLabel)}</span>` : "";
const sourceHtml = sourceLabel
? `<span class="fristen-pill-source">${escHtml(sourceLabel)}</span>`
: "";
// data-* attributes carry everything the click handler needs to drill in
// without re-parsing JSON. drill_url is the canonical fallback (used when
// the user middle-clicks / cmd-clicks for a new tab).
const dataAttrs = [
`data-kind="${escAttr(pill.kind)}"`,
pill.proceeding ? `data-proc="${escAttr(pill.proceeding.code)}"` : "",
pill.rule_local_code ? `data-focus="${escAttr(pill.rule_local_code)}"` : "",
pill.trigger_event_id !== undefined ? `data-trigger-id="${pill.trigger_event_id}"` : "",
].filter(Boolean).join(" ");
return `
<a href="${escAttr(pill.drill_url)}" class="fristen-pill" ${dataAttrs}>
${procHtml}
<span class="fristen-pill-rule">${escHtml(ruleName)}</span>
${sourceHtml}
${durationHtml}
${partyHtml}
</a>`;
}
function formatDuration(d: SearchPillDuration, lang: "de" | "en"): string {
const unitLabels: Record<string, { de: string; en: string }> = {
days: { de: "Tage", en: "days" },
working_days: { de: "Arbeitstage", en: "working days" },
weeks: { de: "Wochen", en: "weeks" },
months: { de: "Monate", en: "months" },
years: { de: "Jahre", en: "years" },
};
const label = unitLabels[d.unit] ? unitLabels[d.unit][lang] : d.unit;
return `${d.value} ${label}`;
}
function partyLabelFor(party: string): string {
switch (party) {
case "claimant": return t("deadlines.search.party.claimant");
case "defendant": return t("deadlines.search.party.defendant");
case "both": return t("deadlines.search.party.both");
case "court": return t("deadlines.search.party.court");
default: return party;
}
}
// ----- Drill-in --------------------------------------------------------------
// Pending focus the next renderProcedureResults() will scroll to and
// highlight. Set by drillToProceeding right before the scheduled calc fires.
let pendingFocusRule: string | null = null;
function applyPendingFocus() {
if (!pendingFocusRule) return;
const code = pendingFocusRule;
pendingFocusRule = null;
// Wait one frame so the DOM has settled.
requestAnimationFrame(() => {
const target = document.querySelector<HTMLElement>(`[data-rule-code="${CSS.escape(code)}"]`);
if (!target) return;
const row = target.closest<HTMLElement>(".frist-row, .frist-card, li") || target;
row.classList.add("fristen-focus-highlight");
row.scrollIntoView({ behavior: "smooth", block: "center" });
window.setTimeout(() => row.classList.remove("fristen-focus-highlight"), 2400);
});
}
function drillToProceeding(procCode: string, focusCode: string | null) {
// Switch to procedure mode if we're on event mode.
const procTab = document.getElementById("mode-procedure-tab");
if (procTab) procTab.click();
const btn = document.querySelector<HTMLButtonElement>(`.proceeding-btn[data-code="${procCode}"]`);
if (!btn) return;
if (focusCode) pendingFocusRule = focusCode;
selectProceeding(btn);
// Scroll the wizard into view so the user sees what just happened.
document.getElementById("step-2")?.scrollIntoView({ behavior: "smooth", block: "start" });
}
function drillToTrigger(triggerId: number) {
// v3 (Phase E): legacy tabs are gone. Show the event panel directly.
// Triggered from concept-card pill clicks; routes via Pathway A so the
// Verfahrensablauf user surface stays consistent.
const procedurePanel = document.getElementById("mode-procedure-panel");
const eventPanel = document.getElementById("mode-event-panel");
if (procedurePanel) procedurePanel.hidden = true;
if (eventPanel) eventPanel.hidden = false;
// Defer a tick so the panel swap has rendered before we touch state.
window.setTimeout(() => {
selectTriggerEvent(triggerId);
document.getElementById("event-step-2")?.scrollIntoView({ behavior: "smooth", block: "start" });
}, 0);
}
// ----- URL state -------------------------------------------------------------
function syncSearchURL(q: string) {
const url = new URL(window.location.href);
if (q === "") url.searchParams.delete("q");
else url.searchParams.set("q", q);
// Only push if the URL actually changed — avoids spamming history with
// identical entries while the user types.
if (url.toString() !== window.location.href) {
window.history.replaceState(null, "", url.toString());
}
}
function readInitialSearchQuery(): string {
return new URLSearchParams(window.location.search).get("q") || "";
}
// Quick-pick chips (t-paliad-134) carry both DE and EN labels via
// data-chip-name-de / data-chip-name-en attributes. relabelChips
// rewrites the visible text to match the active language; chipQueryFor
// returns the active-language label for use as the search query.
function relabelChips() {
const lang = getLang();
document.querySelectorAll<HTMLButtonElement>(".fristen-search-chip").forEach((chip) => {
const de = chip.dataset.chipNameDe;
const en = chip.dataset.chipNameEn;
if (!de && !en) return; // legacy chip without slug-based labels
const label = lang === "en" ? (en || de || "") : (de || en || "");
if (label && chip.textContent?.trim() !== label) {
chip.textContent = label;
}
// data-q kept in sync so existing click paths (e.g. fork-chip path)
// see the right query string without needing a chip-aware fallback.
chip.dataset.q = label;
});
}
function chipQueryFor(chip: HTMLButtonElement): string {
const lang = getLang();
const de = chip.dataset.chipNameDe;
const en = chip.dataset.chipNameEn;
if (de || en) {
return lang === "en" ? (en || de || "") : (de || en || "");
}
return chip.dataset.q || chip.textContent || "";
}
// ----- Wiring ----------------------------------------------------------------
function initSearch() {
const input = document.getElementById("fristen-search-input") as HTMLInputElement | null;
const chips = document.getElementById("fristen-search-chips");
const results = document.getElementById("fristen-search-results");
const clearBtn = document.getElementById("fristen-search-clear") as HTMLButtonElement | null;
if (!input || !chips || !results) return; // older bundle — skip silently
// Initial state from URL.
const initial = readInitialSearchQuery();
if (initial) {
input.value = initial;
if (clearBtn) clearBtn.hidden = false;
scheduleSearch(0);
}
input.addEventListener("input", () => scheduleSearch());
input.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Escape") {
input.value = "";
scheduleSearch(0);
} else if ((e as KeyboardEvent).key === "Enter") {
scheduleSearch(0);
}
});
if (clearBtn) {
clearBtn.addEventListener("click", () => {
input.value = "";
input.focus();
scheduleSearch(0);
});
}
chips.addEventListener("click", (e) => {
const target = (e.target as HTMLElement).closest<HTMLButtonElement>(".fristen-search-chip");
if (!target) return;
// Slug-based chips (t-paliad-134) carry both labels and use the
// active language. Legacy chips (no slug) fall back to data-q.
const q = chipQueryFor(target);
input.value = q;
input.focus();
scheduleSearch(0);
});
relabelChips();
wirePillClicks(results);
// Re-render on language flip so card / pill labels follow the active locale.
onLangChange(() => {
relabelChips();
const q = input.value.trim();
if (q !== "") scheduleSearch(0);
});
// Browser back/forward should restore search state.
window.addEventListener("popstate", () => {
const q = readInitialSearchQuery();
if (q !== input.value) {
input.value = q;
scheduleSearch(0);
}
});
}
// Wire on DOM ready (the existing DOMContentLoaded handler is already busy;
// add a lightweight follow-up listener to keep the diff small).
document.addEventListener("DOMContentLoaded", initSearch);
// ============================================================================
// v3 pathway fork (t-paliad-133)
// ============================================================================
// Three-state landing surface: fork (default), Pathway A (Verfahrensablauf —
// existing wizard), Pathway B (Frist eintragen — search/B1/B2). URL ?path=
// drives visibility; localStorage remembers the last-used pathway for soft
// re-entry. ?legacy=1 keeps the pre-v3 layout (no fork) for parity testing
// during the rollout window.
// Pathway values:
// fork — Step 1 / Step 2 visible (the new front of the funnel)
// outgoing — Step 3a chooser (File / Draft / Enter) visible
// a — Pathway A wizard (Verfahrensablauf timeline)
// b — Pathway B cascade
type Pathway = "fork" | "outgoing" | "a" | "b";
type BMode = "tree" | "filter";
const PATHWAY_STORAGE_KEY = "paliad.fristen.pathway";
function readPathwayFromURL(): Pathway {
const sp = new URLSearchParams(window.location.search);
const p = sp.get("path");
if (p === "a" || p === "b" || p === "outgoing") return p;
return "fork";
}
function readBModeFromURL(): BMode {
const sp = new URLSearchParams(window.location.search);
const m = sp.get("mode");
if (m === "tree" || m === "filter") return m;
// Default: tree mode (B1 cascade is the discovery surface; the
// free-text/filter B2 mode is for power users who already know what
// they want).
return "tree";
}
function setPathwayURL(path: Pathway, mode?: BMode, replace = false) {
const url = new URL(window.location.href);
if (path === "fork") {
url.searchParams.delete("path");
url.searchParams.delete("mode");
url.searchParams.delete("b1");
} else {
url.searchParams.set("path", path);
if (path === "b" && mode) {
url.searchParams.set("mode", mode);
} else {
url.searchParams.delete("mode");
}
}
if (replace) {
window.history.replaceState({}, "", url.toString());
} else {
window.history.pushState({}, "", url.toString());
}
}
// resolveDeadlinesNewURL builds the Step 3a "Enter" destination. For a
// real project: /projects/{id}/deadlines/new. For ad-hoc explore-mode:
// /deadlines/new (the project picker is in the form itself, but the
// user has no Akte to attach it to without picking one anew). m's
// 2026-05-08 spec: this is the manual-entry escape hatch.
function resolveDeadlinesNewURL(ctx: Step1Context): string {
if (ctx.kind === "project" && ctx.projectId) {
return `/projects/${encodeURIComponent(ctx.projectId)}/deadlines/new`;
}
return "/deadlines/new";
}
function showPathway(path: Pathway, mode?: BMode) {
// m's 2026-05-08 18:08 redesign retired the legacy fork; Step 1 and
// Step 2 sit where it used to live. The "fork" Pathway value now
// means "show Step 1 + Step 2 (Step 2 visibility gated by whether a
// project / ad-hoc context is selected — managed by initStep1Step2)";
// "a" / "b" still drive the existing wizard / cascade shells.
const step1 = document.getElementById("fristen-step1");
const step1Summary = document.getElementById("fristen-step1-summary");
const step2 = document.getElementById("fristen-step2");
const step3a = document.getElementById("fristen-step3a");
const a = document.getElementById("fristen-pathway-a");
const b = document.getElementById("fristen-pathway-b");
if (!a || !b) return;
// Step 1 + 2 stay mounted under "fork". Step 2 visibility is also
// gated by the Step 1 context state — initStep1Step2 owns the
// toggle between Step 1 expanded vs collapsed-with-summary, and
// the "show Step 2" gate. We just hide them wholesale when not on
// the fork.
if (step1) step1.style.display = path === "fork" ? "" : "none";
if (step1Summary) {
// Summary stays visible from Step 2 onward so the user always
// sees their selected Akte. Hidden only when no context is set.
const ctx = readStep1ContextFromURL();
step1Summary.style.display = (ctx.kind !== "none" && path !== "fork") ? "" : step1Summary.style.display;
}
if (step2) step2.hidden = path !== "fork";
if (step3a) step3a.hidden = path !== "outgoing";
a.hidden = path !== "a";
b.hidden = path !== "b";
if (path === "b") {
showBMode(mode || readBModeFromURL());
}
}
function showBMode(mode: BMode) {
const tree = document.getElementById("fristen-b1-panel");
const filter = document.getElementById("fristen-b2-panel");
if (!tree || !filter) return;
tree.hidden = mode !== "tree";
filter.hidden = mode !== "filter";
// Trigger tree load on entering tree mode. renderRowStack repaints
// every row + calls runB1Search() for the current slug. With slug=""
// this fetches every concept reachable from any leaf (browse-all,
// t-paliad-134) so the user sees the full landscape before drilling
// in.
if (mode === "tree") {
const stack = document.getElementById("fristen-row-stack");
if (stack && stack.childElementCount === 0) {
stack.innerHTML = `<div class="fristen-b1-stub">${escHtml(t("deadlines.search.loading"))}</div>`;
}
void loadAndRenderB1();
}
}
function navigateToPathway(path: Pathway, mode?: BMode) {
setPathwayURL(path, mode);
showPathway(path, mode);
if (path !== "fork") {
try {
localStorage.setItem(PATHWAY_STORAGE_KEY, path);
} catch { /* private mode */ }
}
}
// ============================================================================
// m's 2026-05-08 18:08 Determinator redesign — Step 1 + Step 2 state
// ============================================================================
// Step 1: pick the project (Akte) that scopes everything downstream, OR
// pick an ad-hoc explore-mode chip (4 jurisdictions). Step 2: choose
// between outgoing intent (Pathway A / Verfahrensablauf) and incoming
// event (Pathway B / cascade). Step 3+ stay as today (Pathway A wizard,
// B1 cascade, B2 search). The legacy "Was möchten Sie tun?" fork is
// retired; back-buttons inside Pathway A/B return to Step 2.
type Step1ContextKind = "project" | "adhoc" | "none";
type AdhocForum = "upc" | "de" | "epa" | "dpma";
interface Step1Context {
kind: Step1ContextKind;
projectId?: string;
project?: ProjectOption;
adhocForum?: AdhocForum;
}
let currentStep1Context: Step1Context = { kind: "none" };
let cachedAkten: ProjectOption[] = [];
function readStep1ContextFromURL(): Step1Context {
const sp = new URLSearchParams(window.location.search);
const project = sp.get("project");
const adhoc = sp.get("ad_hoc");
if (project) return { kind: "project", projectId: project };
if (adhoc === "upc" || adhoc === "de" || adhoc === "epa" || adhoc === "dpma") {
return { kind: "adhoc", adhocForum: adhoc };
}
return { kind: "none" };
}
function writeStep1ContextToURL(ctx: Step1Context, replace = false) {
const url = new URL(window.location.href);
if (ctx.kind === "project" && ctx.projectId) {
url.searchParams.set("project", ctx.projectId);
url.searchParams.delete("ad_hoc");
} else if (ctx.kind === "adhoc" && ctx.adhocForum) {
url.searchParams.set("ad_hoc", ctx.adhocForum);
url.searchParams.delete("project");
} else {
url.searchParams.delete("project");
url.searchParams.delete("ad_hoc");
}
if (replace) window.history.replaceState({}, "", url.toString());
else window.history.pushState({}, "", url.toString());
}
// isAdhocMode is read by the save-to-project CTA — ad-hoc has no
// project to save against, so the CTA disables and renders a hint.
// t-paliad-168: also true when no Step 1 context is set at all (the
// "Verfahrensablauf einsehen" / sidebar deep-link browse path opens
// Pathway A without an Akte). In both cases the user has no project
// to save against; the CTA renders disabled with the same hint.
function isAdhocMode(): boolean {
return currentStep1Context.kind === "adhoc" || currentStep1Context.kind === "none";
}
function adhocSummaryLabel(forum: AdhocForum): string {
switch (forum) {
case "upc": return "Ad-hoc UPC";
case "de": return "Ad-hoc DE";
case "epa": return "Ad-hoc EPA";
case "dpma": return "Ad-hoc DPMA";
}
}
function renderAkteList(query: string) {
const list = document.getElementById("fristen-akte-list");
if (!list) return;
const q = query.trim().toLowerCase();
const filtered = q === ""
? cachedAkten.slice(0, 12)
: cachedAkten.filter((p) =>
(p.title || "").toLowerCase().includes(q) ||
(p.reference || "").toLowerCase().includes(q));
if (filtered.length === 0) {
list.innerHTML = `<li class="fristen-akte-list-empty">${escHtml(t("deadlines.step1.search.empty"))}</li>`;
return;
}
list.innerHTML = filtered.map((p) => {
const ref = p.reference ? `<span class="fristen-akte-list-ref">${escHtml(p.reference)}</span> · ` : "";
return `<li><button type="button" class="fristen-akte-list-item" data-project-id="${escAttr(p.id)}">
${ref}<span class="fristen-akte-list-title">${escHtml(p.title)}</span>
</button></li>`;
}).join("");
list.querySelectorAll<HTMLButtonElement>(".fristen-akte-list-item").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.dataset.projectId!;
const project = cachedAkten.find((p) => p.id === id);
if (project) selectProject(project);
});
});
}
function selectProject(project: ProjectOption) {
currentStep1Context = { kind: "project", projectId: project.id, project };
writeStep1ContextToURL(currentStep1Context);
renderStep1Summary();
showStep2Card();
// t-paliad-164: project.our_side predefines the perspective chip.
// Only fires when the user hasn't already locked a perspective via
// ?role= in the URL — the URL pick wins because it represents an
// explicit choice (chip click or shared link).
applyOurSidePredefine(project, /* replaceURL */ false);
// Slice 3b: project's proceeding type narrows the B1 cascade if the
// user reaches it via Step 2 → Etwas ist passiert. Refresh here so
// a cascade already on screen (rare but possible via popstate) picks
// up the new narrowing.
triggerCascadeRefresh();
}
function selectAdhoc(forum: AdhocForum) {
currentStep1Context = { kind: "adhoc", adhocForum: forum };
writeStep1ContextToURL(currentStep1Context);
renderStep1Summary();
showStep2Card();
triggerCascadeRefresh();
}
function clearStep1Context() {
currentStep1Context = { kind: "none" };
writeStep1ContextToURL(currentStep1Context);
renderStep1Summary();
hideStep2Card();
// t-paliad-180: dropping the project context makes the perspective
// row revert from is-prefilled to is-answered (the "aus Akte" tag
// disappears) — repainting the row stack is the only thing needed;
// we deliberately leave currentPerspective alone, since the user
// may want to keep their pick when returning to Step 1.
triggerCascadeRefresh();
}
function renderStep1Summary() {
const summary = document.getElementById("fristen-step1-summary") as HTMLElement | null;
const name = document.getElementById("fristen-step1-summary-name");
const meta = document.getElementById("fristen-step1-summary-meta");
const step1 = document.getElementById("fristen-step1") as HTMLElement | null;
if (!summary || !name || !step1) return;
if (currentStep1Context.kind === "none") {
summary.style.display = "none";
step1.style.display = "";
return;
}
if (currentStep1Context.kind === "project" && currentStep1Context.project) {
const p = currentStep1Context.project;
const ref = p.reference ? p.reference + " · " : "";
name.textContent = ref + p.title;
if (meta) meta.textContent = "";
} else if (currentStep1Context.kind === "adhoc" && currentStep1Context.adhocForum) {
name.textContent = adhocSummaryLabel(currentStep1Context.adhocForum);
if (meta) meta.textContent = " · " + t("deadlines.step1.summary.adhoc.suffix");
}
summary.style.display = "";
step1.style.display = "none";
}
function showStep2Card() {
const step2 = document.getElementById("fristen-step2");
if (step2) step2.hidden = false;
}
function hideStep2Card() {
const step2 = document.getElementById("fristen-step2");
if (step2) step2.hidden = true;
}
function initPathwayFork() {
// Set chip labels to active language before user sees them.
relabelChips();
// Hydrate Step 1 context from URL first — Step 2 visibility depends
// on whether a project / ad-hoc chip is already locked in.
currentStep1Context = readStep1ContextFromURL();
// Initial Pathway render. Inherits the URL ?path= semantic — Step 2
// having been satisfied is implied if path = a / b.
const initial = readPathwayFromURL();
const initialMode = readBModeFromURL();
showPathway(initial, initialMode);
// Step 1 summary visibility flows from the context kind.
renderStep1Summary();
if (currentStep1Context.kind !== "none") {
showStep2Card();
}
if (initial !== "fork") {
try { localStorage.setItem(PATHWAY_STORAGE_KEY, initial); } catch { /* */ }
}
// Step 1 — fetch projects + proceeding-types in parallel. Both are
// small + cacheable; both are needed before the cascade narrowing
// can fire correctly. Render the list as soon as projects come in,
// refresh cascade once the proceeding-types map is also populated.
void (async () => {
const [projects] = await Promise.all([
fetchProjects(),
loadProceedingTypes(),
]);
cachedAkten = projects;
if (currentStep1Context.kind === "project" && currentStep1Context.projectId) {
currentStep1Context.project = cachedAkten.find((p) => p.id === currentStep1Context.projectId);
renderStep1Summary();
// t-paliad-164: deep-link / refresh path. project loaded async, so
// the predefine has to wait for cachedAkten. replace=true keeps
// the URL clean — the user didn't navigate, they just refreshed.
applyOurSidePredefine(currentStep1Context.project, /* replaceURL */ true);
}
renderAkteList("");
// Cascade may already be on screen if the user landed with
// ?path=b&project=<uuid>; re-render to apply the now-known forum.
triggerCascadeRefresh();
})();
const akteSearch = document.getElementById("fristen-akte-search") as HTMLInputElement | null;
if (akteSearch) {
akteSearch.addEventListener("input", () => renderAkteList(akteSearch.value));
}
// Ad-hoc chips — explore-mode escape hatch. No DB write; the
// save-modal CTA disables itself in this state.
document.querySelectorAll<HTMLButtonElement>(".fristen-adhoc-chip").forEach((chip) => {
chip.addEventListener("click", () => {
const forum = chip.dataset.adHoc as AdhocForum | undefined;
if (forum) selectAdhoc(forum);
});
});
// t-paliad-180: perspective + inbox chip strips folded into the
// row stack; click wiring now lives inside renderRowStack(). We
// still hydrate perspective from URL here so the row stack picks
// up the correct picked-value on first paint.
applyPerspective(readPerspectiveFromURL());
// t-paliad-180: search escape-hatch (Pathway B Direkt-suchen
// button) routes to ?mode=filter. Reset link drops the cascade
// slug back to root, keeping mode / perspective / inbox alone.
// t-paliad-198 Slice 3: Direkt-suchen icon expands an inline search
// overlay over the row stack — the legacy ?mode=filter route is kept
// for deep-link compatibility but is no longer the user-facing path.
document.getElementById("fristen-row-search-link")?.addEventListener("click", () => {
setInlineSearchActive(true);
});
document.getElementById("fristen-row-search-panel-back")?.addEventListener("click", () => {
setInlineSearchActive(false);
});
document.getElementById("fristen-row-reset")?.addEventListener("click", () => {
currentActiveRow = null;
navigateB1("");
});
initInlineSearch();
// Reselect: drop the locked context, return to Step 1.
document.getElementById("fristen-step1-summary-reselect")?.addEventListener("click", () => {
clearStep1Context();
// Bounce back to fork (Step 1 + 2) so the user sees the picker,
// even if they were currently in Pathway A or B.
if (initial !== "fork") {
navigateToPathway("fork");
}
});
// Step 2 cards — outgoing (Step 3a chooser) vs incoming (Pathway B
// cascade). showPathway() owns the actual transition; we just drive
// it from the action choice.
document.getElementById("fristen-step2-file")?.addEventListener("click", () => {
navigateToPathway("outgoing");
});
document.getElementById("fristen-step2-happened")?.addEventListener("click", () => {
navigateToPathway("b", "tree");
});
// t-paliad-179 Slice 1: the "Verfahrensablauf einsehen" Step 2 card
// has been retired — the abstract-browse intent lives on its own
// route at /tools/verfahrensablauf now. No third-card handler here.
// Step 3a cards — File / Draft / Enter. File drops into the existing
// Pathway A wizard; Enter routes to the manual-create form;
// Draft is a v1 placeholder (button disabled in markup, no handler).
document.getElementById("fristen-step3a-file")?.addEventListener("click", () => {
navigateToPathway("a");
});
document.getElementById("fristen-step3a-enter")?.addEventListener("click", () => {
window.location.href = resolveDeadlinesNewURL(currentStep1Context);
});
// Back-from-Pathway buttons return to Step 2 (the new "fork" state).
// Pathway A's back returns to Step 3a since that's where the user
// came from in the new flow; pre-Slice-3 muscle memory of Pathway A
// back going all the way to fork is preserved by clicking back twice.
document.getElementById("fristen-pathway-a-back")?.addEventListener("click", () => {
navigateToPathway("outgoing");
});
document.getElementById("fristen-pathway-b-back")?.addEventListener("click", () => {
navigateToPathway("fork");
});
document.getElementById("fristen-step3a-back")?.addEventListener("click", () => {
navigateToPathway("fork");
});
// t-paliad-180: B1/B2 mode toggle moved into the row stack. The
// mode row's click handler (renderRowStack → handleRowPick) drives
// setPathwayURL + showBMode now; no more standalone radio.
// Quick-pick chips on the Step 2 shortcut row → jump straight to
// Pathway B + filter mode + prefilled query. Same behaviour as the
// legacy fork; only the ID's mounting point changed.
document.querySelectorAll<HTMLButtonElement>("#fristen-fork-chips .fristen-search-chip").forEach((chip) => {
chip.addEventListener("click", () => {
const q = chipQueryFor(chip);
const url = new URL(window.location.href);
url.searchParams.set("path", "b");
url.searchParams.set("mode", "filter");
if (q) url.searchParams.set("q", q);
window.history.pushState({}, "", url.toString());
showPathway("b", "filter");
const input = document.getElementById("fristen-search-input") as HTMLInputElement | null;
if (input && q) {
input.value = q;
input.dispatchEvent(new Event("input", { bubbles: true }));
}
});
});
// Browser back/forward restores pathway + Step 1 context + perspective.
window.addEventListener("popstate", () => {
currentStep1Context = readStep1ContextFromURL();
if (currentStep1Context.kind === "project" && currentStep1Context.projectId) {
currentStep1Context.project = cachedAkten.find((p) => p.id === currentStep1Context.projectId);
}
renderStep1Summary();
if (currentStep1Context.kind !== "none") showStep2Card(); else hideStep2Card();
applyPerspective(readPerspectiveFromURL());
// t-paliad-180: the "aus Akte" tag is now part of the prefilled
// row state; buildRowStack derives visibility from
// project.our_side ↔ currentPerspective inside the renderer, so
// no separate DOM hint flip is needed on popstate.
currentActiveRow = null;
// t-paliad-197: popstate is a fresh navigation — auto-walk should
// re-evaluate from scratch, no stale "ändern-just-clicked" cap.
cascadeAutoWalkStopAfter = null;
const path = readPathwayFromURL();
const mode = readBModeFromURL();
showPathway(path, mode);
});
}
document.addEventListener("DOMContentLoaded", initPathwayFork);
// ============================================================================
// v3 B1 decision tree (t-paliad-133 Phase C)
// ============================================================================
// Data-driven cascade: fetch the event-categories tree from
// GET /api/tools/fristenrechner/event-categories, render the current
// step's button set, walk down on click, show breadcrumb + reset.
// Result cards below come from /api/tools/fristenrechner/search with
// ?event_category_slug= narrowing.
interface EventCategoryNode {
id: string;
slug: string;
label_de: string;
label_en: string;
description_de?: string;
description_en?: string;
step_question_de?: string;
step_question_en?: string;
icon?: string;
sort_order: number;
is_leaf: boolean;
// #15 follow-up: coarse forum tags ('upc' | 'de' | 'epa' | 'dpma').
// Empty / undefined = neutral, node visible from every inbox setting.
forums?: string[];
// Slice 3c: party tags ('claimant' | 'defendant' | 'both' | 'court').
// Empty / undefined = neutral. Hides leaves whose tag doesn't match
// the active perspective chip.
party?: string[];
children?: EventCategoryNode[];
}
let eventCategoryTree: EventCategoryNode[] | null = null;
let eventCategoryFetchInflight: Promise<EventCategoryNode[]> | null = null;
// Top-level cascade roots that represent forward-looking workflows ("I
// want to file X, what deadlines does my action trigger?") rather than
// the backward-looking calc the Fristenrechner is built for ("event Y
// happened, what deadlines spawn?"). m's 2026-05-20 ask (m/paliad#57):
// remove these from the "Was ist passiert?" picker — they belong in a
// future forward-workflow tool, not here. The DB rows stay so that
// future tool can pick them back up; we just hide them at the UI layer.
const HIDDEN_CASCADE_ROOTS: ReadonlySet<string> = new Set([
"ich-moechte-einreichen",
]);
async function loadEventCategoryTree(): Promise<EventCategoryNode[]> {
if (eventCategoryTree) return eventCategoryTree;
if (eventCategoryFetchInflight) return eventCategoryFetchInflight;
eventCategoryFetchInflight = (async () => {
try {
const r = await fetch("/api/tools/fristenrechner/event-categories");
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
const raw = (data.tree || []) as EventCategoryNode[];
eventCategoryTree = raw.filter((n) => !HIDDEN_CASCADE_ROOTS.has(n.slug));
return eventCategoryTree;
} finally {
eventCategoryFetchInflight = null;
}
})();
return eventCategoryFetchInflight;
}
function readB1PathFromURL(): string {
return new URLSearchParams(window.location.search).get("b1") || "";
}
function setB1PathInURL(slug: string, replace = false) {
const url = new URL(window.location.href);
if (slug) {
url.searchParams.set("b1", slug);
} else {
url.searchParams.delete("b1");
}
if (replace) {
window.history.replaceState({}, "", url.toString());
} else {
window.history.pushState({}, "", url.toString());
}
}
function findNodeBySlug(roots: EventCategoryNode[], slug: string): EventCategoryNode | null {
for (const root of roots) {
if (root.slug === slug) return root;
if (root.children) {
const inner = findNodeBySlug(root.children, slug);
if (inner) return inner;
}
}
return null;
}
function buildBreadcrumb(roots: EventCategoryNode[], slug: string): EventCategoryNode[] {
// Slug is dot-separated; walk down each segment.
if (!slug) return [];
const parts = slug.split(".");
const trail: EventCategoryNode[] = [];
let scope = roots;
let cumulative = "";
for (const seg of parts) {
cumulative = cumulative ? `${cumulative}.${seg}` : seg;
const node = scope.find((n) => n.slug === cumulative);
if (!node) break;
trail.push(node);
scope = node.children || [];
}
return trail;
}
function nodeLabel(n: EventCategoryNode): string {
return getLang() === "de" ? n.label_de : n.label_en;
}
function nodeStepQuestion(n: EventCategoryNode): string {
return getLang() === "de"
? (n.step_question_de || "")
: (n.step_question_en || n.step_question_de || "");
}
// t-paliad-180 Slice 1 — row-stack rendering.
//
// The Pathway B shell used to layer four functionally different decision
// surfaces with four different visuals: a radio (mode), two pill strips
// (perspective + inbox), and a button-grid cascade with a breadcrumb.
// They're all "narrow the deadline-rule space" steps; the new model
// renders every one of them as the same `.fristen-row` primitive in a
// single stack — top-down, persistent, each row showing the question +
// the picked answer + an inline "ändern" affordance.
//
// Slice 1 is visual-only: the narrowing engine (forum + perspective
// filters) is unchanged. What changes is presentation — the user can
// finally see their full decision path at a glance, and edit any row
// without losing the rest. Slice 2 will add project-driven prefills +
// auto-walk; Slice 3 polishes mobile + the search escape-hatch.
// t-paliad-198 Slice 3: mode kind retired — the icon-button at the top
// of the row stack (`#fristen-row-search-link`) replaces the mode row,
// toggling an inline search overlay instead of routing to ?mode=filter.
type RowKind = "perspective" | "inbox" | "cascade";
type RowState = "active" | "answered" | "prefilled";
interface RowOption {
value: string;
label: string;
icon?: string;
isLeaf?: boolean;
title?: string;
}
interface RowSpec {
kind: RowKind;
rowId: string;
state: RowState;
question: string;
options: RowOption[];
pickedValue?: string;
pickedLabel?: string;
pickedIcon?: string;
// is-prefilled rows can carry an "aus Akte: <reference>" annotation.
// Per design §11.2, only the first prefilled row in a stack shows the
// reference; subsequent ones show the plain tag.
prefilledReference?: string;
// Cascade-only: the slug of the ancestor node whose children populate
// this row's options. Used by the click handler to navigate to a
// sibling without losing prefix state. Empty string = cascade root.
cascadeParentSlug?: string;
// Cascade-only: the slug to navigate to when the user clicks "ändern"
// on this answered row. Always the row's parent slug (ancestor at
// depth K-1), which drops descendants and re-actives this depth.
cascadeRevertSlug?: string;
// Cascade-only: the depth this row occupies in the post-auto-walk
// trail (0 = root bucket). Used by handleRowEdit to compute the
// auto-walk suppression key.
cascadeDepth?: number;
}
// currentActiveRow tracks which non-cascade row the user has clicked
// "ändern" on. null = no override; the deepest cascade step is the
// natural active row. Cleared on every successful pick.
let currentActiveRow: string | null = null;
// cascadeAutoWalkStopAfter caps the auto-walk depth for the next
// render. Set when the user clicks "ändern" on an auto-walked cascade
// row at depth K — auto-walk would otherwise re-fire on render and
// undo the edit. The cap stays in place until the user picks a chip
// (which clears it via handleRowPick → renderRowStack → cleared by
// the picker), at which point auto-walk re-engages from the new
// position. null = no cap (auto-walk runs to first branching point).
let cascadeAutoWalkStopAfter: number | null = null;
// cascadeTooltipDismissedKey backs the "first auto-walk tooltip" flag
// in localStorage. Once the user dismisses the tooltip, the key stays
// for future sessions; we only re-prompt if the key gets cleared.
const cascadeTooltipDismissedKey = "paliad.fristen.cascade.autoWalkTooltipSeen";
function perspectiveOptionLabel(value: string): string {
if (value === "claimant") return t("deadlines.perspective.claimant.short");
if (value === "defendant") return t("deadlines.perspective.defendant.short");
return t("deadlines.perspective.both.short");
}
function inboxOptionLabel(value: string): string {
if (value === "cms") return "CMS";
if (value === "bea") return "beA";
if (value === "posteingang") return t("deadlines.inbox.posteingang");
return t("deadlines.inbox.all");
}
// Slice 2: cascade-segment ↔ fristenrechner-code bridge. The event_categories
// taxonomy uses kebab-case segments under the `cms-eingang.*` buckets to
// represent proceedings (`upc-inf`, `de-bgh-null`, …); paliad.projects
// binds to fristenrechner codes by id and the lookup yields the
// lowercase dot-separated taxonomy ratified by mig 096
// (`upc.inf.cfi`, `de.inf.bgh`, …). The event_categories slugs are NOT
// renamed by mig 096 — they live in a separate taxonomy and the kebab
// form is presentation-layer (it appears in URL fragments). This map
// is the bridge. Any code not in the map degrades to "no proceeding-
// axis narrowing" — better silent than wrong (design §11.6).
//
// upc.ccr.cfi is the illustrative peer added by mig 096; it shares the
// `upc-inf` kebab segment because rules live on upc.inf.cfi with
// with_ccr=true (design doc S1, proceeding_mapping.go).
const fristenrechnerCodeToCascadeSegment: Record<string, string> = {
"upc.inf.cfi": "upc-inf",
"upc.ccr.cfi": "upc-inf",
"upc.rev.cfi": "upc-rev",
"upc.apl.merits": "upc-app",
"upc.pi.cfi": "upc-pi",
"de.inf.lg": "de-inf",
"de.null.bpatg": "de-null",
"de.inf.bgh": "de-bgh-inf",
"de.null.bgh": "de-bgh-null",
"dpma.opp.dpma": "dpma-opp",
"dpma.appeal.bgh":"dpma-bgh",
"epa.opp.opd": "epa-opp",
"epa.opp.boa": "epa-app",
};
// Set of kebab segments known to be proceeding-axis values. Used to
// distinguish "this child is a proceeding pick" from "this child is a
// sender / bucket / Schriftsatz" — proceeding-axis siblings get filtered
// by project context, others always pass.
const proceedingCascadeSegments = new Set(Object.values(fristenrechnerCodeToCascadeSegment));
function fristenrechnerCodeFromProject(p?: ProjectOption | null): string | null {
if (!p || p.proceeding_type_id == null) return null;
return cachedProceedingTypes.get(p.proceeding_type_id) ?? null;
}
function projectCascadeSegment(p?: ProjectOption | null): string | null {
const code = fristenrechnerCodeFromProject(p);
if (!code) return null;
return fristenrechnerCodeToCascadeSegment[code] ?? null;
}
// cascadeChildAllowsProject narrows cascade children to those matching
// the project's proceeding code along the proceeding axis. Children
// whose terminal slug segment isn't a known proceeding segment always
// pass (they're orthogonal axes — sender / bucket / Schriftsatz / …).
// Without a project context this is a no-op.
function cascadeChildAllowsProject(child: EventCategoryNode, projectSegment: string | null): boolean {
if (!projectSegment) return true;
const terminal = child.slug.split(".").pop() || "";
if (!proceedingCascadeSegments.has(terminal)) return true;
return terminal === projectSegment;
}
function filterCascadeChildren(scope: EventCategoryNode[], projectSegment: string | null): EventCategoryNode[] {
return scope.filter((c) =>
inboxFilterAllowsForums(c.forums)
&& perspectiveAllowsParty(c.party)
&& cascadeChildAllowsProject(c, projectSegment));
}
// projectReferenceLabel returns the short identifier used in the "aus
// Akte: <reference>" tag. Falls back to a short slice of the UUID when
// the project has no human-readable reference yet.
function projectReferenceLabel(p?: ProjectOption | null): string {
if (!p) return "";
if (p.reference && p.reference.trim() !== "") return p.reference.trim();
return p.id ? p.id.slice(0, 8) : "";
}
function buildRowStack(currentSlug: string): RowSpec[] {
const rows: RowSpec[] = [];
const proj = currentStep1Context.kind === "project" ? currentStep1Context.project : undefined;
const hasProject = !!proj;
const projectForum = hasProject ? forumFromProject(proj) : null;
const projectSegment = projectCascadeSegment(proj);
const projectRef = projectReferenceLabel(proj);
// Once a single "aus Akte: <reference>" tag has been rendered, subsequent
// prefilled rows omit the reference (design §11.2) — repeating it on
// every row is just noise.
let referenceTagUsed = false;
const consumeReferenceTag = (): string => {
if (!projectRef) return "";
if (referenceTagUsed) return "";
referenceTagUsed = true;
return projectRef;
};
// R1 — Perspective. Default null ("Beide") = no filter; the row
// renders as answered with that label. t-paliad-164 carry-over: a
// project-bound perspective shows as `is-prefilled` with the "aus Akte"
// tag in the picked-answer area.
const perspectiveValue = currentPerspective ?? "";
const expectedFromAkte = ourSideToPerspective(proj?.our_side);
const perspectivePrefilled =
!!(proj && proj.our_side && expectedFromAkte === currentPerspective && currentPerspective !== null);
rows.push({
kind: "perspective",
rowId: "perspective",
state: currentActiveRow === "perspective"
? "active"
: perspectivePrefilled ? "prefilled" : "answered",
question: t("deadlines.perspective.label"),
options: [
{ value: "claimant", label: t("deadlines.perspective.claimant.short"), title: t("deadlines.perspective.claimant.title") },
{ value: "defendant", label: t("deadlines.perspective.defendant.short"), title: t("deadlines.perspective.defendant.title") },
{ value: "", label: t("deadlines.perspective.both.short") },
],
pickedValue: perspectiveValue,
pickedLabel: perspectiveOptionLabel(perspectiveValue),
prefilledReference: perspectivePrefilled ? consumeReferenceTag() : undefined,
});
// R2 — Inbox channel. Hidden entirely when the project's forum is UPC
// (CMS is the only valid inbox for UPC matters, so the question is
// moot — design §5 matrix). For non-UPC projects, ad-hoc explore mode,
// and the no-context case, the row renders as answered with whatever
// "Wo kam es an?" channel was last picked (default: "Alle").
const r2Hidden = projectForum === "upc" && currentActiveRow !== "inbox";
if (!r2Hidden) {
const inboxValue = currentInboxChannel ?? "";
rows.push({
kind: "inbox",
rowId: "inbox",
state: currentActiveRow === "inbox" ? "active" : "answered",
question: t("deadlines.inbox.label"),
options: [
{ value: "cms", label: "CMS", title: t("deadlines.inbox.cms.title") },
{ value: "bea", label: "beA", title: t("deadlines.inbox.bea.title") },
{ value: "posteingang", label: t("deadlines.inbox.posteingang"), title: t("deadlines.inbox.posteingang.title") },
{ value: "", label: t("deadlines.inbox.all") },
],
pickedValue: inboxValue,
pickedLabel: inboxOptionLabel(inboxValue),
});
}
// R3..Rn — Cascade. URL slug = user-picked deepest position. The
// trail walks the URL slug; each trail node renders as `answered`
// unless it was the sole option at its parent's filtered scope under
// a project context — in which case it's `prefilled` (the user
// effectively had no choice; project context implied this pick).
//
// After the trail, the auto-walk extends the stack with `prefilled`
// rows whenever the remaining scope narrows to a single option under
// a project context; the walk stops at the first branching point,
// empty scope, or leaf — which is where the active row goes (if any).
// Auto-walk does NOT extend the URL — the user-picked slug stays
// explicit. "Ändern" on an auto-walked row sets `cascadeAutoWalkStopAfter`
// so the next render shows the active row at that depth.
const trail = eventCategoryTree
? buildBreadcrumb(eventCategoryTree, currentSlug)
: [];
let parentScope: EventCategoryNode[] = eventCategoryTree || [];
let parentSlug = "";
let parentQuestion = t("deadlines.pathway.b.tree.start_question") || "Was ist passiert?";
for (let i = 0; i < trail.length; i++) {
const node = trail[i];
const filteredSiblings = filterCascadeChildren(parentScope, projectSegment);
// "Sole option" detection: at this depth, the project-filtered scope
// contains only this node — the pick was implied by context.
const wasSoleOption = hasProject && filteredSiblings.length === 1
&& filteredSiblings[0].slug === node.slug;
const isPrefilled = wasSoleOption;
rows.push({
kind: "cascade",
rowId: `cascade:${i}`,
state: isPrefilled ? "prefilled" : "answered",
question: parentQuestion,
options: filteredSiblings.map(nodeToRowOption),
pickedValue: node.slug,
pickedLabel: nodeLabel(node),
pickedIcon: node.icon,
cascadeParentSlug: parentSlug,
cascadeRevertSlug: parentSlug,
cascadeDepth: i,
prefilledReference: isPrefilled ? consumeReferenceTag() : undefined,
});
parentScope = node.children || [];
parentSlug = node.slug;
parentQuestion = nodeStepQuestion(node) || parentQuestion;
}
// Auto-walk extension + active row. Walk down the cascade as long as
// the filtered scope narrows to a single option (with a project
// context that justifies the inference); stop at branching, empty
// scope, or a leaf. The first branching point becomes the active row;
// an empty scope or leaf finalises the stack with no active row.
let walkDepth = trail.length;
const deepestTrail = trail.length > 0 ? trail[trail.length - 1] : null;
let walked: EventCategoryNode | null = null;
if (!deepestTrail || !deepestTrail.is_leaf) {
// eslint-disable-next-line no-constant-condition
while (true) {
const filtered = filterCascadeChildren(parentScope, projectSegment);
if (filtered.length === 0) break;
const canAutoWalk = filtered.length === 1
&& hasProject
&& (cascadeAutoWalkStopAfter === null || walkDepth < cascadeAutoWalkStopAfter);
if (canAutoWalk) {
const node = filtered[0];
rows.push({
kind: "cascade",
rowId: `cascade:${walkDepth}`,
state: "prefilled",
question: parentQuestion,
options: filtered.map(nodeToRowOption),
pickedValue: node.slug,
pickedLabel: nodeLabel(node),
pickedIcon: node.icon,
cascadeParentSlug: parentSlug,
// Revert slug for an auto-walked row is the URL-explicit slug
// (= the trail tail). ändern on this row should NOT change the
// URL — it suppresses auto-walk past this depth instead. See
// handleRowEdit's cascade branch.
cascadeRevertSlug: parentSlug,
cascadeDepth: walkDepth,
prefilledReference: consumeReferenceTag(),
});
walked = node;
parentScope = node.children || [];
parentSlug = node.slug;
parentQuestion = nodeStepQuestion(node) || parentQuestion;
walkDepth += 1;
if (node.is_leaf) break;
continue;
}
// Branching or no-project — render active row at this depth.
rows.push({
kind: "cascade",
rowId: `cascade:${walkDepth}`,
state: "active",
question: parentQuestion,
options: filtered.map(nodeToRowOption),
cascadeParentSlug: parentSlug,
cascadeRevertSlug: parentSlug,
cascadeDepth: walkDepth,
});
break;
}
}
// Stash the effective deepest slug so renderRowStack can pass it to
// runB1Search — the result panel narrows on the auto-walked leaf, not
// the URL slug.
cascadeEffectiveSlug = walked ? walked.slug : (deepestTrail ? deepestTrail.slug : "");
return rows;
}
// cascadeEffectiveSlug is the deepest slug after auto-walk extension —
// used by runB1Search to narrow the concept-card results to the
// effective cascade position, not just the URL-explicit slug. Updated
// by buildRowStack on every render.
let cascadeEffectiveSlug = "";
function nodeToRowOption(c: EventCategoryNode): RowOption {
return {
value: c.slug,
label: nodeLabel(c),
icon: c.icon,
isLeaf: c.is_leaf,
};
}
function rowOptionChipHtml(rowId: string, opt: RowOption, isPicked: boolean): string {
const cls = [
"fristen-row-chip",
opt.isLeaf ? "fristen-row-chip--leaf" : "",
isPicked ? "is-picked" : "",
].filter(Boolean).join(" ");
const titleAttr = opt.title ? ` title="${escAttr(opt.title)}"` : "";
const iconHtml = opt.icon
? `<span class="fristen-row-chip-icon" aria-hidden="true">${escHtml(opt.icon)}</span>`
: "";
return `<button type="button" class="${cls}" data-row-id="${escAttr(rowId)}"
data-row-value="${escAttr(opt.value)}"${titleAttr}>
${iconHtml}<span class="fristen-row-chip-label">${escHtml(opt.label)}</span>
</button>`;
}
function rowHtml(row: RowSpec, rowNumber: number): string {
const stateClass = `is-${row.state}`;
const ariaCurrent = row.state === "active" ? ' aria-current="step"' : "";
const dataKindAttr = ` data-row-kind="${escAttr(row.kind)}"`;
const dataIdAttr = ` data-row-id="${escAttr(row.rowId)}"`;
if (row.state === "active") {
const chipsHtml = row.options.map((o) =>
rowOptionChipHtml(row.rowId, o, o.value === (row.pickedValue ?? ""))).join("");
return `<div class="fristen-row ${stateClass}"${ariaCurrent}${dataKindAttr}${dataIdAttr}>
<div class="fristen-row-head">
<span class="fristen-row-num" aria-hidden="true">${rowNumber}</span>
<span class="fristen-row-label">${escHtml(row.question)}</span>
</div>
<div class="fristen-row-body">${chipsHtml}</div>
</div>`;
}
// answered + prefilled share the compact head-only layout.
const iconHtml = row.pickedIcon
? `<span class="fristen-row-answer-icon" aria-hidden="true">${escHtml(row.pickedIcon)}</span>`
: "";
// Prefilled tag carries the project reference on the first prefilled
// row only (design §11.2 — subsequent rows show the plain "aus Akte"
// tag). prefilledReference !== "" signals "this row owns the
// reference"; "" / undefined signals plain tag.
const prefilledTag = row.state === "prefilled"
? (row.prefilledReference
? `<span class="fristen-row-prefilled-tag">
<span data-i18n="deadlines.row.prefilled.from_akte">aus Akte</span>:
<span class="fristen-row-prefilled-ref">${escHtml(row.prefilledReference)}</span>
</span>`
: `<span class="fristen-row-prefilled-tag" data-i18n="deadlines.row.prefilled.from_akte">aus Akte</span>`)
: "";
return `<div class="fristen-row ${stateClass}"${dataKindAttr}${dataIdAttr}>
<div class="fristen-row-head">
<span class="fristen-row-num" aria-hidden="true">${rowNumber}</span>
<span class="fristen-row-label">${escHtml(row.question)}</span>
<span class="fristen-row-answer">
<span class="fristen-row-answer-check" aria-hidden="true">&#10003;</span>
${iconHtml}<span class="fristen-row-answer-label">${escHtml(row.pickedLabel || "")}</span>
${prefilledTag}
</span>
<button type="button" class="fristen-row-edit" data-row-edit="${escAttr(row.rowId)}">
<span>${escHtml(t("deadlines.row.edit"))}</span>
</button>
</div>
</div>`;
}
function renderRowStack(currentSlug: string) {
const stack = document.getElementById("fristen-row-stack");
if (!stack || !eventCategoryTree) return;
const rows = buildRowStack(currentSlug);
stack.innerHTML = rows.map((r, i) => rowHtml(r, i + 1)).join("");
maybeShowAutoWalkTooltip(stack, rows);
// t-paliad-198 Slice 3: bring the active row into view on every
// render. Particularly important on mobile, where a chip pick may
// push the next active row below the fold; the helper is a no-op
// when the row is already in view, so desktop doesn't see jumpy
// scrolling.
autoscrollToActiveRow(stack);
// Wire chip picks (active rows).
stack.querySelectorAll<HTMLButtonElement>(".fristen-row-chip").forEach((btn) => {
btn.addEventListener("click", () => {
const rowId = btn.dataset.rowId || "";
const value = btn.dataset.rowValue || "";
handleRowPick(rowId, value);
});
});
// Wire "ändern" affordance + whole-row click on answered/prefilled
// rows. Whole-row click follows the CLAUDE.md "row-level click handler
// that skips inner <a>/<button>" pattern — clicking the ändern button
// is handled by its own listener, so the row-level handler skips it.
stack.querySelectorAll<HTMLButtonElement>(".fristen-row-edit").forEach((btn) => {
btn.addEventListener("click", (ev) => {
ev.stopPropagation();
const rowId = btn.dataset.rowEdit || "";
handleRowEdit(rowId);
});
});
stack.querySelectorAll<HTMLDivElement>(".fristen-row.is-answered, .fristen-row.is-prefilled")
.forEach((row) => {
row.addEventListener("click", (ev) => {
const target = ev.target as Element | null;
if (target && target.closest("a, button")) return;
const rowId = (row as HTMLElement).dataset.rowId || "";
handleRowEdit(rowId);
});
});
runB1Search(cascadeEffectiveSlug);
}
// handleRowPick routes a chip selection in an active row to the right
// state mutator + URL writer. Cascade picks navigate to the child slug
// (slugs encode the full path, so cascade depth is reconstructed on
// every render); the other kinds update their per-kind state + clear
// the "currently being edited" override.
function handleRowPick(rowId: string, value: string) {
// t-paliad-198 Slice 3: any chip pick is also a "user interacted"
// signal — the auto-walk tooltip's job is done.
dismissAutoWalkTooltip();
if (rowId === "perspective") {
const next: Perspective = value === "claimant" || value === "defendant" ? value : null;
writePerspectiveToURL(next);
currentActiveRow = null;
cascadeAutoWalkStopAfter = null;
applyPerspective(next);
return;
}
if (rowId === "inbox") {
const next: InboxChannel = value === "cms" || value === "bea" || value === "posteingang"
? value : null;
writeInboxToURL(next);
currentActiveRow = null;
cascadeAutoWalkStopAfter = null;
applyInboxFilter(next);
applyFineForumsFromInbox(next);
writeForumsToURL(true);
void persistInboxPref(next);
return;
}
if (rowId.startsWith("cascade:")) {
currentActiveRow = null;
// A new cascade pick clears any auto-walk cap — the user has
// committed to this branch, so auto-walk can re-engage from here.
cascadeAutoWalkStopAfter = null;
navigateB1(value);
return;
}
}
// handleRowEdit re-activates an answered row. For non-cascade rows that
// flips the row to active in-place (toggle via currentActiveRow) so the
// user can re-pick; the cascade below stays valid. For cascade rows it
// either drops descendants by navigating to the row's revert slug —
// matching today's breadcrumb-click semantic — or, when the row was
// auto-walked, caps the auto-walk depth so the row turns active
// in-place without changing the URL.
function handleRowEdit(rowId: string) {
if (rowId === "perspective" || rowId === "inbox") {
currentActiveRow = rowId;
renderRowStack(readB1PathFromURL());
// t-paliad-198 Slice 3: any explicit ändern click counts as user
// interaction, so the auto-walk tooltip is no longer needed.
dismissAutoWalkTooltip();
return;
}
if (rowId.startsWith("cascade:")) {
const idx = parseInt(rowId.slice("cascade:".length), 10);
if (!Number.isFinite(idx)) return;
const urlSlug = readB1PathFromURL();
const trail = buildBreadcrumb(eventCategoryTree || [], urlSlug);
if (idx < trail.length) {
// Row K is within the user-explicit trail — ändern drops to
// the parent slug, which drops descendants AND clears any
// auto-walk suppression so the next pick can extend again.
const revertSlug = idx > 0 ? (trail[idx - 1]?.slug || "") : "";
currentActiveRow = null;
cascadeAutoWalkStopAfter = null;
navigateB1(revertSlug);
dismissAutoWalkTooltip();
return;
}
// Row K is auto-walked (beyond the URL trail). Suppress auto-walk
// past depth K so the row materialises as active in the next
// render — URL stays at the trail end.
cascadeAutoWalkStopAfter = idx;
currentActiveRow = null;
renderRowStack(urlSlug);
dismissAutoWalkTooltip();
}
}
// ============================================================================
// t-paliad-198 Slice 3 — inline search overlay + autoscroll-to-active +
// tooltip polish.
// ============================================================================
//
// Inline search replaces the Slice 1 mode-toggle row: clicking the
// `🔍 Direkt suchen` icon in the row-stack header expands a search input
// over the row stack and renders results into the same #fristen-b1-results
// container the cascade narrows into. ESC inside the input or the back
// button collapses it. State is ephemeral — no URL change — so a user can
// step into search to check a name, then return to the cascade with all
// their picks intact. Deep-link path ?mode=filter still routes to the
// legacy B2 panel for backwards compatibility, but is no longer exposed
// in the cascade UI.
let inlineSearchActive = false;
let inlineSearchSeq = 0;
let inlineSearchDebounce: number | undefined;
function setInlineSearchActive(active: boolean) {
if (inlineSearchActive === active) return;
inlineSearchActive = active;
const panel = document.getElementById("fristen-row-search-panel");
const header = document.getElementById("fristen-row-stack-header");
const stack = document.getElementById("fristen-row-stack");
const results = document.getElementById("fristen-b1-results");
const trigger = document.getElementById("fristen-row-search-link") as HTMLButtonElement | null;
const input = document.getElementById("fristen-row-search-panel-input") as HTMLInputElement | null;
if (!panel || !stack) return;
panel.hidden = !active;
stack.hidden = active;
// Reset link belongs to the cascade affordance set; hide while the
// user is in search mode so the header reads as "← back to tree" only.
if (header) header.classList.toggle("is-inline-search-active", active);
if (trigger) trigger.setAttribute("aria-expanded", active ? "true" : "false");
if (active) {
if (input) {
input.focus();
if (input.value.trim() !== "") {
scheduleInlineSearch(0);
} else if (results) {
// No query yet — clear results so a stale cascade leaf panel
// doesn't show alongside the search input.
results.innerHTML = "";
results.classList.remove("is-loading", "is-no-hits");
}
}
} else {
// On collapse, restore the cascade results for the current slug
// so the user picks up exactly where they left off.
runB1Search(cascadeEffectiveSlug || readB1PathFromURL());
}
}
function scheduleInlineSearch(delayMs = 180) {
if (inlineSearchDebounce !== undefined) window.clearTimeout(inlineSearchDebounce);
inlineSearchDebounce = window.setTimeout(runInlineSearch, delayMs);
}
async function runInlineSearch() {
const input = document.getElementById("fristen-row-search-panel-input") as HTMLInputElement | null;
const results = document.getElementById("fristen-b1-results");
const clearBtn = document.getElementById("fristen-row-search-panel-clear") as HTMLButtonElement | null;
if (!input || !results) return;
const q = input.value.trim();
if (clearBtn) clearBtn.hidden = q.length === 0;
if (q === "") {
results.innerHTML = "";
results.classList.remove("is-loading", "is-no-hits");
return;
}
results.classList.remove("is-no-hits");
results.classList.add("is-loading");
if (results.childElementCount === 0) {
results.innerHTML = `<div class="fristen-search-status">${escHtml(t("deadlines.search.loading"))}</div>`;
}
const seq = ++inlineSearchSeq;
try {
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
url.searchParams.set("q", q);
url.searchParams.set("limit", "12");
const forums = getActiveForumsParam();
if (forums) url.searchParams.set("forum", forums);
const r = await fetch(url.toString(), { credentials: "same-origin" });
if (seq !== inlineSearchSeq) return;
results.classList.remove("is-loading");
if (!r.ok) {
results.innerHTML = `<div class="fristen-search-status fristen-search-error">${escHtml(t("deadlines.search.no_hits"))}</div>`;
return;
}
const data = (await r.json()) as SearchResponse;
if (seq !== inlineSearchSeq) return;
if (!data.cards || data.cards.length === 0) {
results.innerHTML = `<div class="fristen-search-status">${escHtml(t("deadlines.search.no_hits"))}</div>`;
results.classList.add("is-no-hits");
return;
}
renderSearchResultsInto("fristen-b1-results", data);
} catch {
if (seq !== inlineSearchSeq) return;
results.classList.remove("is-loading");
results.innerHTML = `<div class="fristen-search-status fristen-search-error">${escHtml(t("deadlines.search.no_hits"))}</div>`;
}
}
function initInlineSearch() {
const input = document.getElementById("fristen-row-search-panel-input") as HTMLInputElement | null;
const clearBtn = document.getElementById("fristen-row-search-panel-clear") as HTMLButtonElement | null;
if (input) {
input.addEventListener("input", () => scheduleInlineSearch());
input.addEventListener("keydown", (ev) => {
if (ev.key === "Escape") {
if (input.value.trim() !== "") {
// First ESC: clear the input; second ESC: close the panel.
input.value = "";
scheduleInlineSearch(0);
return;
}
setInlineSearchActive(false);
}
});
}
if (clearBtn) {
clearBtn.addEventListener("click", () => {
if (!input) return;
input.value = "";
input.focus();
scheduleInlineSearch(0);
});
}
}
// autoscrollToActiveRow brings the active cascade row into view when a
// user pick on a higher row causes descendants to drop and the next
// active row is now below the fold. The 60px headroom matches the
// pattern used by the Akte picker (Step 1) and other paliad forms so
// the row's chip body is visible without scrolling further. Desktop +
// mobile share this behaviour; the visual change is only noticeable on
// narrow viewports where the row stack scrolls.
function autoscrollToActiveRow(stack: HTMLElement) {
const active = stack.querySelector<HTMLElement>(".fristen-row.is-active");
if (!active) return;
// Skip if the active row is already fully visible (avoid jumpy
// scroll-on-every-render on desktop).
const rect = active.getBoundingClientRect();
if (rect.top >= 60 && rect.bottom <= window.innerHeight - 16) return;
const targetY = window.scrollY + rect.top - 60;
window.scrollTo({ top: Math.max(0, targetY), behavior: "smooth" });
}
// dismissAutoWalkTooltip removes the live tooltip element (if any) and
// flips the localStorage suppression key so future renders skip it.
// Called from any meaningful user interaction (chip pick, ändern,
// dismiss-button) — once the user has touched the cascade they no
// longer need the inference hint.
function dismissAutoWalkTooltip() {
document.querySelector(".fristen-row-autowalk-tip")?.remove();
try { localStorage.setItem(cascadeTooltipDismissedKey, "1"); } catch { /* private mode */ }
}
// maybeShowAutoWalkTooltip surfaces a one-time hint when ≥ 2 cascade
// rows render in the prefilled (auto-walked) state. Per design §11.3
// and Q11 (deferred to v2 nice-to-have), the tooltip only appears the
// first time the user lands in a multi-row auto-walk — once dismissed,
// localStorage suppresses it forever. Tooltip is inline (no portal /
// modal): it injects a small banner above the first prefilled row.
function maybeShowAutoWalkTooltip(stack: HTMLElement, rows: RowSpec[]) {
const prefilledCount = rows.filter((r) => r.state === "prefilled" && r.kind === "cascade").length;
if (prefilledCount < 2) return;
let dismissed = false;
try { dismissed = localStorage.getItem(cascadeTooltipDismissedKey) === "1"; } catch { /* private mode */ }
if (dismissed) return;
// Inject the tooltip element. It's a sibling of the row stack, slotted
// just above the first prefilled row via DOM insertion. The
// `is-entering` class drives the fade-in + slide-down animation in
// CSS (Slice 3 polish); we remove it on the next animation frame so
// the CSS transition kicks in.
const firstPrefilled = stack.querySelector(".fristen-row.is-prefilled");
if (!firstPrefilled) return;
const tip = document.createElement("div");
tip.className = "fristen-row-autowalk-tip is-entering";
tip.setAttribute("role", "status");
tip.innerHTML = `
<span class="fristen-row-autowalk-tip-icon" aria-hidden="true">&#9432;</span>
<span class="fristen-row-autowalk-tip-text" data-i18n="deadlines.row.autowalk.tooltip">
${escHtml(t("deadlines.row.autowalk.tooltip"))}
</span>
<button type="button" class="fristen-row-autowalk-tip-dismiss"
aria-label="${escAttr(t("deadlines.row.autowalk.dismiss"))}">
&times;
</button>`;
// Desktop: tip sits above the prefilled row so it reads as a header.
// Mobile (<640px): tip sits below the row so the user's eye lands on
// the row's "aus Akte" tag first and reads the explanation beneath
// — matches design §7 + the natural reading pattern on small screens.
const isMobile = window.matchMedia && window.matchMedia("(max-width: 640px)").matches;
if (isMobile) {
firstPrefilled.parentElement?.insertBefore(tip, firstPrefilled.nextSibling);
} else {
firstPrefilled.parentElement?.insertBefore(tip, firstPrefilled);
}
// Trigger the transition: remove the is-entering offset on next frame.
window.requestAnimationFrame(() => tip.classList.remove("is-entering"));
tip.querySelector<HTMLButtonElement>(".fristen-row-autowalk-tip-dismiss")?.addEventListener("click", () => {
dismissAutoWalkTooltip();
});
}
// b1SearchSeq guards against out-of-order responses when the user
// click-cascades faster than the network. Only the latest invocation
// gets to render its result.
let b1SearchSeq = 0;
async function runB1Search(slug: string) {
const results = document.getElementById("fristen-b1-results");
if (!results) return;
wirePillClicks(results);
const seq = ++b1SearchSeq;
results.classList.add("is-loading");
results.classList.remove("is-no-hits");
// Fade-out is CSS-driven via .is-loading; render the spinner-row only
// when the container is still empty (first paint), otherwise keep the
// previous cards visible underneath the dimming layer for continuity.
if (results.childElementCount === 0) {
results.innerHTML = `<div class="fristen-search-status">${escHtml(t("deadlines.search.loading"))}</div>`;
}
try {
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
if (slug) {
url.searchParams.set("event_category_slug", slug);
} else {
// No tree node picked yet → show every concept reachable from any
// leaf (t-paliad-134: full landscape on entry).
url.searchParams.set("browse", "all");
}
const forums = getActiveForumsParam();
if (forums) url.searchParams.set("forum", forums);
const r = await fetch(url.toString());
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
if (seq !== b1SearchSeq) return;
results.classList.remove("is-loading");
if (!data.cards || data.cards.length === 0) {
// At root we should always have data (event_category_concepts is
// seeded). At a deeper node, offer to step back. Either way render
// a friendly empty state.
const empty = `<div class="fristen-search-status fristen-search-error">
${escHtml(t("deadlines.pathway.b.tree.empty"))}
${slug ? `<button type="button" class="fristen-b1-loosen-link" data-action="loosen">
${escHtml(t("deadlines.pathway.b.tree.step.back"))}
</button>` : ""}
</div>`;
results.innerHTML = empty;
results.classList.add("is-no-hits");
if (slug) {
results.querySelector<HTMLButtonElement>(".fristen-b1-loosen-link")?.addEventListener("click", () => {
const trail = buildBreadcrumb(eventCategoryTree || [], slug);
const parent = trail.length > 1 ? trail[trail.length - 2].slug : "";
navigateB1(parent);
});
}
return;
}
renderSearchResultsInto("fristen-b1-results", data);
} catch (e) {
if (seq !== b1SearchSeq) return;
results.classList.remove("is-loading");
results.innerHTML = `<div class="fristen-search-status fristen-search-error">
${escHtml(t("deadlines.search.no_hits"))}
</div>`;
}
}
function navigateB1(slug: string) {
setB1PathInURL(slug);
renderRowStack(slug);
}
// loadAndRenderB1 fetches the tree (cached after first call) and
// renders the cascade + result cards at the slug currently in the URL.
// Module-level so showBMode("tree") can trigger it on Pathway B entry
// without relying on a synthetic radio-change event.
async function loadAndRenderB1() {
try {
await loadEventCategoryTree();
renderRowStack(readB1PathFromURL());
} catch (e) {
const stack = document.getElementById("fristen-row-stack");
if (stack) {
stack.innerHTML = `<div class="fristen-b1-error">${escHtml(t("deadlines.pathway.b.tree.empty"))}</div>`;
}
}
}
async function initB1Cascade() {
const panel = document.getElementById("fristen-b1-panel");
if (!panel) return;
// t-paliad-180: mode-radio retired; the row-stack's mode-row click
// handler drives tree↔filter routing. No standalone change listener
// needed here — showBMode() triggers loadAndRenderB1 when the
// pathway enters tree mode.
// Initial render if the URL already lands in tree mode.
const sp = new URLSearchParams(window.location.search);
if (sp.get("path") === "b" && sp.get("mode") === "tree") {
loadAndRenderB1();
}
// popstate restores the cascade depth.
window.addEventListener("popstate", () => {
const params = new URLSearchParams(window.location.search);
if (params.get("path") === "b" && params.get("mode") === "tree") {
// Always re-render — tree may not have loaded yet on first popstate.
currentActiveRow = null;
cascadeAutoWalkStopAfter = null;
loadAndRenderB1();
}
});
}
document.addEventListener("DOMContentLoaded", initB1Cascade);
// ============================================================================
// v3 B2 forum filter (t-paliad-133 Phase D)
// ============================================================================
// 10 forum buckets per m's spec lock §10 Q8. Multi-select chips,
// AND-narrowing: each chip click toggles its membership in the active
// set; the active set is sent as ?forum=<comma-separated> on every
// search. Empty set = no filter.
const FORUM_BUCKETS: { slug: string; i18nKey: string }[] = [
{ slug: "upc_cfi", i18nKey: "deadlines.filter.forum.upc_cfi" },
{ slug: "upc_coa", i18nKey: "deadlines.filter.forum.upc_coa" },
{ slug: "de_lg", i18nKey: "deadlines.filter.forum.de_lg" },
{ slug: "de_olg", i18nKey: "deadlines.filter.forum.de_olg" },
{ slug: "de_bgh", i18nKey: "deadlines.filter.forum.de_bgh" },
{ slug: "de_bpatg", i18nKey: "deadlines.filter.forum.de_bpatg" },
{ slug: "epa_grant", i18nKey: "deadlines.filter.forum.epa_grant" },
{ slug: "epa_opp", i18nKey: "deadlines.filter.forum.epa_opp" },
{ slug: "epa_appeal", i18nKey: "deadlines.filter.forum.epa_appeal" },
{ slug: "dpma", i18nKey: "deadlines.filter.forum.dpma" },
];
const activeForums = new Set<string>();
function readForumsFromURL(): string[] {
const sp = new URLSearchParams(window.location.search);
const raw = sp.get("forum");
if (!raw) return [];
return raw.split(",").map((s) => s.trim()).filter((s) => FORUM_BUCKETS.some((b) => b.slug === s));
}
function writeForumsToURL(replace = false) {
const url = new URL(window.location.href);
if (activeForums.size === 0) {
url.searchParams.delete("forum");
} else {
url.searchParams.set("forum", Array.from(activeForums).sort().join(","));
}
if (replace) {
window.history.replaceState({}, "", url.toString());
} else {
window.history.pushState({}, "", url.toString());
}
}
function renderForumChips() {
const container = document.getElementById("fristen-forum-chips");
const wrapper = document.getElementById("fristen-forum-filter");
if (!container || !wrapper) return;
wrapper.hidden = false;
container.innerHTML = FORUM_BUCKETS.map((b) => {
const active = activeForums.has(b.slug);
return `<button type="button" class="fristen-forum-chip${active ? " fristen-forum-chip--active" : ""}"
data-forum="${escAttr(b.slug)}"
aria-pressed="${active ? "true" : "false"}">
${escHtml(t(b.i18nKey))}
</button>`;
}).join("");
container.querySelectorAll<HTMLButtonElement>(".fristen-forum-chip").forEach((chip) => {
chip.addEventListener("click", () => {
const slug = chip.dataset.forum || "";
if (!slug) return;
if (activeForums.has(slug)) {
activeForums.delete(slug);
} else {
activeForums.add(slug);
}
writeForumsToURL();
renderForumChips();
reissueSearchWithCurrentFilters();
});
});
}
function reissueSearchWithCurrentFilters() {
// If we're in B1 mode, refresh the current cascade slug's results.
const sp = new URLSearchParams(window.location.search);
if (sp.get("mode") === "tree") {
const slug = sp.get("b1") || "";
if (slug) {
runB1Search(slug);
return;
}
}
// Otherwise re-trigger the B2 search input handler.
const input = document.getElementById("fristen-search-input") as HTMLInputElement | null;
if (input && input.value.trim() !== "") {
input.dispatchEvent(new Event("input", { bubbles: true }));
}
}
function getActiveForumsParam(): string {
if (activeForums.size === 0) return "";
return Array.from(activeForums).sort().join(",");
}
function initForumFilter() {
// Hydrate from URL on first load.
for (const slug of readForumsFromURL()) {
activeForums.add(slug);
}
renderForumChips();
// Restore on browser nav.
window.addEventListener("popstate", () => {
activeForums.clear();
for (const slug of readForumsFromURL()) {
activeForums.add(slug);
}
renderForumChips();
});
// Re-render labels on language change.
onLangChange(() => renderForumChips());
}
document.addEventListener("DOMContentLoaded", initForumFilter);
// ============================================================================
// m/paliad#15 inbox-channel pre-filter
// ============================================================================
// Coarse forum chip on the page header above the pathway fork. Three
// values: cms (UPC), bea (national-DE), posteingang (national-DE, slower
// channel — same forums as beA). State priority on hydrate:
// 1. URL ?inbox=cms|bea|posteingang (per-visit override; lets a
// colleague share a CMS-narrowed link without flipping anyone's
// saved preference)
// 2. /api/me forum_pref (the user's persisted default)
// 3. unset (no filter; picker shows all groups)
//
// On chip click: write URL + PATCH /api/me + filter Pathway A picker.
// Empty string PATCH body clears the saved preference (matches the
// EscalationContactID convention in services/user_service.go).
type InboxChannel = "cms" | "bea" | "posteingang" | null;
const INBOX_CHANNEL_VALUES = new Set<string>(["cms", "bea", "posteingang"]);
// currentInboxChannel mirrors the chip's active state so the B1 cascade
// renderer (which lives in a different section of this file) can ask
// "which forum is active right now?" without re-deriving from URL on
// every render. Updated by applyInboxFilter on hydrate / click /
// popstate.
let currentInboxChannel: InboxChannel = null;
function readInboxFromURL(): InboxChannel {
const raw = new URLSearchParams(window.location.search).get("inbox");
return raw && INBOX_CHANNEL_VALUES.has(raw) ? (raw as InboxChannel) : null;
}
function writeInboxToURL(ch: InboxChannel, replace = false) {
const url = new URL(window.location.href);
if (ch === null) url.searchParams.delete("inbox");
else url.searchParams.set("inbox", ch);
if (replace) window.history.replaceState({}, "", url.toString());
else window.history.pushState({}, "", url.toString());
}
// inboxChannelToForum collapses the channel onto the coarse forum
// bucket the proceeding-group filter understands. cms → upc; beA and
// Posteingang both → de (same set of national-DE proceedings, different
// inbox name). Null = no filter.
function inboxChannelToForum(ch: InboxChannel): "upc" | "de" | null {
if (ch === "cms") return "upc";
if (ch === "bea" || ch === "posteingang") return "de";
return null;
}
// inboxToFineForumSlugs maps the inbox channel to the matching subset
// of the 10-bucket B2 forum filter. CMS narrows to UPC CFI + CoA;
// beA / Posteingang narrow to all four national-DE buckets (LG / OLG /
// BGH / BPatG). Null returns an empty list — caller handles the
// "no inbox set" case explicitly because the meaning differs between
// hydrate (don't touch fine chips) and click-clear (clear them).
function inboxToFineForumSlugs(ch: InboxChannel): string[] {
if (ch === "cms") return ["upc_cfi", "upc_coa"];
if (ch === "bea" || ch === "posteingang") return ["de_lg", "de_olg", "de_bgh", "de_bpatg"];
return [];
}
// applyFineForumsFromInbox replaces activeForums with the inbox's
// implied fine-bucket set and re-renders the B2 chip strip + reissues
// the active search. Caller decides when to invoke (user click always;
// hydrate / popstate only when URL ?forum= is empty so an explicit
// link-share wins over the inbox derivation).
function applyFineForumsFromInbox(ch: InboxChannel) {
activeForums.clear();
for (const slug of inboxToFineForumSlugs(ch)) activeForums.add(slug);
// renderForumChips guards on missing container, so calling from
// contexts where Pathway B isn't yet rendered is safe.
renderForumChips();
reissueSearchWithCurrentFilters();
}
function applyInboxFilter(ch: InboxChannel) {
currentInboxChannel = ch;
// t-paliad-180: the inbox chip strip is gone — chip state now lives
// inside the row stack and is repainted on every renderRowStack call.
// Pathway A's "Verlauf" is intentionally NOT filtered here (m's
// 2026-05-08 feedback: the chip belongs inside the Determinator, not
// page-wide). The .proceeding-group [data-forum] attributes stay on
// the markup as documentation of intent but no longer drive
// visibility.
if (eventCategoryTree) {
renderRowStack(readB1PathFromURL());
}
}
// Slice 3b: cascade narrowing now flows from THREE inputs, in priority
// order. Whichever is set first wins.
//
// 1. Inbox chip (cms / bea / posteingang) — user-clicked override.
// Maps to upc / de / de.
// 2. Ad-hoc chip (Step 1's explore-mode upc / de / epa / dpma).
// 3. Project context (Step 1's selected Akte → proceeding_type_id →
// proceeding_types.code → forum prefix).
//
// activeForumOnPage() returns the first non-null value or null when
// nothing is set. inboxFilterAllowsForums consults this so the B1
// cascade narrows automatically when the user enters Pathway B with a
// project context — no extra clicks needed. The chip can still
// override at the top of the B1 panel.
let cachedProceedingTypes: Map<number, string> = new Map();
async function loadProceedingTypes(): Promise<void> {
try {
const resp = await fetch("/api/proceeding-types-db");
if (!resp.ok) return;
const list = (await resp.json()) as Array<{ id: number; code: string }>;
for (const pt of list) {
cachedProceedingTypes.set(pt.id, pt.code);
}
} catch {
// Anonymous visitor / network blip — leave the map empty; project
// narrowing falls back to "no opinion" and the cascade stays
// wide-open.
}
}
function forumFromProceedingCode(code: string): "upc" | "de" | "epa" | "dpma" | null {
if (code.startsWith("UPC_")) return "upc";
if (code.startsWith("DE_")) return "de";
if (code.startsWith("EPA_") || code.startsWith("EP_")) return "epa";
if (code.startsWith("DPMA_")) return "dpma";
return null;
}
function forumFromProject(p?: ProjectOption | null): "upc" | "de" | "epa" | "dpma" | null {
if (!p || p.proceeding_type_id == null) return null;
const code = cachedProceedingTypes.get(p.proceeding_type_id);
return code ? forumFromProceedingCode(code) : null;
}
function activeForumOnPage(): "upc" | "de" | "epa" | "dpma" | null {
const chipForum = inboxChannelToForum(currentInboxChannel);
if (chipForum !== null) return chipForum;
if (currentStep1Context.kind === "adhoc" && currentStep1Context.adhocForum) {
return currentStep1Context.adhocForum;
}
if (currentStep1Context.kind === "project") {
return forumFromProject(currentStep1Context.project);
}
return null;
}
// inboxFilterAllowsForums returns true when a node with the given
// forums tags should be visible. Neutral nodes (forums undefined /
// empty) are always visible. When no forum is active anywhere on the
// page, every node is visible.
function inboxFilterAllowsForums(forums: string[] | undefined): boolean {
if (!forums || forums.length === 0) return true;
const active = activeForumOnPage();
if (active === null) return true;
return forums.includes(active);
}
// triggerCascadeRefresh re-renders the B1 row stack if the panel is
// mounted. Call after any change that affects activeForumOnPage() or
// the perspective filter (row pick, project selection, ad-hoc
// selection, clear, perspective change).
function triggerCascadeRefresh() {
if (eventCategoryTree && document.getElementById("fristen-row-stack")) {
renderRowStack(readB1PathFromURL());
}
}
// ============================================================================
// Slice 3c: perspective chip (Klägerseite / Beklagtenseite / Beide)
// ============================================================================
// Lives at the top of the B1 panel above the inbox-channel chip strip.
// Filters the cascade by event_categories.party. Default unset →
// every leaf visible regardless of party.
//
// State is URL-only (?role=claimant|defendant). No localStorage —
// dogfood will tell us whether persistence is wanted.
type Perspective = "claimant" | "defendant" | null;
let currentPerspective: Perspective = null;
function readPerspectiveFromURL(): Perspective {
const raw = new URLSearchParams(window.location.search).get("role");
return raw === "claimant" || raw === "defendant" ? raw : null;
}
function writePerspectiveToURL(p: Perspective, replace = false) {
const url = new URL(window.location.href);
if (p === null) url.searchParams.delete("role");
else url.searchParams.set("role", p);
if (replace) window.history.replaceState({}, "", url.toString());
else window.history.pushState({}, "", url.toString());
}
function applyPerspective(p: Perspective) {
currentPerspective = p;
// t-paliad-180: chip state lives inside the row stack now; one refresh
// repaints the perspective row + re-narrows the cascade options.
triggerCascadeRefresh();
}
// ourSideToPerspective maps the project-level "Client Role" enum
// (DB column: our_side) onto the chip-strip Perspective.
//
// Per t-paliad-222 (m/paliad#47) the field carries one of seven
// sub-role values grouped at display time:
// Active (we initiate) : claimant, applicant, appellant → "claimant"
// Reactive (we defend) : defendant, respondent → "defendant"
// Other : third_party, other, NULL → null
//
// Legacy 'court' / 'both' values no longer exist in the column
// (mig 110 backfilled them to NULL); both fall through to the null
// default arm if a stale value sneaks in.
function ourSideToPerspective(os: string | null | undefined): Perspective {
switch (os) {
case "claimant":
case "applicant":
case "appellant":
return "claimant";
case "defendant":
case "respondent":
return "defendant";
default:
return null;
}
}
// applyOurSidePredefine locks the perspective from project.our_side
// when the user hasn't already explicitly picked one. The URL is the
// "explicit pick" signal: if ?role= is present at call time, the user
// (or a shared link) chose it and we don't overwrite. When we do
// predefine, we write the same value to the URL so back/forward +
// refresh round-trip cleanly. t-paliad-180: the "aus Akte" tag now
// lives inline in the prefilled-row state — no separate hint element.
//
// `replaceURL=true` is for the deep-link / refresh path; `false` for
// in-page project selection so back-button restores the empty state.
function applyOurSidePredefine(project: ProjectOption | undefined, replaceURL: boolean) {
if (!project || !project.our_side) return;
// URL wins — user has an explicit pick. Don't clobber it.
if (readPerspectiveFromURL() !== null) return;
const next = ourSideToPerspective(project.our_side);
writePerspectiveToURL(next, replaceURL);
applyPerspective(next);
}
// perspectiveAllowsParty returns true when a node tagged with `party`
// should be visible under the current perspective. Neutral nodes
// (party undefined / empty) always pass. "both" matches every
// perspective; "court" matches every perspective (court actions are
// neutral to the user's side).
function perspectiveAllowsParty(party: string[] | undefined): boolean {
if (!party || party.length === 0) return true;
if (currentPerspective === null) return true;
if (party.includes("both") || party.includes("court")) return true;
return party.includes(currentPerspective);
}
async function persistInboxPref(ch: InboxChannel) {
// forum_pref="" clears on the server side (NULL in the DB) — matches
// the EscalationContactID convention in services/user_service.go.
// Persistence is opportunistic; URL state already won the visit.
try {
await fetch("/api/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ forum_pref: ch === null ? "" : ch }),
});
} catch {
// Network blip on persist isn't worth blocking the UI; the user can
// re-pick on the next visit.
}
}
async function initInboxFilter() {
// t-paliad-180: the standalone inbox chip strip is retired; inbox
// state still drives cascade narrowing + B2 fine-bucket sync, just
// surfaced through the row-stack row now. This init still hydrates
// from URL / saved preference + wires the popstate restore.
if (!document.getElementById("fristen-b1-panel")) return;
let initial: InboxChannel = readInboxFromURL();
if (initial === null) {
try {
const resp = await fetch("/api/me", { credentials: "same-origin" });
if (resp.ok) {
const me = (await resp.json()) as { forum_pref?: string | null };
if (me.forum_pref && INBOX_CHANNEL_VALUES.has(me.forum_pref)) {
initial = me.forum_pref as InboxChannel;
}
}
} catch {
// Anonymous visitor or transient error — leave the chip unset.
}
}
applyInboxFilter(initial);
// Sync B2 fine-bucket chips from the inbox on hydrate ONLY when the
// URL doesn't explicitly carry ?forum=… — an explicit forum= comes
// from a shared link and should win over the user's saved inbox
// preference. initForumFilter (which runs first) has already
// populated activeForums from URL forum=, so we leave it alone here.
if (initial !== null && readForumsFromURL().length === 0) {
applyFineForumsFromInbox(initial);
writeForumsToURL(true);
}
window.addEventListener("popstate", () => {
const newInbox = readInboxFromURL();
applyInboxFilter(newInbox);
// popstate can land on a URL with inbox= but no forum= (the user
// navigated to a state where derivation should re-apply). Don't
// touch activeForums when forum= is explicit — initForumFilter's
// own popstate handler has already loaded it from the URL.
if (newInbox !== null && readForumsFromURL().length === 0) {
applyFineForumsFromInbox(newInbox);
}
});
}
document.addEventListener("DOMContentLoaded", initInboxFilter);