From 3097df39181ad0c8f473c6ad1c2a5719934d4699 Mon Sep 17 00:00:00 2001 From: mAi Date: Tue, 26 May 2026 15:43:30 +0200 Subject: [PATCH] =?UTF-8?q?mAi:=20#133=20=E2=80=94=20Verfahrensablauf=20du?= =?UTF-8?q?ration=20affordance=20(hover=20+=20toggle)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit t-paliad-302 / m/paliad#133. Surface each event card's rule duration ("2 Mo. nach") on /tools/verfahrensablauf — by default as a hover tooltip on the date span, and optionally inline via a new "Dauern anzeigen" header toggle (localStorage key paliad.verfahrensablauf.durations-show). The issue scoped this as pure-frontend on the assumption that the duration fields were already on the /api/tools/fristenrechner payload. They were not: lp.TimelineEntry exposed only the computed dueDate, not the rule's (duration_value, duration_unit, timing) tuple. Added these as three additive optional fields and populated them in both engine emission sites (Calculate + CalculateByTriggerEvent) from the rule row directly. Source values are the base rule fields, not the post-alt-swap arithmetic — the tooltip reads as a property of the rule rather than a recap of which branch fired. Frontend wiring: - formatDurationLabel() in verfahrensablauf-core builds the " " string from the existing deadlines.event.unit..{one,many} + deadlines.event.timing.* i18n keys, reused from /tools/fristenrechner's event-mode renderer. - deadlineCardHtml attaches the label as title= on the date span (hover, default) and, when CardOpts.showDurations is on, emits an inline in the meta row. - Court-set / zero-duration rules (trigger event, hearings) skip the affordance — durationValue <= 0 short-circuits in formatDurationLabel. - Toggle persisted in localStorage under paliad.verfahrensablauf.durations-show, default off; sits next to the existing "Hinweise anzeigen" toggle. bun run build clean, go test ./pkg/litigationplanner/... and ./internal/... clean, bun test src/client/views clean (89/89). --- frontend/src/client/i18n.ts | 2 + frontend/src/client/verfahrensablauf.ts | 31 +++++++- .../src/client/views/verfahrensablauf-core.ts | 79 ++++++++++++++++++- frontend/src/i18n-keys.ts | 1 + frontend/src/styles/global.css | 10 +++ frontend/src/verfahrensablauf.tsx | 7 ++ pkg/litigationplanner/engine.go | 10 +++ pkg/litigationplanner/types.go | 11 +++ 8 files changed, 146 insertions(+), 5 deletions(-) diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 6647ab1..f066070 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -312,6 +312,7 @@ const translations: Record> = { "deadlines.view.timeline": "Zeitstrahl", "deadlines.view.columns": "Spalten", "deadlines.notes.show": "Hinweise anzeigen", + "deadlines.durations.show": "Dauern anzeigen", "deadlines.col.ours": "Unsere Seite", "deadlines.col.court": "Gericht", "deadlines.col.opponent": "Gegnerseite", @@ -3410,6 +3411,7 @@ const translations: Record> = { "deadlines.view.timeline": "Timeline", "deadlines.view.columns": "Columns", "deadlines.notes.show": "Show details", + "deadlines.durations.show": "Show durations", "deadlines.col.ours": "Client Side", "deadlines.col.court": "Court", "deadlines.col.opponent": "Opponent Side", diff --git a/frontend/src/client/verfahrensablauf.ts b/frontend/src/client/verfahrensablauf.ts index 30928b1..9febd1e 100644 --- a/frontend/src/client/verfahrensablauf.ts +++ b/frontend/src/client/verfahrensablauf.ts @@ -225,6 +225,21 @@ function writeNotesPref(on: boolean): void { } let showNotes = readNotesPref(); +// Durations toggle (m/paliad#133, t-paliad-302) — when off (default), +// the per-rule duration label ("2 Mo. nach") only shows on hover via +// the date span's `title` attribute. When on, the label renders inline +// in the timeline meta row of every event card. Persisted in +// localStorage under its own key so the preference is independent of +// "Hinweise anzeigen". +const DURATIONS_PREF_KEY = "paliad.verfahrensablauf.durations-show"; +function readDurationsPref(): boolean { + try { return localStorage.getItem(DURATIONS_PREF_KEY) === "1"; } catch { return false; } +} +function writeDurationsPref(on: boolean): void { + try { localStorage.setItem(DURATIONS_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ } +} +let showDurations = readDurationsPref(); + // Jurisdiction display prefix for the proceeding-summary chip + the // trigger-event placeholder. Same forum slugs the .proceeding-group // `data-forum` attribute carries in verfahrensablauf.tsx / @@ -431,10 +446,11 @@ function renderResults(data: DeadlineResponse) { ? renderColumnsBody(data, { editable: true, showNotes, + showDurations, side: currentSide, appellant: hasAppellantAxis(selectedType) ? currentAppellant : null, }) - : renderTimelineBody(data, { showParty: true, editable: true, showNotes }); + : renderTimelineBody(data, { showParty: true, editable: true, showNotes, showDurations }); container.innerHTML = headerHtml + noteHtml + bodyHtml; if (printBtn) printBtn.style.display = "block"; @@ -841,6 +857,19 @@ document.addEventListener("DOMContentLoaded", () => { }); } + // Durations toggle (m/paliad#133, t-paliad-302) — sibling of the + // notes toggle. Hover-only labels (default) become inline labels when + // the user opts in. + const durationsShowCb = document.getElementById("verfahrensablauf-durations-show") as HTMLInputElement | null; + if (durationsShowCb) { + durationsShowCb.checked = showDurations; + durationsShowCb.addEventListener("change", () => { + showDurations = durationsShowCb.checked; + writeDurationsPref(showDurations); + if (lastResponse) renderResults(lastResponse); + }); + } + // t-paliad-290 — show-hidden toggle. Hydrate from URL, wire change // to URL + recalc (the backend reshapes the response — we can't just // re-render lastResponse since the hidden rows aren't in it when the diff --git a/frontend/src/client/views/verfahrensablauf-core.ts b/frontend/src/client/views/verfahrensablauf-core.ts index ff26182..1727ee4 100644 --- a/frontend/src/client/views/verfahrensablauf-core.ts +++ b/frontend/src/client/views/verfahrensablauf-core.ts @@ -95,6 +95,36 @@ export interface CalculatedDeadline { parentRuleCode?: string; parentRuleName?: string; parentRuleNameEN?: string; + // durationValue / durationUnit / timing surface the rule's arithmetic + // so the timeline card can show "2 Mo. nach" on hover (and inline when + // the "Dauern anzeigen" toggle is on). Zero-duration rules (root + // event, court-set) carry durationValue=0 and the renderer suppresses + // the affordance — those don't have an explainable interval. + // (m/paliad#133, t-paliad-302) + durationValue?: number; + durationUnit?: string; + timing?: string; +} + +// formatDurationLabel renders the per-rule duration ("2 Mo. nach") for +// the Verfahrensablauf card affordance (m/paliad#133, t-paliad-302). +// Returns empty string for rules without a usable duration so the +// caller can skip the tooltip / inline span entirely. +// +// Pluralisation key naming mirrors the Fristenrechner event-mode +// renderer (deadlines.event.unit..{one,many}) — the unit and +// timing translations already exist for /tools/fristenrechner's +// "Was kommt nach…" mode and are reused here as the single +// source of truth. +export function formatDurationLabel(dl: CalculatedDeadline): string { + const value = dl.durationValue ?? 0; + const unit = dl.durationUnit || ""; + if (value <= 0 || !unit) return ""; + const unitKey = `deadlines.event.unit.${unit}` + (value === 1 ? ".one" : ".many"); + const unitStr = tDyn(unitKey); + const timing = dl.timing || ""; + const timingStr = timing ? tDyn(`deadlines.event.timing.${timing}`) : ""; + return timingStr ? `${value} ${unitStr} ${timingStr}` : `${value} ${unitStr}`; } // priorityRendering returns the per-priority UX hints the save-modal @@ -321,15 +351,38 @@ export interface CardOpts { // Page shells expose a toggle ("Hinweise anzeigen") that flips this and // re-renders. Default false — notes are noisy on long timelines. showNotes?: boolean; + // showDurations controls per-rule duration rendering on event cards + // (m/paliad#133, t-paliad-302): + // true → inline `2 Mo. nach` + // next to the date. + // false → hover-only tooltip on the date span (browser-native + // `title` attribute). Cards without a usable + // `durationValue > 0` get neither — court-set and trigger- + // event cards have no explainable interval. + // /tools/verfahrensablauf exposes a toggle ("Dauern anzeigen") that + // flips this and re-renders; persisted via the localStorage key + // `paliad.verfahrensablauf.durations-show`. Default false. + showDurations?: boolean; } export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string { const wantsEditable = !!opts.editable; const editable = wantsEditable && !dl.isRootEvent && dl.code !== ""; const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : ""; + // Duration affordance (m/paliad#133, t-paliad-302). Computed once so + // both the date-span tooltip and the inline meta-row span pull from + // the same string. Empty for rules without a usable duration. + const durationLabel = formatDurationLabel(dl); + // Hover affordance on the date span: prefer the duration tooltip when + // we have one, else fall back to the edit-hint when the cell is + // click-to-edit. The edit affordance still works either way — the + // title is purely advisory. + const dateTitle = durationLabel + ? durationLabel + : (editable ? t("deadlines.date.edit.hint") : ""); 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"))}"` - : ""; + ? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0"${dateTitle ? ` title="${escAttr(dateTitle)}"` : ""}` + : (dateTitle ? ` title="${escAttr(dateTitle)}"` : ""); // Conditional rows (t-paliad-289) replace the date column with an // "abhängig von " chip. The chip remains click-to-edit so // the user can pin a real date once known (e.g. once the oral @@ -434,9 +487,19 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string ? `` : ""; - const meta = (opts.showParty || ruleRef || noteHint) + // Inline duration affordance (m/paliad#133, t-paliad-302). Only + // emitted when the "Dauern anzeigen" toggle is on AND the rule has a + // usable duration; the default-off hover-tooltip path is wired + // separately on the date span itself. + const showDurations = opts.showDurations === true; + const durationInline = showDurations && durationLabel + ? `${escHtml(durationLabel)}` + : ""; + + const meta = (opts.showParty || ruleRef || noteHint || durationInline) ? `
${opts.showParty ? partyBadge(dl.party) : ""} + ${durationInline} ${ruleRef} ${noteHint}
` @@ -614,6 +677,9 @@ type ColumnPosition = "ours" | "opponent"; export interface ColumnsBodyOpts { editable?: boolean; showNotes?: boolean; + // Forwarded to deadlineCardHtml — see CardOpts.showDurations. + // (m/paliad#133, t-paliad-302) + showDurations?: boolean; // side: which side the user is on. Drives column placement; // does NOT filter rows. Default null = claimant-on-the-left // (i.e. "ours = claimant", legacy default). @@ -724,7 +790,12 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant }); const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant"; - const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes }; + const cardOpts: CardOpts = { + showParty: false, + editable: opts.editable, + showNotes: opts.showNotes, + showDurations: opts.showDurations, + }; // Collapsed "both" rows lose their mirror tag — there's no longer // a sibling row to mirror to, so the "↔ beide Seiten" hint would diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index dc2717d..6d6756b 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -1268,6 +1268,7 @@ export type I18nKey = | "deadlines.dpma.appeal.bgh" | "deadlines.dpma.appeal.bpatg" | "deadlines.dpma.opp.dpma" + | "deadlines.durations.show" | "deadlines.empty.filtered" | "deadlines.empty.hint" | "deadlines.empty.title" diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 9ddf8a7..12475e9 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -3750,6 +3750,16 @@ input[type="range"]::-moz-range-thumb { color: var(--color-text-muted); } +/* Per-rule duration label rendered inline in the meta row when + "Dauern anzeigen" is on (m/paliad#133, t-paliad-302). Matches the + sibling .timeline-rule weight so the meta line reads as one band of + secondary metadata; non-mono so the value reads as prose ("2 Mo. nach") + rather than a code reference. */ +.timeline-duration { + font-size: 0.72rem; + color: var(--color-text-muted); +} + .timeline-adjusted { font-size: 0.78rem; color: var(--status-amber-fg-2); diff --git a/frontend/src/verfahrensablauf.tsx b/frontend/src/verfahrensablauf.tsx index 9700816..1c7322e 100644 --- a/frontend/src/verfahrensablauf.tsx +++ b/frontend/src/verfahrensablauf.tsx @@ -358,6 +358,13 @@ export function renderVerfahrensablauf(): string { Hinweise anzeigen + {/* Durations toggle (m/paliad#133, t-paliad-302). + Default off — hover-tooltips on date spans are + the always-on path. */} +
diff --git a/pkg/litigationplanner/engine.go b/pkg/litigationplanner/engine.go index ceeb840..3161aa6 100644 --- a/pkg/litigationplanner/engine.go +++ b/pkg/litigationplanner/engine.go @@ -249,6 +249,10 @@ func Calculate( appellantContext[r.ID] = ctxVal } + ruleTiming := "" + if r.Timing != nil { + ruleTiming = *r.Timing + } d := TimelineEntry{ RuleID: r.ID.String(), Name: r.Name, @@ -258,6 +262,9 @@ func Calculate( AppellantContext: ctxVal, ChoicesOffered: json.RawMessage(r.ChoicesOffered), IsHidden: isHidden, + DurationValue: r.DurationValue, + DurationUnit: r.DurationUnit, + Timing: ruleTiming, } if r.SubmissionCode != nil { d.Code = *r.SubmissionCode @@ -656,6 +663,9 @@ func calculateByTriggerEvent( OriginalDate: original.Format("2006-01-02"), WasAdjusted: wasAdj, AdjustmentReason: reason, + DurationValue: r.DurationValue, + DurationUnit: r.DurationUnit, + Timing: timing, } if r.SubmissionCode != nil { d.Code = *r.SubmissionCode diff --git a/pkg/litigationplanner/types.go b/pkg/litigationplanner/types.go index 217db1d..026ab4b 100644 --- a/pkg/litigationplanner/types.go +++ b/pkg/litigationplanner/types.go @@ -371,6 +371,17 @@ type TimelineEntry struct { ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"` AppellantContext string `json:"appellantContext,omitempty"` IsHidden bool `json:"isHidden,omitempty"` + // DurationValue / DurationUnit / Timing surface the rule's + // arithmetic so /tools/verfahrensablauf can show "2 Mo. nach" on + // each event card (m/paliad#133, t-paliad-302). Source values from + // the Rule row (not the post-alt-swap arithmetic) — the tooltip + // reads as a property of the rule, not a recap of which branch + // fired. Zero-duration rules (root event, court-set) emit + // DurationValue=0 and the frontend suppresses the affordance. + // Timing is "before" | "after" — empty when r.Timing is NULL. + DurationValue int `json:"durationValue,omitempty"` + DurationUnit string `json:"durationUnit,omitempty"` + Timing string `json:"timing,omitempty"` } // RuleCalculation is the single-rule calc response that backs the