feat(procedures): T4 — appeal-target + Alle Optionen + cross-party + polish (m/paliad#152)
Some checks failed
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled

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
This commit is contained in:
mAi
2026-05-27 21:55:52 +02:00
parent 1ed75c56e3
commit c80723fc85
5 changed files with 294 additions and 15 deletions

View File

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

View File

@@ -141,6 +141,49 @@ const FALLBACK_FLAGS: Record<string, string[]> = {
"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<Rendered
card.className = "tracker-proceeding";
card.dataset.proceeding = params.proceedingType;
// Header — proceeding name + jurisdiction badge + flag strip host.
// Flag strip hydrates from the calc response below.
// Header — proceeding name + jurisdiction badge + detail-mode
// toggle + collapse toggle. Detail-mode renders only on non-
// collapsed cards; collapse toggle renders always.
const def = lookupProceeding(params.proceedingType);
const jur = def ? (FORUM_LABEL[def.forum] || "") : "";
const procName = proceedingDisplayName(params.proceedingType);
const detailMode = params.detailMode || "selected";
const detailToggle = params.collapsed
? ""
: `<button type="button" class="tracker-proceeding-detail" data-action="detail-toggle"
data-code="${escHtml(params.proceedingType)}"
aria-pressed="${detailMode === "all_options" ? "true" : "false"}"
title="${escHtml(t("procedures.proceeding.detail.title"))}">
${escHtml(detailMode === "all_options" ? t("procedures.proceeding.detail.all") : t("procedures.proceeding.detail.selected"))}
</button>`;
const header = document.createElement("header");
header.className = "tracker-proceeding-header";
header.innerHTML = `
<span class="tracker-proceeding-jur">${escHtml(jur)}</span>
<h3 class="tracker-proceeding-name">${escHtml(procName)}</h3>
<span class="tracker-proceeding-code" title="${escHtml(params.proceedingType)}">${escHtml(params.proceedingType)}</span>
${detailToggle}
<button type="button" class="tracker-proceeding-toggle" data-action="proc-toggle"
data-code="${escHtml(params.proceedingType)}"
aria-label="${escHtml(t("procedures.proceeding.toggle"))}">
@@ -250,6 +318,33 @@ export async function renderCard(params: TimelineRenderParams): Promise<Rendered
return { card, data: null, hasAnchor: false };
}
// Appeal-target chip group — visible only on proceedings with
// applies_to_target rules (today: upc.apl.unified). The picked slug
// feeds the calc's appealTarget param so the timeline narrows to the
// rule subset (Endentscheidung / Kostenentscheidung / Anordnung /
// Schadensbemessung / Bucheinsicht).
if (hasAppealTarget(params.proceedingType)) {
const targetGroup = document.createElement("div");
targetGroup.className = "tracker-proceeding-targets";
const lbl = document.createElement("span");
lbl.className = "tracker-proceeding-options-label";
lbl.textContent = t("procedures.appeal_target.label");
targetGroup.appendChild(lbl);
const active = params.appealTarget || "endentscheidung";
for (const slug of APPEAL_TARGETS) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "tracker-pill tracker-pill--sm";
btn.dataset.action = "appeal-target";
btn.dataset.code = params.proceedingType;
btn.dataset.target = slug;
btn.textContent = t(`deadlines.appeal_target.${slug}` as never);
if (slug === active) btn.classList.add("is-active");
targetGroup.appendChild(btn);
}
card.appendChild(targetGroup);
}
// Optionen strip — scenario flag checkboxes scoped to this card.
// Hydrated after the calc response so the applicable flag set is
// known. T1 floor: card-level placement; T2+ may move these to
@@ -270,6 +365,13 @@ export async function renderCard(params: TimelineRenderParams): Promise<Rendered
proceedingType: params.proceedingType,
triggerDate: params.triggerDate,
flags: params.flags,
appealTarget: hasAppealTarget(params.proceedingType)
? (params.appealTarget || "endentscheidung")
: undefined,
// includeHidden=true under "all_options" so the calculator
// re-surfaces previously-skipped conditional rules with isHidden
// set; the tracker mutes them.
includeHidden: params.detailMode === "all_options" || undefined,
});
if (!data) {
@@ -302,11 +404,12 @@ export async function renderCard(params: TimelineRenderParams): Promise<Rendered
optionsStrip.hidden = false;
}
// Filter to selected detail mode (mandatory + recommended +
// active-flag-gated). Conditional rules whose gate is OFF were
// already dropped server-side — the filter just removes optionals
// not explicitly selected.
const filtered = filterByDetailMode(data.deadlines, "selected", null);
// Filter per detail mode (§3.4).
// selected → mandatory + recommended + active-flag-gated
// all_options → everything; unselected optionals + conditional-off
// rules render muted (the renderer stamps the
// __detailUnselected flag via filterByDetailMode).
const filtered = filterByDetailMode(data.deadlines, detailMode, null);
// Anchor-present detection: does this card's rule set contain the
// active anchor rule id? Drives the multi-proceeding scope logic
@@ -314,9 +417,9 @@ export async function renderCard(params: TimelineRenderParams): Promise<Rendered
const hasAnchor = !!(params.anchorRuleId && filtered.some((d) => d.ruleId === params.anchorRuleId));
if (params.zoom && hasAnchor && params.anchorRuleId) {
body.innerHTML = renderZoomedBody(filtered, params.anchorRuleId, params.actuals);
body.innerHTML = renderZoomedBody(filtered, params.anchorRuleId, params.actuals, params.party);
} else {
body.innerHTML = renderTreeBody(filtered, params.anchorRuleId, params.actuals);
body.innerHTML = renderTreeBody(filtered, params.anchorRuleId, params.actuals, params.party);
}
if (hasAnchor) card.classList.add("tracker-proceeding--anchored");
@@ -335,6 +438,7 @@ function renderTreeBody(
deadlines: CalculatedDeadline[],
anchorRuleId?: string,
actuals?: ActualsMap,
party?: "claimant" | "defendant" | "",
): string {
if (deadlines.length === 0) {
return `<div class="tracker-proceeding-empty">${escHtml(t("procedures.timelines.empty"))}</div>`;
@@ -358,27 +462,48 @@ function renderTreeBody(
const parts: string[] = [`<ul class="tracker-tree tracker-tree-root">`];
for (const root of roots) {
parts.push(renderTreeNode(root, childrenOf, 0, anchorRuleId, actuals));
parts.push(renderTreeNode(root, childrenOf, 0, anchorRuleId, actuals, party));
}
parts.push(`</ul>`);
return parts.join("");
}
// isCrossParty — §3.6. When perspective is set, rows whose primary_party
// is the OPPOSITE side render with a "Gegenseitig" badge and a muted
// style. court / both / informational rows are never cross-party.
function isCrossParty(dl: CalculatedDeadline, party: "claimant" | "defendant" | ""): boolean {
if (!party) return false;
if (!dl.party) return false;
if (dl.party === "court" || dl.party === "both") return false;
return dl.party !== party;
}
function renderTreeNode(
dl: CalculatedDeadline,
childrenOf: Record<string, CalculatedDeadline[]>,
depth: number,
anchorRuleId?: string,
actuals?: ActualsMap,
party?: "claimant" | "defendant" | "",
): string {
const children = childrenOf[dl.code] || [];
const isAnchored = !!(anchorRuleId && dl.ruleId === anchorRuleId);
const lang = getLang();
const crossParty = party ? isCrossParty(dl, party) : false;
// __detailUnselected is stamped by filterByDetailMode under
// all_options mode (verfahrensablauf-detail-mode.ts). Read via
// unknown-prop cast so we don't pollute the public CalculatedDeadline
// type for one transient ui hint.
const unselected = !!(dl as unknown as { __detailUnselected?: boolean }).__detailUnselected;
const isHidden = !!dl.isHidden;
// Priority-driven bullet style.
const priorityClass = `tracker-node--${dl.priority || "mandatory"}`;
const anchorClass = isAnchored ? " tracker-node--anchored" : "";
const courtClass = dl.isCourtSet ? " tracker-node--court" : "";
const crossClass = crossParty ? " tracker-node--cross" : "";
const unselectedClass = unselected ? " tracker-node--unselected" : "";
const hiddenClass = isHidden ? " tracker-node--hidden" : "";
const name = lang === "en" ? (dl.nameEN || dl.name) : (dl.name || dl.nameEN);
const ref = dl.legalSourceDisplay || dl.ruleRef || "";
@@ -441,11 +566,16 @@ function renderTreeNode(
title="${escHtml(t("procedures.node.fokus"))}">🔍</button>`
: "";
const crossBadge = crossParty
? `<span class="tracker-node-cross" title="${escHtml(t("procedures.node.cross"))}">${escHtml(t("procedures.node.cross.short"))}</span>`
: "";
const meta = `
<div class="tracker-node-line">
<span class="tracker-node-bullet" aria-hidden="true"></span>
${statusBadge}
<span class="tracker-node-name">${escHtml(name)}</span>
${crossBadge}
${ref ? `<span class="tracker-node-ref">${escHtml(ref)}</span>` : ""}
${dateLabel ? `<span class="tracker-node-date">${escHtml(dateLabel)}</span>` : ""}
${partyBadge ? `<span class="tracker-node-party tracker-node-party--${escHtml(dl.party || "")}">${escHtml(partyBadge)}</span>` : ""}
@@ -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 += `<ul class="tracker-tree tracker-tree--depth-${depth + 1}">${kids}</ul>`;
}
return `<li class="tracker-node ${priorityClass}${anchorClass}${courtClass}${actualClass}"${ruleIdAttr}${codeAttr}>${inner}</li>`;
return `<li class="tracker-node ${priorityClass}${anchorClass}${courtClass}${crossClass}${unselectedClass}${hiddenClass}${actualClass}"${ruleIdAttr}${codeAttr}>${inner}</li>`;
}
// ─── 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.

View File

@@ -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<string, string> = {};
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<void> {
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<void> {
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<ScenarioFlagChangedDetail>).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();
});
});

View File

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

View File

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