From c80723fc85ad3893a240a7b7fa28a7992a5750ef Mon Sep 17 00:00:00 2001 From: mAi Date: Wed, 27 May 2026 21:55:52 +0200 Subject: [PATCH] =?UTF-8?q?feat(procedures):=20T4=20=E2=80=94=20appeal-tar?= =?UTF-8?q?get=20+=20Alle=20Optionen=20+=20cross-party=20+=20polish=20(m/p?= =?UTF-8?q?aliad#152)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The final tracker layer per design §3.4 / §3.6 / §11 polish list: - Per-proceeding "· Gewählt · / Alle Optionen" toggle (§3.4) lives in the card header next to the show/hide button. State persists in localStorage per proceeding code, so a page with multiple cards can keep one expanded without affecting siblings. Toggle drives the detail mode for filterByDetailMode + sets includeHidden=true on the calc, so previously-skipped conditional rules re-surface muted. - Appeal-target chip group (§3.2 #3) renders below the header on proceedings with applies_to_target rules — today only upc.apl.unified. Endentscheidung / Kostenentscheidung / Anordnung / Schadensbemessung / Bucheinsicht. Picking a target re-fetches the calc with the appealTarget param so the timeline narrows to the matching subset. - Cross-party muted treatment (§3.6) — when the find-header Partei pill is set, rows whose primary_party is the opposite side render with a "Gegen." badge and a muted style. Court / both / informational rows are never cross-party. - "Unselected" + "hidden" styling — under "Alle Optionen" the rules that filterByDetailMode stamps __detailUnselected on render dotted italic, and previously-skipped (isHidden) rules render at reduced opacity. Honest preview of what the user is NOT considering. - Cross-surface scenario-flag-changed listener — the tracker now reseeds its flags state when Mode B / Verfahrensablauf / Verlauf patches the same project's flags, so toggling there flows through here without a refresh. Out of T4 (court-set choices_offered chip groups and the court-set date override from appointments) — those need a follow-up backend pass to surface the choicesOffered payload on TimelineEntry through the calc response in a usable shape. The data field exists on CalculatedDeadline but isn't yet wired to a paint route on the tracker. t-paliad-338 --- frontend/src/client/i18n.ts | 12 ++ frontend/src/client/procedures-tracker.ts | 159 ++++++++++++++++++++-- frontend/src/client/procedures.ts | 57 +++++++- frontend/src/i18n-keys.ts | 6 + frontend/src/styles/global.css | 75 ++++++++++ 5 files changed, 294 insertions(+), 15 deletions(-) diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index bc93a96..c00a82c 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -232,6 +232,12 @@ const translations: Record> = { "procedures.node.actual.done": "Erledigt", "procedures.node.actual.overdue": "Überfällig", "procedures.node.actual.open": "Offen", + "procedures.node.cross": "Gegenseitige Handlung", + "procedures.node.cross.short": "Gegen.", + "procedures.proceeding.detail.title": "Detailgrad umschalten", + "procedures.proceeding.detail.selected": "· Gewählt ·", + "procedures.proceeding.detail.all": "Alle Optionen", + "procedures.appeal_target.label": "Berufung gegen:", "procedures.node.pin": "An dieses Ereignis anheften", "procedures.node.fokus": "Fokus \u2014 andere Zweige ausblenden", "procedures.node.here": "\u2500\u2500 DU BIST HIER \u2500\u2500", @@ -3466,6 +3472,12 @@ const translations: Record> = { "procedures.node.actual.done": "Done", "procedures.node.actual.overdue": "Overdue", "procedures.node.actual.open": "Open", + "procedures.node.cross": "Opposing-side action", + "procedures.node.cross.short": "Opp.", + "procedures.proceeding.detail.title": "Toggle detail level", + "procedures.proceeding.detail.selected": "· Selected ·", + "procedures.proceeding.detail.all": "All options", + "procedures.appeal_target.label": "Appeal target:", "procedures.node.pin": "Pin this event as the anchor", "procedures.node.fokus": "Focus — hide sibling branches", "procedures.node.here": "── YOU ARE HERE ──", diff --git a/frontend/src/client/procedures-tracker.ts b/frontend/src/client/procedures-tracker.ts index 66aa4d3..9091bd1 100644 --- a/frontend/src/client/procedures-tracker.ts +++ b/frontend/src/client/procedures-tracker.ts @@ -141,6 +141,49 @@ const FALLBACK_FLAGS: Record = { "upc.rev.cfi": ["with_amend", "with_cci"], }; +// Appeal-target slugs the engine accepts for the upc.apl.unified +// proceeding (mig 137 / B1). Each chip filters the appeal timeline to +// the rule subset whose applies_to_target jsonb contains the slug. +// Same vocabulary the VerfahrensablaufBody chip group exposes. +export const APPEAL_TARGETS = [ + "endentscheidung", + "kostenentscheidung", + "anordnung", + "schadensbemessung", + "bucheinsicht", +] as const; + +const APPEAL_TARGET_PROCEEDINGS = new Set(["upc.apl.unified"]); + +function hasAppealTarget(code: string): boolean { + return APPEAL_TARGET_PROCEEDINGS.has(code); +} + +// Per-proceeding detail mode persistence (T4 §3.4). State is keyed by +// proceeding code so a page with 3 proceedings can have one in "Alle +// Optionen" without affecting the others. +const DETAIL_MODE_PREFIX = "procedures.tracker.detail_mode:"; + +export function readDetailMode(code: string): "selected" | "all_options" { + try { + const raw = localStorage.getItem(DETAIL_MODE_PREFIX + code); + if (raw === "all_options") return "all_options"; + } catch { + // fall through + } + return "selected"; +} + +export function writeDetailMode(code: string, mode: "selected" | "all_options"): void { + try { + if (mode === "selected") localStorage.removeItem(DETAIL_MODE_PREFIX + code); + else localStorage.setItem(DETAIL_MODE_PREFIX + code, mode); + } catch { + // localStorage unavailable — runtime state stays in memory only; + // toggle still works for this session. + } +} + export function applicableFlagsForProceeding( code: string, deadlines: CalculatedDeadline[], @@ -203,6 +246,20 @@ export interface TimelineRenderParams { actuals?: ActualsMap; // projectId carries through to deep-links and write-back paths. projectId?: string; + // T4 — Verfahren-card detail mode toggle. "selected" (default) shows + // mandatory + recommended + active-flag-gated; "all_options" reveals + // conditional rules whose flag is off and unselected optionals, + // muted. Per-proceeding state lives in localStorage keyed by code. + detailMode?: "selected" | "all_options"; + // T4 — appeal-target slug for proceedings with `applies_to_target` + // (upc.apl.unified). Drives the chip group at the appeal root. Empty + // = use the engine's default (endentscheidung for upc.apl). + appealTarget?: string; + // T4 — perspective for the cross-party muted treatment (§3.6). Rows + // whose party doesn't match are rendered with a "Gegenseitig" badge + // and a muted style. Empty = no perspective applied, render all + // rows at full saturation. + party?: "claimant" | "defendant" | ""; } export interface RenderedTimeline { @@ -223,17 +280,28 @@ export async function renderCard(params: TimelineRenderParams): Promise + ${escHtml(detailMode === "all_options" ? t("procedures.proceeding.detail.all") : t("procedures.proceeding.detail.selected"))} + `; const header = document.createElement("header"); header.className = "tracker-proceeding-header"; header.innerHTML = ` ${escHtml(jur)}

${escHtml(procName)}

${escHtml(params.proceedingType)} + ${detailToggle} ` : ""; + const crossBadge = crossParty + ? `${escHtml(t("procedures.node.cross.short"))}` + : ""; + const meta = `
${statusBadge} ${escHtml(name)} + ${crossBadge} ${ref ? `${escHtml(ref)}` : ""} ${dateLabel ? `${escHtml(dateLabel)}` : ""} ${partyBadge ? `${escHtml(partyBadge)}` : ""} @@ -458,12 +588,12 @@ function renderTreeNode( let inner = meta; if (children.length > 0) { const kids = children - .map((c) => renderTreeNode(c, childrenOf, depth + 1, anchorRuleId, actuals)) + .map((c) => renderTreeNode(c, childrenOf, depth + 1, anchorRuleId, actuals, party)) .join(""); inner += `
    ${kids}
`; } - return `
  • ${inner}
  • `; + return `
  • ${inner}
  • `; } // ─── zoom mode (§6.2) ────────────────────────────────────────────────────── @@ -484,13 +614,14 @@ function renderZoomedBody( deadlines: CalculatedDeadline[], anchorRuleId: string, actuals?: ActualsMap, + party?: "claimant" | "defendant" | "", ): string { const anchor = deadlines.find((d) => d.ruleId === anchorRuleId); if (!anchor) { // The anchor is no longer in the filtered set (e.g. the user // toggled a flag that hid it). Fall back to the full tree so the // user can re-pin. - return renderTreeBody(deadlines, anchorRuleId, actuals); + return renderTreeBody(deadlines, anchorRuleId, actuals, party); } // Build the parent chain (anchor → root). The chain is walked via @@ -537,7 +668,7 @@ function renderZoomedBody( } } const subtree = deadlines.filter((d) => descendants.has(d.code)); - const subtreeBody = renderTreeBody(subtree, anchorRuleId, actuals); + const subtreeBody = renderTreeBody(subtree, anchorRuleId, actuals, party); // Sibling count summary — descendants ignored. Stays terse so the // page tells the user how much is hidden without listing it. diff --git a/frontend/src/client/procedures.ts b/frontend/src/client/procedures.ts index 425e9d7..374786e 100644 --- a/frontend/src/client/procedures.ts +++ b/frontend/src/client/procedures.ts @@ -42,7 +42,19 @@ import { scrollAnchorIntoView, summariseRender, } from "./procedures-tracker"; -import { fetchScenarioFlags, patchScenarioFlags } from "./scenario-flags"; +import { + fetchScenarioFlags, + patchScenarioFlags, + SCENARIO_FLAG_CHANGED_EVENT, + type ScenarioFlagChangedDetail, +} from "./scenario-flags"; +import { readDetailMode, writeDetailMode } from "./procedures-tracker"; + +// Per-proceeding appeal-target state. Today only upc.apl.unified has +// applies_to_target rules; the map is keyed by proceeding_type code +// so future appeal-style proceedings (de.apl, etc.) can opt in without +// touching the state shape. +const appealTargets: Record = {}; type ForumId = "upc" | "de" | "epa" | "dpma" | ""; type PartyId = "claimant" | "defendant" | "both" | ""; @@ -386,6 +398,25 @@ function wireClickDelegation(): void { void rerender(); return; } + + if (action === "detail-toggle") { + // Per-proceeding "Alle Optionen" ↔ "Gewählt" toggle (§3.4). + const code = btn.dataset.code || ""; + if (!code) return; + const next = readDetailMode(code) === "all_options" ? "selected" : "all_options"; + writeDetailMode(code, next); + void rerender(); + return; + } + + if (action === "appeal-target") { + const code = btn.dataset.code || ""; + const target = btn.dataset.target || ""; + if (!code || !target) return; + appealTargets[code] = target; + void rerender(); + return; + } }); } @@ -444,6 +475,9 @@ async function rerender(): Promise { zoom: state.zoom, actuals: state.akteLoaded ? state.actuals : undefined, projectId: state.projectId || undefined, + detailMode: readDetailMode(code), + appealTarget: appealTargets[code] || undefined, + party: state.party || "", }), ), ); @@ -468,6 +502,9 @@ async function rerender(): Promise { collapsed: true, actuals: state.akteLoaded ? state.actuals : undefined, projectId: state.projectId || undefined, + detailMode: readDetailMode(r.card.dataset.proceeding || ""), + appealTarget: appealTargets[r.card.dataset.proceeding || ""] || undefined, + party: state.party || "", }); }), ); @@ -698,4 +735,22 @@ document.addEventListener("DOMContentLoaded", () => { } else { void rerender(); } + + // T4: cross-surface scenario-flag re-sync. When another surface + // (Mode B Fristenrechner, Verfahrensablauf, /admin) PATCHes the + // same project's flags, scenario-flags.ts dispatches this event. + // We re-seed state.flags from the detail payload and re-render so + // the tracker stays coherent without a fresh GET. + document.addEventListener(SCENARIO_FLAG_CHANGED_EVENT, (ev) => { + const detail = (ev as CustomEvent).detail; + if (!detail || !state.projectId) return; + if (detail.projectId !== state.projectId) return; + const onFlags: string[] = []; + for (const [k, v] of Object.entries(detail.flags)) { + if (k.startsWith("rule:")) continue; + if (v === true) onFlags.push(k); + } + state.flags = onFlags; + void rerender(); + }); }); diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 5e37403..b73eb74 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -2205,6 +2205,7 @@ export type I18nKey = | "partner_unit.members_label" | "partner_unit.none" | "partner_unit.subtitle" + | "procedures.appeal_target.label" | "procedures.cold_open.hint" | "procedures.filter.axis.date" | "procedures.filter.axis.forum" @@ -2223,10 +2224,15 @@ export type I18nKey = | "procedures.node.actual.done" | "procedures.node.actual.open" | "procedures.node.actual.overdue" + | "procedures.node.cross" + | "procedures.node.cross.short" | "procedures.node.fokus" | "procedures.node.here" | "procedures.node.pin" | "procedures.panel.akte.placeholder" + | "procedures.proceeding.detail.all" + | "procedures.proceeding.detail.selected" + | "procedures.proceeding.detail.title" | "procedures.proceeding.hide" | "procedures.proceeding.show" | "procedures.proceeding.toggle" diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 62e74c7..f665108 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -20310,6 +20310,81 @@ a.fristen-overhaul-rule-source { background: rgba(239, 68, 68, 0.05); } +/* ─── T4 polish ────────────────────────────────────────────────── */ + +/* Per-proceeding detail-mode toggle in the card header. Compact pill + * that flips between "· Gewählt ·" and "Alle Optionen". */ +.tracker-proceeding-detail { + border: 1px solid var(--color-border-strong); + background: var(--color-bg); + color: var(--color-text-muted); + font-size: 0.75rem; + padding: 0.15rem 0.6rem; + border-radius: 4px; + cursor: pointer; +} + +.tracker-proceeding-detail[aria-pressed="true"] { + background: var(--color-bg-lime-tint); + border-color: var(--color-accent); + color: var(--color-text); +} + +.tracker-proceeding-detail:hover { + border-color: var(--color-accent); + color: var(--color-text); +} + +/* Appeal-target chip strip on upc.apl.unified. Mirrors the per-card + * "Optionen" strip but lives below the header, separated by a thin + * border so it reads as a sub-row of the proceeding card. */ +.tracker-proceeding-targets { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.4rem; + padding: 0.4rem 0.9rem; + border-bottom: 1px solid var(--color-border); + background: var(--color-bg); + font-size: 0.8rem; +} + +.tracker-pill--sm { + padding: 0.1rem 0.5rem; + font-size: 0.78rem; +} + +/* Cross-party row treatment (§3.6). Rows where the user is not the + * primary actor get a "Gegen." badge + muted text. The user can still + * read what the opposing side files, but it doesn't compete with + * their own actionable items. */ +.tracker-node-cross { + display: inline-block; + padding: 0.05rem 0.35rem; + background: var(--color-bg-subtle); + color: var(--color-text-subtle); + font-size: 0.7rem; + border-radius: 3px; + font-weight: 500; +} + +.tracker-node--cross > .tracker-node-line .tracker-node-name, +.tracker-node--cross > .tracker-node-line .tracker-node-date, +.tracker-node--cross > .tracker-node-line .tracker-node-ref { + color: var(--color-text-subtle); +} + +/* Unselected rows in "all_options" view (dotted muted treatment per + * design §3.4). Same affordance for hidden-by-user rows. */ +.tracker-node--unselected > .tracker-node-line, +.tracker-node--hidden > .tracker-node-line { + opacity: 0.55; +} + +.tracker-node--unselected > .tracker-node-line .tracker-node-name { + font-style: italic; +} + .tracker-node--highlight > .tracker-node-line { animation: tracker-node-flash 3s ease-out; }