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.
This commit is contained in:
m
2026-05-04 19:33:33 +02:00
parent 371a38a194
commit 04d034af81

View File

@@ -54,6 +54,19 @@ const PARTY_CLASS: Record<string, string> = {
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
@@ -163,6 +176,7 @@ function showStep(n: number) {
}
async function calculate() {
const seq = ++procCalcSeq;
const dateInput = document.getElementById("trigger-date") as HTMLInputElement;
const triggerDate = dateInput.value;
if (!triggerDate || !selectedType) return;
@@ -188,6 +202,7 @@ async function calculate() {
}),
});
if (seq !== procCalcSeq) return;
if (!resp.ok) {
const err = await resp.json();
console.error("API error:", err);
@@ -195,6 +210,7 @@ async function calculate() {
}
const data: DeadlineResponse = await resp.json();
if (seq !== procCalcSeq) return;
lastResponse = data;
renderTimeline(data);
showStep(3);
@@ -467,39 +483,56 @@ function reset() {
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", () => {
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);
});
btn.addEventListener("click", () => selectProceeding(btn));
});
// Calculate button
document.getElementById("calculate-btn")!.addEventListener("click", calculate);
// Calculate button — manual force-recalc affordance.
document.getElementById("calculate-btn")!.addEventListener("click", () => scheduleProcCalc(0));
// Also calculate on Enter in date field
document.getElementById("trigger-date")!.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") calculate();
// 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);
@@ -515,6 +548,12 @@ document.addEventListener("DOMContentLoaded", () => {
// 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);
});
// ============================================================================
@@ -537,6 +576,11 @@ function initModeTabs() {
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();
});
});
}
@@ -581,6 +625,18 @@ 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}`);
@@ -646,6 +702,10 @@ async function loadTriggerEvents() {
}
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>`;
}
@@ -658,9 +718,11 @@ function selectTriggerEvent(id: number) {
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;
@@ -672,12 +734,14 @@ async function calculateEvent() {
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);
@@ -780,14 +844,33 @@ function initEventMode() {
});
}
document.getElementById("event-calculate-btn")?.addEventListener("click", calculateEvent);
document.getElementById("event-date")?.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") calculateEvent();
});
// 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);