Files
paliad/frontend/src/client/fristenrechner.ts
m b54e938bdf feat(t-paliad-136): Phase B — card-click → calc panel → add to project
The v3 result cards were dead-ends: clicking a Klageerwiderung pill
showed no deadline; users had to switch to Pathway A's wizard, retype
the date, and read the deadline out of the timeline. v4 makes the card
the entry to a single-rule calculator + add-to-project flow per m's
2026-05-05 11:58 feedback.

Backend (single-rule calc, no parent walk):
- New POST /api/tools/fristenrechner/calculate-rule endpoint accepts
  either ruleId OR (proceedingCode + ruleLocalCode), trigger date, and
  optional condition flags. Returns rule metadata + computed dueDate +
  originalDate + adjustment-reason chip data.
- FristenrechnerService.CalculateRule() reuses the existing addDuration
  + HolidayService.AdjustForNonWorkingDaysWithReason pipeline so
  t-paliad-119's adjustment-reason explainer and t-paliad-121's UPC-
  Sommerferien skip both apply automatically. Court-determined rules
  (party='court' or event_type ∈ hearing/decision/order) return
  IsCourtSet=true and an empty due date.
- Flag-conditional rules surface FlagsRequired even when the caller
  hasn't supplied the flag — the UI uses this to render checkboxes;
  toggling recomputes live. With all flags satisfied + alt_duration_*
  present, the calc swaps to alt values (existing semantics).
- Live-DB integration test covers plain calc, court-set, flag handling,
  and error paths (skipped without TEST_DATABASE_URL).

