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.
2671 lines
101 KiB
TypeScript
2671 lines
101 KiB
TypeScript
// 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, "&").replace(/"/g, """);
|
||
}
|
||
|
||
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">×</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);
|
||
|