diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 060571d..e24f9b9 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", @@ -3406,6 +3407,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 cf65b26..6b03041 100644 --- a/frontend/src/client/verfahrensablauf.ts +++ b/frontend/src/client/verfahrensablauf.ts @@ -276,6 +276,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 / @@ -482,6 +497,7 @@ function renderResults(data: DeadlineResponse) { ? renderColumnsBody(data, { editable: true, showNotes, + showDurations, side: currentSide, // t-paliad-301: the appellant axis collapses into the single // side picker. For role-swap proceedings, currentSide IS the @@ -490,7 +506,7 @@ function renderResults(data: DeadlineResponse) { // the appellant axis is irrelevant — pass null. appellant: hasAppellantAxis(selectedType) ? currentSide : 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"; @@ -868,6 +884,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 7d335a6..6205713 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -1264,6 +1264,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 6ae72bc..fd30611 100644 --- a/frontend/src/verfahrensablauf.tsx +++ b/frontend/src/verfahrensablauf.tsx @@ -341,6 +341,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 80df60e..16905e5 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 @@ -671,6 +678,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 fd48979..d1523b4 100644 --- a/pkg/litigationplanner/types.go +++ b/pkg/litigationplanner/types.go @@ -430,6 +430,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