Frontend (inline calc panel):
- Click any card body or rule pill → expand inline panel inside the
  card (only one open at a time). Pill picker (radio chips) appears
  when the card has 2+ rule pills; first preselected. Trigger date
  defaults to today (m's Q3). Flag checkboxes auto-render from the
  rule's condition_flag.
- Result row shows due date, "(N units from triggerDate)", and a
  shift chip when wasAdjusted ("⚠ Verschoben vom … wegen UPC-
  Sommerferien (27.7.–28.8.)").
- "Zu Akte hinzufügen" CTA → inline project picker → POST to existing
  /api/projects/{id}/deadlines/bulk with a single-element array using
  source='fristenrechner' (m's Q2: existing tag, no new audit category).
- Modifier-key clicks (Cmd/Ctrl/Shift/middle) preserve the legacy
  drill-to-Pathway-A semantics via <a href> anchors. Trigger pills
  (Wiedereinsetzung, etc.) keep the trigger-event drill — they don't
  have a single rule to compute.
- Escape collapses the open card.

CSS: lime accent border on hover/expanded; dashed top border for the
calc panel; mobile-friendly grid for the pill picker.

UPC R.221 cost-appeal sequence (m's Q5) is wired in Phase C's seed
already; Phase B's pill picker renders both pills (leave-to-appeal +
notice-of-appeal) when the user hits one of those leaves.
2026-05-05 14:04:54 +02:00

2671 lines
101 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
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import { projectIndent } from "./project-indent";
interface AdjustmentHoliday {
Date: string;
Name: string;
IsVacation: boolean;
IsClosure: boolean;
}
interface AdjustmentReason {
kind: "weekend" | "public_holiday" | "vacation";
holidays?: AdjustmentHoliday[];
vacation_name?: string;
vacation_start?: string;
vacation_end?: string;
original_weekday?: string;
}
interface CalculatedDeadline {
code: string;
name: string;
nameEN: string;
party: string;
isMandatory: boolean;
ruleRef: string;
legalSource?: string;
notes?: string;
notesEN?: string;
dueDate: string;
originalDate: string;
wasAdjusted: boolean;
adjustmentReason?: AdjustmentReason;
isRootEvent: boolean;
isCourtSet: boolean;
isOverridden?: boolean;
}
interface DeadlineResponse {
proceedingType: string;
proceedingName: string;
triggerDate: string;
deadlines: CalculatedDeadline[];
}
const PARTY_CLASS: Record<string, string> = {
claimant: "party-claimant",
defendant: "party-defendant",
court: "party-court",
both: "party-both",
};
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";
let procedureView: ProcedureView = "timeline";
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;
}
});
function formatDate(dateStr: string): string {
if (!dateStr) return "\u2014";
const d = new Date(dateStr + "T00:00:00");
if (getLang() === "en") {
// ISO date (YYYY-MM-DD) \u2014 unambiguous for both US and intl readers, since
// en-GB renders dd/mm/yyyy which US users misread as mm/dd/yyyy.
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
const yyyy = d.getFullYear();
const mm = String(d.getMonth() + 1).padStart(2, "0");
const dd = String(d.getDate()).padStart(2, "0");
return `${weekday}, ${yyyy}-${mm}-${dd}`;
}
return d.toLocaleDateString("de-DE", {
weekday: "short",
day: "2-digit",
month: "2-digit",
year: "numeric",
});
}
function partyBadge(party: string): string {
const cls = PARTY_CLASS[party] || "party-both";
return `<span class="party-badge ${cls}">${tDyn("deadlines.party." + party)}</span>`;
}
// 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.
function formatDateSpan(startISO: string, endISO: string): string {
const start = new Date(startISO + "T00:00:00");
const end = new Date(endISO + "T00:00:00");
if (getLang() === "en") {
const fmt = (d: Date) => d.toLocaleDateString("en-US", { day: "numeric", month: "short" });
return `${fmt(start)} ${fmt(end)}`;
}
const fmt = (d: Date) => `${d.getDate()}.${d.getMonth() + 1}.`;
return `${fmt(start)}${fmt(end)}`;
}
// 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.
function localizeVacationName(name: string): string {
return name;
}
function localizeWeekday(en: string): string {
if (en === "Saturday") return t("deadlines.adjusted.weekend.saturday");
if (en === "Sunday") return t("deadlines.adjusted.weekend.sunday");
return en;
}
// Backend-shaped reason → human-readable phrase ("UPC-Gerichtsferien
// (27.7.28.8.)" / "Karfreitag holiday" / "Wochenende"). See t-paliad-119.
function renderAdjustmentReason(r: AdjustmentReason): string {
if (r.kind === "vacation" && r.vacation_name && r.vacation_start && r.vacation_end) {
const span = formatDateSpan(r.vacation_start, r.vacation_end);
return tDyn("deadlines.adjusted.vacation")
.replace("{name}", localizeVacationName(r.vacation_name))
.replace("{span}", span);
}
if (r.kind === "public_holiday" && r.holidays && r.holidays.length > 0) {
return tDyn("deadlines.adjusted.holiday").replace("{name}", r.holidays[0].Name);
}
if (r.kind === "weekend" && r.original_weekday) {
return localizeWeekday(r.original_weekday);
}
return t("deadlines.adjusted.weekend");
}
// "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.
function formatAdjustedNote(dl: CalculatedDeadline): string {
const arrow = `${formatDate(dl.originalDate)}${formatDate(dl.dueDate)}`;
const reason = dl.adjustmentReason
? renderAdjustmentReason(dl.adjustmentReason)
: t("deadlines.adjusted.reason");
if (getLang() === "en") {
return `${t("deadlines.adjusted")} (${reason}): ${arrow}`;
}
return `${t("deadlines.adjusted")} wegen ${reason}: ${arrow}`;
}
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 EP_GRANT (Art. 93 EPÜ publish-anchor).
const priorityInput = document.getElementById("priority-date") as HTMLInputElement | null;
const priorityDate = selectedType === "EP_GRANT" && priorityInput?.value ? priorityInput.value : "";
// Flags — three proceeding-specific checkboxes:
// UPC_INF: with_ccr (always available); with_amend (nested under
// with_ccr — R.30 application is only available with a CCR).
// UPC_REV: with_amend (R.49.2.a) and with_cci (R.49.2.b) as two
// independent gates; both can be on simultaneously.
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") {
if (ccrFlag?.checked) flags.push("with_ccr");
if (ccrFlag?.checked && infAmendFlag?.checked) flags.push("with_amend");
}
if (selectedType === "UPC_REV") {
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;
try {
const resp = await fetch("/api/tools/fristenrechner", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
proceedingType: selectedType,
triggerDate,
priorityDate: priorityDate || undefined,
flags: flags.length > 0 ? flags : undefined,
anchorOverrides: Object.keys(overrides).length > 0 ? overrides : undefined,
}),
});
if (seq !== procCalcSeq) return;
if (!resp.ok) {
const err = await resp.json();
console.error("API error:", err);
return;
}
const data: DeadlineResponse = await resp.json();
if (seq !== procCalcSeq) return;
lastResponse = data;
renderProcedureResults(data);
showStep(3);
} catch (e) {
console.error("Fetch error:", e);
}
}
interface ProjectOption {
id: string;
reference?: string | null;
title: string;
path: string;
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function escHtml(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
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";
}
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;
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)}`;
return `<option value="${escAttr(p.id)}">${label}</option>`;
})
.join("");
}
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";
const disabled = isCourtDetermined || !dl.dueDate;
const checked = !disabled;
const meta = isCourtDetermined
? `<span class="frist-save-meta">${escHtml(t("deadlines.court.set"))}</span>`
: `<span class="frist-save-meta">${escHtml(formatDate(dl.dueDate))}</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)
: renderTimelineBody(data);
container.innerHTML = headerHtml + bodyHtml;
printBtn.style.display = "block";
if (saveBtn) saveBtn.style.display = "block";
if (toggle) toggle.style.display = "";
applyPendingFocus();
}
// openInlineDateEditor swaps the date span for a date input. On commit
// (blur or Enter), the override is recorded and the timeline re-fetched.
// On Escape, the editor closes without changing anything. An empty
// commit clears the override (lets the user revert to the calculated
// date or to the IsCourtSet placeholder).
function openInlineDateEditor(span: HTMLElement) {
const ruleCode = span.dataset.ruleCode!;
const current = span.dataset.currentDate || anchorOverrides.get(ruleCode) || "";
const editor = document.createElement("input");
editor.type = "date";
editor.className = "frist-date-edit-input";
editor.value = current;
const commit = (newValue: string) => {
if (newValue === "") {
anchorOverrides.delete(ruleCode);
} else {
anchorOverrides.set(ruleCode, newValue);
}
void calculate();
};
const cancel = () => {
editor.replaceWith(span);
};
editor.addEventListener("blur", () => {
if (editor.value !== current) commit(editor.value);
else cancel();
});
editor.addEventListener("keydown", (e) => {
const ke = e as KeyboardEvent;
if (ke.key === "Enter") {
e.preventDefault();
editor.blur();
} else if (ke.key === "Escape") {
e.preventDefault();
cancel();
}
});
span.replaceWith(editor);
editor.focus();
if (editor.value) editor.select();
}
function deadlineCardHtml(dl: CalculatedDeadline, opts: { showParty: boolean }): string {
// Click-to-edit on dated rows + court-set placeholders: lets the user
// override the calculated date (e.g. court extended the deadline) or
// fill in a court-set decision date once known. Downstream rules
// re-anchor on the override via anchorOverrides → /api/tools/fristenrechner.
// Root-event rows (the trigger anchor itself) are NOT editable — the
// trigger date input is the canonical place to change that.
const editable = !dl.isRootEvent && dl.code !== "";
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
const editAttrs = editable
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
: "";
const dateStr = dl.isCourtSet
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t("deadlines.court.set")}</span>`
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
const mandatoryBadge = dl.isMandatory
? ""
: '<span class="optional-badge">optional</span>';
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
const adjustedNote = dl.wasAdjusted
? `<div class="timeline-adjusted">\u26a0 ${formatAdjustedNote(dl)}</div>`
: "";
const ruleRef = dl.ruleRef
? `<span class="timeline-rule">${dl.ruleRef}</span>`
: "";
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
const notes = noteText
? `<div class="timeline-notes">${noteText}</div>`
: "";
const meta = (opts.showParty || ruleRef)
? `<div class="timeline-meta">
${opts.showParty ? partyBadge(dl.party) : ""}
${ruleRef}
</div>`
: "";
return `<div class="timeline-item-header">
<span class="timeline-name">
${dlName}
${mandatoryBadge}
</span>
${dateStr}
</div>
${meta}
${adjustedNote}
${notes}`;
}
function renderTimelineBody(data: DeadlineResponse): string {
let html = '<div class="timeline">';
for (const dl of data.deadlines) {
html += `
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
<div class="timeline-dot-col">
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
<div class="timeline-line"></div>
</div>
<div class="timeline-content">
${deadlineCardHtml(dl, { showParty: true })}
</div>
</div>
`;
}
html += "</div>";
return html;
}
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
// (defendant). Each grid row corresponds to a distinct dueDate, so events on
// the same day line up across columns. Deadlines with party=both render in
// BOTH the Proactive and Reactive cells of their row with a "beide Seiten"
// caption so the duplication is legible as intentional. Court-set / dateless
// rows collapse into a single trailing row at the bottom.
function renderColumnsBody(data: DeadlineResponse): string {
type Cell = CalculatedDeadline[];
type Row = { proactive: Cell; court: Cell; reactive: Cell };
const NO_DATE = "";
const rowsMap = new Map<string, Row>();
const ensureRow = (key: string): Row => {
let r = rowsMap.get(key);
if (!r) {
r = { proactive: [], court: [], reactive: [] };
rowsMap.set(key, r);
}
return r;
};
for (const dl of data.deadlines) {
const key = dl.dueDate || NO_DATE;
const row = ensureRow(key);
switch (dl.party) {
case "claimant":
row.proactive.push(dl);
break;
case "defendant":
row.reactive.push(dl);
break;
case "court":
row.court.push(dl);
break;
case "both":
// Mirrored: same card lands in Proactive AND Reactive at this date.
row.proactive.push(dl);
row.reactive.push(dl);
break;
default:
// Unknown party: keep visible by parking in the Court column.
row.court.push(dl);
}
}
// Sort row keys chronologically; the dateless bucket (court-set rows) sinks
// to the bottom because it has no temporal anchor.
const keys = Array.from(rowsMap.keys()).sort((a, b) => {
if (a === NO_DATE) return 1;
if (b === NO_DATE) return -1;
return a < b ? -1 : a > b ? 1 : 0;
});
const renderCell = (items: CalculatedDeadline[]): string => {
if (items.length === 0) {
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
}
const cards = items
.map((dl) => {
const mirrorTag = dl.party === "both"
? `<div class="fr-col-mirror">\u2194 ${escHtml(t("deadlines.party.both.label"))}</div>`
: "";
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
${deadlineCardHtml(dl, { showParty: false })}
${mirrorTag}
</div>`;
})
.join("");
return `<div class="fr-col-cell">${cards}</div>`;
};
const headerCell = (label: string, cls: string) =>
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
let html = '<div class="fr-columns-view">';
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
html += headerCell(t("deadlines.col.court"), "fr-col-court");
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
for (const key of keys) {
const row = rowsMap.get(key)!;
html += renderCell(row.proactive);
html += renderCell(row.court);
html += renderCell(row.reactive);
}
html += "</div>";
return html;
}
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";
showStep(1);
}
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 → EP_GRANT
// ccr-flag → UPC_INF only
// inf-amend-flag → UPC_INF only, but disabled until ccr-flag is on
// (R.30 amend only available with a CCR)
// rev-amend-flag → UPC_REV only
// rev-cci-flag → UPC_REV only
const priorityRow = document.getElementById("priority-date-row");
if (priorityRow) priorityRow.style.display = selectedType === "EP_GRANT" ? "" : "none";
const ccrRow = document.getElementById("ccr-flag-row");
if (ccrRow) ccrRow.style.display = selectedType === "UPC_INF" ? "" : "none";
const infAmendRow = document.getElementById("inf-amend-flag-row");
if (infAmendRow) infAmendRow.style.display = selectedType === "UPC_INF" ? "" : "none";
const revAmendRow = document.getElementById("rev-amend-flag-row");
if (revAmendRow) revAmendRow.style.display = selectedType === "UPC_REV" ? "" : "none";
const revCciRow = document.getElementById("rev-cci-flag-row");
if (revCciRow) revCciRow.style.display = selectedType === "UPC_REV" ? "" : "none";
syncInfAmendEnabled();
showStep(2);
scheduleProcCalc(0);
}
// inf-amend-flag is only meaningful when ccr-flag is on (R.30 application
// 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 (defaults to timeline).
const initial = new URLSearchParams(window.location.search).get("view");
if (initial === "columns") procedureView = "columns";
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 === "timeline") {
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));
});
// 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));
// 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) {
timelineContainer.addEventListener("click", (e) => {
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
if (!target || !target.dataset.ruleCode) return;
openInlineDateEditor(target);
});
timelineContainer.addEventListener("keydown", (e) => {
const ke = e as KeyboardEvent;
if (ke.key !== "Enter" && ke.key !== " ") return;
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
if (!target || !target.dataset.ruleCode) return;
e.preventDefault();
openInlineDateEditor(target);
});
}
// 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);
// 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");
const panel = buildCalcPanel(cardData, rulePills);
card.appendChild(panel);
// Auto-select the clicked pill if it's a rule pill; otherwise the
// first pill is preselected by buildCalcPanel.
if (autoSelectPill && autoSelectPill.dataset.kind === "rule") {
selectCalcPill(card, autoSelectPill.dataset.proc, autoSelectPill.dataset.focus);
}
scheduleCardCalc(card);
}
function buildCalcPanel(_cardData: SearchCard, rulePills: SearchPill[]): 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];
// Pill picker (only when >1 rule pill).
const pickerHtml = rulePills.length <= 1
? `<input type="hidden" class="fristen-card-calc-pill-picker" data-proc="${escAttr(rulePills[0].proceeding?.code || "")}" data-focus="${escAttr(rulePills[0].rule_local_code || "")}" />`
: `<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);
});
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);
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}`;
return `<option value="${escAttr(p.id)}">${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")!;
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">
<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">
<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.
type Pathway = "fork" | "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") 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());
}
}
function showPathway(path: Pathway, mode?: BMode) {
const fork = document.getElementById("fristen-pathway-fork");
const a = document.getElementById("fristen-pathway-a");
const b = document.getElementById("fristen-pathway-b");
if (!fork || !a || !b) return;
fork.hidden = path !== "fork";
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");
const treeRadio = document.getElementById("fristen-b-mode-tree") as HTMLInputElement | null;
const filterRadio = document.getElementById("fristen-b-mode-filter") as HTMLInputElement | null;
if (!tree || !filter) return;
tree.hidden = mode !== "tree";
filter.hidden = mode !== "filter";
if (treeRadio) treeRadio.checked = mode === "tree";
if (filterRadio) filterRadio.checked = mode === "filter";
// Trigger tree load on entering tree mode. The cascade auto-renders
// the breadcrumb / question / buttons + 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 cascade = document.getElementById("fristen-b1-cascade");
if (cascade && cascade.childElementCount === 0) {
cascade.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 */ }
}
}
function initPathwayFork() {
// Set chip labels to active language before user sees them.
relabelChips();
// Initial render from URL (or saved preference if URL is bare).
const initial = readPathwayFromURL();
const initialMode = readBModeFromURL();
showPathway(initial, initialMode);
// Persist initial choice from URL.
if (initial !== "fork") {
try { localStorage.setItem(PATHWAY_STORAGE_KEY, initial); } catch { /* */ }
}
// Click handlers on the two fork cards.
document.getElementById("fristen-pathway-a-cta")?.addEventListener("click", () => {
navigateToPathway("a");
});
document.getElementById("fristen-pathway-b-cta")?.addEventListener("click", () => {
// Default to tree mode on first entry to Pathway B.
navigateToPathway("b", "tree");
});
// Back-to-fork buttons inside each pathway shell.
document.getElementById("fristen-pathway-a-back")?.addEventListener("click", () => {
navigateToPathway("fork");
});
document.getElementById("fristen-pathway-b-back")?.addEventListener("click", () => {
navigateToPathway("fork");
});
// B1/B2 mode toggle inside Pathway B.
const bModeRadios = document.querySelectorAll<HTMLInputElement>("input[name='fristen-b-mode']");
bModeRadios.forEach((r) => {
r.addEventListener("change", () => {
if (!r.checked) return;
const mode: BMode = r.value === "tree" ? "tree" : "filter";
setPathwayURL("b", mode);
showBMode(mode);
});
});
// Quick-pick chips on the fork shortcut row → jump straight to Pathway B + filter mode + prefilled query.
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");
// initSearch listens for popstate, but we used pushState; sync the
// search input directly.
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 should restore pathway state.
window.addEventListener("popstate", () => {
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;
children?: EventCategoryNode[];
}
let eventCategoryTree: EventCategoryNode[] | null = null;
let eventCategoryFetchInflight: Promise<EventCategoryNode[]> | null = null;
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();
eventCategoryTree = (data.tree || []) as EventCategoryNode[];
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 || "");
}
function renderB1Cascade(currentSlug: string) {
const cascade = document.getElementById("fristen-b1-cascade");
if (!cascade || !eventCategoryTree) return;
const trail = buildBreadcrumb(eventCategoryTree, currentSlug);
const node = trail.length > 0 ? trail[trail.length - 1] : null;
const childScope = node ? (node.children || []) : eventCategoryTree;
const breadcrumbHtml = trail.length === 0
? ""
: `<nav class="fristen-b1-breadcrumb" aria-label="Pfad">
<button type="button" class="fristen-b1-crumb fristen-b1-crumb--root" data-slug="">
${escHtml(t("deadlines.pathway.b.tree.reset"))}
</button>
${trail.map((c, i) =>
`<span class="fristen-b1-crumb-sep" aria-hidden="true"></span>
<button type="button" class="fristen-b1-crumb${i === trail.length - 1 ? " fristen-b1-crumb--current" : ""}" data-slug="${escAttr(c.slug)}">
${c.icon ? `<span class="fristen-b1-crumb-icon" aria-hidden="true">${escHtml(c.icon)}</span> ` : ""}${escHtml(nodeLabel(c))}
</button>`).join("")}
</nav>`;
const question = node && node.step_question_de
? `<p class="fristen-b1-question">${escHtml(nodeStepQuestion(node))}</p>`
: trail.length === 0
? `<p class="fristen-b1-question">${escHtml(t("deadlines.pathway.b.tree.start_question") || "Was ist passiert?")}</p>`
: "";
let buttonsHtml = "";
if (childScope.length > 0) {
buttonsHtml = `<div class="fristen-b1-buttons">${
childScope.map((c) =>
`<button type="button" class="fristen-b1-button${c.is_leaf ? " fristen-b1-button--leaf" : ""}" data-slug="${escAttr(c.slug)}">
${c.icon ? `<span class="fristen-b1-button-icon" aria-hidden="true">${escHtml(c.icon)}</span>` : ""}
<span class="fristen-b1-button-label">${escHtml(nodeLabel(c))}</span>
</button>`).join("")
}</div>`;
}
// Step-back affordance on any non-root state.
let backHtml = "";
if (trail.length > 0) {
const parentSlug = trail.length > 1 ? trail[trail.length - 2].slug : "";
backHtml = `<button type="button" class="fristen-b1-step-back" data-slug="${escAttr(parentSlug)}">
${escHtml(t("deadlines.pathway.b.tree.step.back"))}
</button>`;
}
cascade.innerHTML = `${breadcrumbHtml}${question}${buttonsHtml}${backHtml}`;
// Wire button clicks.
cascade.querySelectorAll<HTMLButtonElement>(".fristen-b1-button, .fristen-b1-crumb, .fristen-b1-step-back").forEach((btn) => {
btn.addEventListener("click", () => {
const slug = btn.dataset.slug || "";
navigateB1(slug);
});
});
runB1Search(currentSlug);
}
// 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);
renderB1Cascade(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();
renderB1Cascade(readB1PathFromURL());
} catch (e) {
const cascade = document.getElementById("fristen-b1-cascade");
if (cascade) {
cascade.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;
// Watch for tree mode becoming visible (Phase B's mode toggle).
const treeRadio = document.getElementById("fristen-b-mode-tree") as HTMLInputElement | null;
if (treeRadio) {
treeRadio.addEventListener("change", () => {
if (treeRadio.checked) loadAndRenderB1();
});
}
// 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.
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);