Files
paliad/frontend/src/client/fristenrechner.ts
m 04d034af81 feat(t-paliad-126): Fristenrechner auto-calc on tab open + input change
The Verfahrensablauf and "Was kommt nach" tabs now render results
immediately, without requiring a click on "Fristen berechnen". The
button stays as a manual force-recalc affordance.

- Pre-select the first proceeding type on load so step 3 has data
  out of the box.
- Pre-select the first trigger event on first event-tab activation
  (or right after the list loads if the tab was already active).
- Auto-recalc on date / proceeding-type / condition-flag change.
- Debounce input events to 200ms so spam-edits coalesce into one
  request, with a per-mode sequence counter so a stale fetch result
  can never overwrite a fresher one.
2026-05-04 19:33:47 +02:00

886 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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

// Fristenrechner client-side logic
// 3-step wizard: select proceeding -> enter date -> view timeline
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import { projectIndent } from "./project-indent";
interface AdjustmentHoliday {
Date: string;
Name: string;
IsVacation: boolean;
IsClosure: boolean;
}
interface AdjustmentReason {
kind: "weekend" | "public_holiday" | "vacation";
holidays?: AdjustmentHoliday[];
vacation_name?: string;
vacation_start?: string;
vacation_end?: string;
original_weekday?: string;
}
interface CalculatedDeadline {
code: string;
name: string;
nameEN: string;
party: string;
isMandatory: boolean;
ruleRef: string;
notes?: string;
notesEN?: string;
dueDate: string;
originalDate: string;
wasAdjusted: boolean;
adjustmentReason?: AdjustmentReason;
isRootEvent: boolean;
isCourtSet: 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;
// 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);
}
onLangChange(() => {
if (lastResponse) renderTimeline(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 — UPC_INF surfaces "Mit Widerklage auf Nichtigkeit" toggle.
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
const flags: string[] = [];
if (selectedType === "UPC_INF" && ccrFlag?.checked) flags.push("with_ccr");
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,
}),
});
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;
renderTimeline(data);
showStep(3);
} catch (e) {
console.error("Fetch error:", e);
}
}
interface ProjectOption {
id: string;
reference?: string | null;
title: string;
path: string;
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function escHtml(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
async function fetchProjects(): Promise<ProjectOption[]> {
try {
const resp = await fetch("/api/projects");
if (!resp.ok) return [];
return (await resp.json()) as ProjectOption[];
} catch {
return [];
}
}
function ensureSaveModal() {
if (document.getElementById("frist-save-modal")) return;
const modal = document.createElement("div");
modal.id = "frist-save-modal";
modal.className = "modal-overlay";
modal.style.display = "none";
modal.innerHTML = `
<div class="modal-card">
<div class="modal-header">
<h2 data-i18n="deadlines.save.modal.title">${escHtml(t("deadlines.save.modal.title"))}</h2>
<button class="modal-close" id="frist-save-modal-close" type="button">&times;</button>
</div>
<div class="form-field">
<label for="frist-save-project" data-i18n="deadlines.save.modal.akte">${escHtml(t("deadlines.save.modal.akte"))}</label>
<select id="frist-save-project"></select>
<p class="form-hint" id="frist-save-no-akten" style="display:none">
<span data-i18n="deadlines.save.modal.no_akten">${escHtml(t("deadlines.save.modal.no_akten"))}</span>
<a href="/projects/new" data-i18n="deadlines.save.modal.no_akten.link">${escHtml(t("deadlines.save.modal.no_akten.link"))}</a>
</p>
</div>
<div class="form-field">
<p data-i18n="deadlines.save.modal.choose">${escHtml(t("deadlines.save.modal.choose"))}</p>
<ul class="frist-save-list" id="frist-save-list"></ul>
<p class="form-hint" data-i18n="deadlines.save.skip_court_set">${escHtml(t("deadlines.save.skip_court_set"))}</p>
</div>
<p class="form-msg" id="frist-save-msg"></p>
<div class="form-actions">
<button type="button" class="btn-cancel" id="frist-save-cancel" data-i18n="deadlines.save.modal.cancel">${escHtml(t("deadlines.save.modal.cancel"))}</button>
<button type="button" class="btn-primary btn-cta-lime" id="frist-save-submit" data-i18n="deadlines.save.modal.submit">${escHtml(t("deadlines.save.modal.submit"))}</button>
</div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeSaveModal();
});
document.getElementById("frist-save-modal-close")!.addEventListener("click", closeSaveModal);
document.getElementById("frist-save-cancel")!.addEventListener("click", closeSaveModal);
document.getElementById("frist-save-submit")!.addEventListener("click", submitSave);
}
function closeSaveModal() {
const modal = document.getElementById("frist-save-modal");
if (modal) modal.style.display = "none";
}
async function openSaveModal() {
if (!lastResponse) return;
ensureSaveModal();
const projects = await fetchProjects();
const sel = document.getElementById("frist-save-project") as HTMLSelectElement;
const noProjects = document.getElementById("frist-save-no-akten")!;
const submit = document.getElementById("frist-save-submit") as HTMLButtonElement;
if (projects.length === 0) {
sel.style.display = "none";
noProjects.style.display = "";
submit.disabled = true;
} else {
sel.style.display = "";
noProjects.style.display = "none";
submit.disabled = false;
sel.innerHTML = projects
.map((p) => {
const ref = (p.reference || "").trim();
const indent = projectIndent(p.path);
const label = ref
? `${indent}${escHtml(ref)} \u2014 ${escHtml(p.title)}`
: `${indent}${escHtml(p.title)}`;
return `<option value="${escAttr(p.id)}">${label}</option>`;
})
.join("");
}
const list = document.getElementById("frist-save-list")!;
list.innerHTML = lastResponse.deadlines
.map((dl, idx) => {
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
// Court-determined entries (interim conference, oral hearing, decision,
// any party=court row) have no calculable date — disable + pre-uncheck
// so users don't save the trigger-date placeholder as a real deadline.
const isCourtDetermined = dl.isCourtSet || dl.party === "court";
const disabled = isCourtDetermined || !dl.dueDate;
const checked = !disabled;
const meta = isCourtDetermined
? `<span class="frist-save-meta">${escHtml(t("deadlines.court.set"))}</span>`
: `<span class="frist-save-meta">${escHtml(formatDate(dl.dueDate))}</span>`;
return `<li class="frist-save-row">
<label>
<input type="checkbox" data-idx="${idx}" ${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
<span class="frist-save-title">${escHtml(dlName)}</span>
${meta}
</label>
</li>`;
})
.join("");
document.getElementById("frist-save-msg")!.textContent = "";
document.getElementById("frist-save-modal")!.style.display = "flex";
}
async function submitSave() {
if (!lastResponse) return;
const sel = document.getElementById("frist-save-project") as HTMLSelectElement;
const projectID = sel.value;
const submit = document.getElementById("frist-save-submit") as HTMLButtonElement;
const msg = document.getElementById("frist-save-msg")!;
if (!projectID) return;
const checks = document.querySelectorAll<HTMLInputElement>("#frist-save-list input[type=checkbox]");
const deadlinesPayload: Array<Record<string, unknown>> = [];
checks.forEach((cb) => {
if (!cb.checked || cb.disabled) return;
const idx = Number(cb.dataset.idx);
const dl = lastResponse!.deadlines[idx];
if (!dl || !dl.dueDate) return;
const isEN = getLang() === "en";
const dlName = isEN ? dl.nameEN : dl.name;
const dlNotes = isEN ? (dl.notesEN || dl.notes) : dl.notes;
deadlinesPayload.push({
title: dlName,
rule_code: dl.ruleRef || undefined,
due_date: dl.dueDate,
original_due_date: dl.originalDate || undefined,
source: "fristenrechner",
notes: dlNotes || undefined,
});
});
if (deadlinesPayload.length === 0) return;
submit.disabled = true;
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}/deadlines/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deadlines: deadlinesPayload }),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = data.error || t("deadlines.save.error");
msg.className = "form-msg form-msg-error";
submit.disabled = false;
return;
}
msg.innerHTML = `${escHtml(t("deadlines.save.success"))} <a href="/deadlines?project_id=${encodeURIComponent(projectID)}">${escHtml(t("deadlines.save.success.link"))}</a>`;
msg.className = "form-msg form-msg-ok";
// Re-enable after a short delay so user can read it; modal stays open with the link.
setTimeout(() => {
submit.disabled = false;
}, 1500);
} catch {
msg.textContent = t("deadlines.save.error");
msg.className = "form-msg form-msg-error";
submit.disabled = false;
}
}
function renderTimeline(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 procName = tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
let html = `<div class="timeline-header">
<strong>${procName}</strong>
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
</div>`;
html += '<div class="timeline">';
for (const dl of data.deadlines) {
const dateStr = dl.isCourtSet
? `<span class="timeline-court-set">${t("deadlines.court.set")}</span>`
: `<span class="timeline-date">${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>`
: "";
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">
<div class="timeline-item-header">
<span class="timeline-name">
${dlName}
${mandatoryBadge}
</span>
${dateStr}
</div>
<div class="timeline-meta">
${partyBadge(dl.party)}
${ruleRef}
</div>
${adjustedNote}
${notes}
</div>
</div>
`;
}
html += "</div>";
container.innerHTML = html;
printBtn.style.display = "block";
if (saveBtn) saveBtn.style.display = "block";
}
function reset() {
selectedType = "";
lastResponse = null;
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";
showStep(1);
}
function selectProceeding(btn: HTMLButtonElement) {
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
btn.classList.add("active");
selectedType = btn.dataset.code!;
// Update trigger event name
const name = btn.querySelector("strong")?.textContent || "";
document.getElementById("trigger-event")!.textContent = name;
// Conditional inputs: priority date for EP_GRANT, CCR toggle for UPC_INF.
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";
showStep(2);
scheduleProcCalc(0);
}
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", () => scheduleProcCalc(0));
// 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);
// 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 || "");
}
});