Merge: t-paliad-302 — Verfahrensablauf duration indicator (hover + toggle, +3 lp.TimelineEntry fields) (m/paliad#133)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

This commit is contained in:
mAi
2026-05-26 15:45:15 +02:00
8 changed files with 146 additions and 5 deletions

View File

@@ -312,6 +312,7 @@ const translations: Record<Lang, Record<string, string>> = {
"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<Lang, Record<string, string>> = {
"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",

View File

@@ -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

View File

@@ -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.<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 `<span class="timeline-duration">2 Mo. nach</span>`
// 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 <parent>" 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
? `<span class="timeline-note-hint" tabindex="0" role="note" aria-label="${escAttr(noteText)}" title="${escAttr(noteText)}">ⓘ</span>`
: "";
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
? `<span class="timeline-duration">${escHtml(durationLabel)}</span>`
: "";
const meta = (opts.showParty || ruleRef || noteHint || durationInline)
? `<div class="timeline-meta">
${opts.showParty ? partyBadge(dl.party) : ""}
${durationInline}
${ruleRef}
${noteHint}
</div>`
@@ -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

View File

@@ -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"

View File

@@ -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);

View File

@@ -341,6 +341,13 @@ export function renderVerfahrensablauf(): string {
<input type="checkbox" id="fristen-notes-show" />
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
</label>
{/* Durations toggle (m/paliad#133, t-paliad-302).
Default off — hover-tooltips on date spans are
the always-on path. */}
<label className="fristen-notes-option">
<input type="checkbox" id="verfahrensablauf-durations-show" />
<span data-i18n="deadlines.durations.show">Dauern anzeigen</span>
</label>
</div>
<div id="timeline-container">

View File

@@ -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

View File

@@ -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