diff --git a/frontend/src/client/fristenrechner.ts b/frontend/src/client/fristenrechner.ts index 08ece99..a2814bb 100644 --- a/frontend/src/client/fristenrechner.ts +++ b/frontend/src/client/fristenrechner.ts @@ -2,11 +2,11 @@ // 3-step wizard: select proceeding -> enter date -> view timeline // // Rendering primitives (renderTimelineBody / renderColumnsBody / -// deadlineCardHtml / formatDate / partyBadge / court picker) live in -// `./views/verfahrensablauf-core` and are shared with the -// /tools/verfahrensablauf page (t-paliad-179 Slice 1). This module owns -// the Step1/2/3a wizard, Pathway A/B, Akte save flow, anchor-override -// click-to-edit — none of which Verfahrensablauf wants. +// deadlineCardHtml / formatDate / partyBadge / court picker / inline +// date editor) live in `./views/verfahrensablauf-core` and are shared +// with /tools/verfahrensablauf. This module owns the Step1/2/3a +// wizard, Pathway A/B, Akte save flow — none of which Verfahrensablauf +// wants. import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n"; import { initSidebar } from "./sidebar"; @@ -22,6 +22,7 @@ import { priorityRendering, renderColumnsBody, renderTimelineBody, + wireDateEditClicks, } from "./views/verfahrensablauf-core"; let lastResponse: DeadlineResponse | null = null; @@ -430,54 +431,21 @@ function renderProcedureResults(data: DeadlineResponse) { 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(); +// onDateEditCommit is the click-to-edit callback handed to the shared +// wireDateEditClicks() helper: persist the per-rule override (empty value +// clears it) then recompute so downstream rules re-anchor. +function onDateEditCommit(ruleCode: string, newValue: string) { + if (newValue === "") { + anchorOverrides.delete(ruleCode); + } else { + anchorOverrides.set(ruleCode, newValue); + } + void calculate(); } -// deadlineCardHtml / renderTimelineBody / renderColumnsBody moved to -// ./views/verfahrensablauf-core (t-paliad-179 Slice 1). +// deadlineCardHtml / renderTimelineBody / renderColumnsBody / +// openInlineDateEditor / wireDateEditClicks moved to +// ./views/verfahrensablauf-core. function reset() { selectedType = ""; @@ -648,21 +616,7 @@ document.addEventListener("DOMContentLoaded", () => { // 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(".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(".frist-date-edit"); - if (!target || !target.dataset.ruleCode) return; - e.preventDefault(); - openInlineDateEditor(target); - }); - } + if (timelineContainer) wireDateEditClicks(timelineContainer, onDateEditCommit); // Reset button document.getElementById("reset-btn")!.addEventListener("click", reset); diff --git a/frontend/src/client/verfahrensablauf.ts b/frontend/src/client/verfahrensablauf.ts index 863712c..d8e76ab 100644 --- a/frontend/src/client/verfahrensablauf.ts +++ b/frontend/src/client/verfahrensablauf.ts @@ -17,11 +17,21 @@ import { populateCourtPicker, renderColumnsBody, renderTimelineBody, + wireDateEditClicks, } from "./views/verfahrensablauf-core"; let selectedType = ""; let lastResponse: DeadlineResponse | null = null; +// Per-rule anchor overrides set by the click-to-edit affordance on +// timeline / column date cells. Posted as `anchorOverrides` to the +// /api/tools/fristenrechner calc so downstream rules re-anchor off the +// user's chosen date. Cleared whenever the trigger changes (proceeding, +// trigger date, flag toggle) so a fresh calc starts unanchored — same +// semantic as /tools/fristenrechner. +const anchorOverrides = new Map(); +function clearAnchorOverrides() { anchorOverrides.clear(); } + type ProcedureView = "timeline" | "columns"; let procedureView: ProcedureView = "columns"; @@ -125,10 +135,14 @@ async function doCalc() { ? courtPicker.value : ""; + const overrides: Record = {}; + for (const [code, date] of anchorOverrides) overrides[code] = date; + const data = await calculateDeadlines({ proceedingType: selectedType, triggerDate, flags: readFlags(), + anchorOverrides: overrides, courtId, }); if (seq !== calcSeq) return; @@ -180,8 +194,8 @@ function renderResults(data: DeadlineResponse) { `; const bodyHtml = procedureView === "columns" - ? renderColumnsBody(data, { showNotes }) - : renderTimelineBody(data, { showParty: true, showNotes }); + ? renderColumnsBody(data, { editable: true, showNotes }) + : renderTimelineBody(data, { showParty: true, editable: true, showNotes }); container.innerHTML = headerHtml + bodyHtml; if (printBtn) printBtn.style.display = "block"; @@ -229,7 +243,12 @@ function syncInfAmendEnabled() { function selectProceeding(btn: HTMLButtonElement) { document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active")); btn.classList.add("active"); - selectedType = btn.dataset.code || ""; + const nextType = btn.dataset.code || ""; + // Different proceeding tree → previously-set overrides reference + // rule codes that don't exist in the new tree. Clear before the + // next calc so the fresh proceeding starts unanchored. + if (selectedType !== nextType) clearAnchorOverrides(); + selectedType = nextType; // Trigger-event label fires from the calc response (root rule). // Until step 3 renders, fall back to an em-dash placeholder. @@ -312,6 +331,21 @@ document.addEventListener("DOMContentLoaded", () => { document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print()); + // Click-to-edit on timeline / column date cells — same delegated + // pattern as /tools/fristenrechner. Survives renderResults()'s + // innerHTML rewrites because the listener lives on the container. + const timelineContainer = document.getElementById("timeline-container"); + if (timelineContainer) { + wireDateEditClicks(timelineContainer, (ruleCode, newValue) => { + if (newValue === "") { + anchorOverrides.delete(ruleCode); + } else { + anchorOverrides.set(ruleCode, newValue); + } + scheduleCalc(0); + }); + } + // Notes toggle — restores last preference on load + re-renders when // the user flips it. Lives in the same toggle bar as the view picker. const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null; diff --git a/frontend/src/client/views/verfahrensablauf-core.test.ts b/frontend/src/client/views/verfahrensablauf-core.test.ts new file mode 100644 index 0000000..3139c68 --- /dev/null +++ b/frontend/src/client/views/verfahrensablauf-core.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { + type CalculatedDeadline, + deadlineCardHtml, +} from "./verfahrensablauf-core"; + +// Regression tests for the editable→click-to-edit wiring on timeline date +// cells (m/paliad#59). When CardOpts.editable=true the card renderer must +// emit `class="… frist-date-edit"` with `data-rule-code` + `data-current- +// date` on the date span. Pages then attach a delegated click handler that +// resolves that selector to swap in an inline ``. If a +// future refactor drops the attrs, /tools/verfahrensablauf and +// /tools/fristenrechner both silently lose click-to-edit (no script error, +// nothing happens on click). These tests pin the contract. +// +// Fixture leaves ruleRef/legalSource* empty so deadlineCardHtml stays +// inside its non-DOM code paths (escHtml is DOM-backed and bun test runs +// in plain Node without jsdom). + +const dl = (overrides: Partial = {}): CalculatedDeadline => ({ + code: "upc-rop-12", + name: "Klageerwiderung", + nameEN: "Statement of Defence", + party: "defendant", + priority: "mandatory", + ruleRef: "", + dueDate: "2026-07-15", + originalDate: "2026-07-15", + wasAdjusted: false, + isRootEvent: false, + isCourtSet: false, + ...overrides, +}); + +describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => { + test("date span carries frist-date-edit class + data-rule-code + data-current-date", () => { + const html = deadlineCardHtml(dl(), { showParty: true, editable: true }); + expect(html).toContain('class="timeline-date frist-date-edit"'); + expect(html).toContain('data-rule-code="upc-rop-12"'); + expect(html).toContain('data-current-date="2026-07-15"'); + expect(html).toContain('role="button"'); + expect(html).toContain('tabindex="0"'); + }); + + test("editable=false (default) emits the date span without click-to-edit attrs", () => { + const html = deadlineCardHtml(dl(), { showParty: true }); + expect(html).toContain("timeline-date"); + expect(html).not.toContain("data-rule-code="); + expect(html).not.toContain('role="button"'); + }); + + test("root event suppresses editable even when editable=true (root has no override semantic)", () => { + const html = deadlineCardHtml(dl({ isRootEvent: true }), { showParty: true, editable: true }); + expect(html).not.toContain("data-rule-code="); + }); + + test("isCourtSet renders the court-set placeholder with click-to-edit so users can pin a real date", () => { + const html = deadlineCardHtml(dl({ isCourtSet: true }), { showParty: true, editable: true }); + expect(html).toContain("timeline-court-set frist-date-edit"); + expect(html).toContain('data-rule-code="upc-rop-12"'); + }); + + test("empty rule code with editable=true still suppresses click-to-edit (no anchor target)", () => { + const html = deadlineCardHtml(dl({ code: "" }), { showParty: true, editable: true }); + expect(html).not.toContain("data-rule-code="); + }); +}); diff --git a/frontend/src/client/views/verfahrensablauf-core.ts b/frontend/src/client/views/verfahrensablauf-core.ts index dfc02ea..d69a388 100644 --- a/frontend/src/client/views/verfahrensablauf-core.ts +++ b/frontend/src/client/views/verfahrensablauf-core.ts @@ -299,6 +299,87 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string ${notesBlock}`; } +// ─── inline date editor (click-to-edit per-rule due date) ──────────────── +// +// The renderer emits `` when +// CardOpts.editable is true. Pages call wireDateEditClicks() on their +// result container once, and the delegated click/keydown handlers swap a +// clicked span for a `` editor via openInlineDateEditor. +// The caller's onCommit callback receives (ruleCode, newValue) — an empty +// newValue means "revert" (clear the anchor override and let the calculator +// re-project). The actual recompute is the caller's job — they own the +// anchor-overrides map + the calc dispatch. + +export function openInlineDateEditor( + span: HTMLElement, + onCommit: (ruleCode: string, newValue: string) => void, +): void { + const ruleCode = span.dataset.ruleCode || ""; + if (!ruleCode) return; + const current = span.dataset.currentDate || ""; + const editor = document.createElement("input"); + editor.type = "date"; + editor.className = "frist-date-edit-input"; + editor.value = current; + + let done = false; + const cancel = () => { + if (done) return; + done = true; + editor.replaceWith(span); + }; + const commit = (newValue: string) => { + if (done) return; + done = true; + onCommit(ruleCode, newValue); + }; + + 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(); +} + +// wireDateEditClicks attaches delegated click + keyboard handlers to the +// timeline result container so click-to-edit survives every innerHTML +// rewrite the page does on recalc. Idempotent — re-calling on the same +// container does nothing (the dataset flag short-circuits). +export function wireDateEditClicks( + container: HTMLElement, + onCommit: (ruleCode: string, newValue: string) => void, +): void { + if (container.dataset.dateEditWired === "1") return; + container.dataset.dateEditWired = "1"; + container.addEventListener("click", (e) => { + const target = (e.target as HTMLElement).closest(".frist-date-edit"); + if (!target || !target.dataset.ruleCode) return; + openInlineDateEditor(target, onCommit); + }); + container.addEventListener("keydown", (e) => { + const ke = e as KeyboardEvent; + if (ke.key !== "Enter" && ke.key !== " ") return; + const target = (e.target as HTMLElement).closest(".frist-date-edit"); + if (!target || !target.dataset.ruleCode) return; + e.preventDefault(); + openInlineDateEditor(target, onCommit); + }); +} + export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string { let html = '
'; for (const dl of data.deadlines) {