Revert "Merge: t-paliad-338 T1 — workflow-tracker shell replaces catalog (m/paliad#152)"
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 reverts commit 2e6427dca6, reversing
changes made to 9fe06094a8.
This commit is contained in:
mAi
2026-05-27 22:09:06 +02:00
parent be570c2fd0
commit ed3c5d1f32
6 changed files with 243 additions and 1619 deletions

View File

@@ -212,33 +212,6 @@ const translations: Record<Lang, Record<string, string>> = {
"procedures.tab.wizard": "Gef\u00fchrt",
"procedures.tab.akte": "Aus Akte",
"procedures.panel.akte.placeholder": "Akten-Einstieg folgt in einem sp\u00e4teren Slice.",
// Workflow-tracker shell (m/paliad#152 T1+) \u2014 keys for the new
// /tools/procedures shape (find header + per-proceeding cards).
"procedures.filter.axis.date": "Stichtag:",
"procedures.filter.forum.all": "Alle",
"procedures.filter.party.all": "Alle",
"procedures.timelines.loading": "Verfahren werden geladen\u2026",
"procedures.timelines.empty": "Keine Verfahren passen. Filter zur\u00fccksetzen.",
"procedures.timelines.error": "Fehler beim Laden dieses Verfahrens.",
"procedures.timelines.options": "Optionen:",
"procedures.timelines.court_set": "vom Gericht bestimmt",
"procedures.cold_open.hint": "Suchen oder filtern, um andere Verfahren einzublenden.",
"procedures.find.summary.empty": "Keine Treffer.",
"procedures.find.summary.one": "{n} Verfahren",
"procedures.find.summary.many": "{n} Verfahren",
"procedures.find.summary.anchor": "Anker: {name}",
"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",
"procedures.zoom.breadcrumb": "Pfad",
"procedures.zoom.hidden": "{n} weitere Schritte verborgen \u2014 Fokus aufheben f\u00fcr volle Ansicht",
"procedures.proceeding.toggle": "Verfahren ein-/ausblenden",
"procedures.proceeding.show": "zeigen",
"procedures.proceeding.hide": "ausblenden",
"deadlines.flag.amend": "Mit Antrag auf Patent\u00e4nderung",
"deadlines.flag.cci": "Mit Verletzungswiderklage",
"nav.procedures": "Verfahren & Fristen",
"deadlines.step1": "Verfahrensart w\u00e4hlen",
@@ -3443,32 +3416,6 @@ const translations: Record<Lang, Record<string, string>> = {
"procedures.tab.wizard": "Guided",
"procedures.tab.akte": "From matter",
"procedures.panel.akte.placeholder": "Matter entry ships in a later slice.",
// Workflow-tracker shell (m/paliad#152 T1+).
"procedures.filter.axis.date": "As of:",
"procedures.filter.forum.all": "All",
"procedures.filter.party.all": "All",
"procedures.timelines.loading": "Loading proceedings…",
"procedures.timelines.empty": "No proceedings match. Reset filters.",
"procedures.timelines.error": "Failed to load this proceeding.",
"procedures.timelines.options": "Options:",
"procedures.timelines.court_set": "court-set",
"procedures.cold_open.hint": "Search or filter to surface other proceedings.",
"procedures.find.summary.empty": "No matches.",
"procedures.find.summary.one": "{n} proceeding",
"procedures.find.summary.many": "{n} proceedings",
"procedures.find.summary.anchor": "Anchor: {name}",
"procedures.node.pin": "Pin this event as the anchor",
"procedures.node.fokus": "Focus — hide sibling branches",
"procedures.node.here": "── YOU ARE HERE ──",
"procedures.zoom.breadcrumb": "Path",
"procedures.zoom.hidden": "{n} more steps hidden — unfocus to see all",
"procedures.proceeding.toggle": "Toggle proceeding",
"procedures.proceeding.show": "show",
"procedures.proceeding.hide": "hide",
"deadlines.flag.amend": "With patent amendment request",
"deadlines.flag.cci": "With infringement counterclaim",
"nav.procedures": "Procedures & Deadlines",
"deadlines.step1": "Select Proceeding Type",

View File

@@ -1,514 +0,0 @@
// procedures-tracker — render module for /tools/procedures (m/paliad#152
// T1 + onwards, docs/design-procedures-workflow-tracker-2026-05-27.md).
//
// Responsibilities:
// - Per-proceeding card render: header + chained tree by parent_id +
// priority-styled bullets + scenario-flag fork checkboxes.
// - Tree layout: children grouped under their parentRuleCode in
// deadlines-list order, root rules surface at depth 0.
// - Default detail mode = "selected" (mandatory + recommended +
// scenario-flag-enabled). Conditional rules whose gate is OFF are
// filtered out by the calculator and don't surface; the
// corresponding fork checkbox on the gating node reveals them when
// toggled ON.
//
// T1 floor: card-level "Optionen" strip carries the scenario-flag
// forks at the top of each proceeding card. Per-node inline placement
// (the design's stated final shape — fork checkbox on the actual
// gating node) is a T2 refinement; T1 keeps forks discoverable but
// scoped per proceeding so they're not the global-page strip m's bug
// #5 flagged.
import { t, tDyn, getLang } from "./i18n";
import {
type CalculatedDeadline,
type DeadlineResponse,
calculateDeadlines,
escHtml,
formatDate,
} from "./views/verfahrensablauf-core";
import { filterByDetailMode } from "./verfahrensablauf-detail-mode";
// ProceedingDef — the catalog of proceedings the find header pills and
// the cold-open default surface against. Kept in sync with paliad's
// proceeding_types catalog as of 2026-05-27 (matches
// VerfahrensablaufBody.tsx's listing).
export interface ProceedingDef {
code: string;
forum: "upc" | "de" | "epa" | "dpma";
i18nKey: string;
nameDE: string;
nameEN: string;
}
export const PROCEEDINGS: ProceedingDef[] = [
{ code: "upc.inf.cfi", forum: "upc", i18nKey: "deadlines.upc.inf.cfi", nameDE: "Verletzungsverfahren", nameEN: "Infringement (CFI)" },
{ code: "upc.rev.cfi", forum: "upc", i18nKey: "deadlines.upc.rev.cfi", nameDE: "Nichtigkeitsklage", nameEN: "Revocation (CFI)" },
{ code: "upc.ccr.cfi", forum: "upc", i18nKey: "deadlines.upc.ccr.cfi", nameDE: "Widerklage auf Nichtigkeit", nameEN: "Counterclaim for revocation" },
{ code: "upc.pi.cfi", forum: "upc", i18nKey: "deadlines.upc.pi.cfi", nameDE: "Einstw. Maßnahmen", nameEN: "Provisional measures" },
{ code: "upc.apl.unified", forum: "upc", i18nKey: "deadlines.upc.apl.unified", nameDE: "Berufung UPC", nameEN: "Appeal UPC" },
{ code: "upc.dmgs.cfi", forum: "upc", i18nKey: "deadlines.upc.dmgs.cfi", nameDE: "Schadensbemessung", nameEN: "Damages" },
{ code: "upc.disc.cfi", forum: "upc", i18nKey: "deadlines.upc.disc.cfi", nameDE: "Bucheinsicht", nameEN: "Inspection of accounts" },
{ code: "de.inf.lg", forum: "de", i18nKey: "deadlines.de.inf.lg", nameDE: "LG Verletzungsklage", nameEN: "LG infringement (DE 1st inst.)" },
{ code: "de.inf.olg", forum: "de", i18nKey: "deadlines.de.inf.olg", nameDE: "OLG Berufung", nameEN: "OLG appeal" },
{ code: "de.inf.bgh", forum: "de", i18nKey: "deadlines.de.inf.bgh", nameDE: "BGH Revision / NZB", nameEN: "BGH revision / NZB" },
{ code: "de.null.bpatg", forum: "de", i18nKey: "deadlines.de.null.bpatg", nameDE: "BPatG Nichtigkeit", nameEN: "BPatG revocation" },
{ code: "de.null.bgh", forum: "de", i18nKey: "deadlines.de.null.bgh", nameDE: "BGH Berufung (Nichtigkeit)", nameEN: "BGH revocation appeal" },
{ code: "epa.opp.opd", forum: "epa", i18nKey: "deadlines.epa.opp.opd", nameDE: "Einspruchsverfahren EPA", nameEN: "EPO opposition" },
{ code: "epa.opp.boa", forum: "epa", i18nKey: "deadlines.epa.opp.boa", nameDE: "Beschwerdeverfahren EPA", nameEN: "EPO appeal" },
{ code: "epa.grant.exa", forum: "epa", i18nKey: "deadlines.epa.grant.exa", nameDE: "EP-Erteilungsverfahren", nameEN: "EP grant" },
{ code: "dpma.opp.dpma", forum: "dpma", i18nKey: "deadlines.dpma.opp.dpma", nameDE: "Einspruch DPMA", nameEN: "DPMA opposition" },
{ code: "dpma.appeal.bpatg", forum: "dpma", i18nKey: "deadlines.dpma.appeal.bpatg", nameDE: "Beschwerde BPatG (DPMA)", nameEN: "BPatG appeal (DPMA)" },
{ code: "dpma.appeal.bgh", forum: "dpma", i18nKey: "deadlines.dpma.appeal.bgh", nameDE: "Rechtsbeschwerde BGH", nameEN: "BGH legal appeal" },
];
// COLD_OPEN_DEFAULTS — design §8 / §11.Q4. When no URL params and no
// Akte context, the page renders these 6 proceedings stacked. Hint
// text above the timelines invites the user to filter for more.
export const COLD_OPEN_DEFAULTS: string[] = [
"upc.inf.cfi",
"upc.rev.cfi",
"upc.apl.unified",
"de.inf.lg",
"epa.opp.opd",
"dpma.opp.dpma",
];
// FORUM_LABEL mirrors the forum-pill label and the proceeding card
// header jurisdiction prefix. Same slugs the proceeding-types catalog
// carries.
const FORUM_LABEL: Record<string, string> = {
upc: "UPC",
de: "DE",
epa: "EPA",
dpma: "DPMA",
};
export function lookupProceeding(code: string): ProceedingDef | undefined {
return PROCEEDINGS.find((p) => p.code === code);
}
export function proceedingDisplayName(code: string): string {
const def = lookupProceeding(code);
if (!def) return code;
const lang = getLang();
return lang === "en" ? def.nameEN : def.nameDE;
}
// ─── condition_expr flag extraction ─────────────────────────────────────────
//
// Walks the jsonb tree shape documented in pkg/litigationplanner/expr.go:
// {"flag": "<name>"} — leaf
// {"op": "and|or|not", "args":[…]} — composite
// Returns the set of flag names mentioned in the expression. The page
// uses this to decide which scenario_flag forks apply to a given
// proceeding (the union over all conditional rules' expressions).
function collectFlagsFromExpr(node: unknown, out: Set<string>): void {
if (!node || typeof node !== "object") return;
const n = node as Record<string, unknown>;
if (typeof n.flag === "string" && n.flag) {
out.add(n.flag);
return;
}
if (Array.isArray(n.args)) {
for (const arg of n.args) collectFlagsFromExpr(arg, out);
}
}
export function gatingFlagsForProceeding(deadlines: CalculatedDeadline[]): string[] {
const set = new Set<string>();
for (const dl of deadlines) {
if (!dl.conditionExpr) continue;
collectFlagsFromExpr(dl.conditionExpr, set);
}
return Array.from(set).sort();
}
// Hard-coded fallback set: when the calc was run without a flag, the
// conditional rules gated by that flag are filtered out server-side
// (selected mode doesn't surface their condition_expr). The fallback
// lists the flags each proceeding *can* gate so the per-card Optionen
// strip surfaces them even on first render with the flag off.
//
// Mirrors mig 084's backfill: each scenario_flag → the proceedings
// where it's referenced by at least one rule. Today's catalog: 18
// conditional rules across upc.inf.cfi (with_ccr / with_amend) and
// upc.rev.cfi (with_amend / with_cci). Keep in sync with
// paliad.sequencing_rules.condition_expr.
const FALLBACK_FLAGS: Record<string, string[]> = {
"upc.inf.cfi": ["with_ccr", "with_amend"],
"upc.rev.cfi": ["with_amend", "with_cci"],
};
export function applicableFlagsForProceeding(
code: string,
deadlines: CalculatedDeadline[],
): string[] {
const fromExpr = gatingFlagsForProceeding(deadlines);
if (fromExpr.length > 0) return fromExpr;
return FALLBACK_FLAGS[code] || [];
}
// flagLabel maps scenario_flag keys to i18n keys (matches the
// existing deadlines.flag.* keys used by VerfahrensablaufBody).
const FLAG_I18N: Record<string, string> = {
with_ccr: "deadlines.flag.ccr",
with_amend: "deadlines.flag.amend",
with_cci: "deadlines.flag.cci",
};
function labelForFlag(flagKey: string, proceeding: string): string {
// upc.inf.cfi with_amend = R.30 amendment request; upc.rev.cfi
// with_amend = R.49.2(a) amendment. Different labels even though the
// flag key is the same. Honour the inf vs rev distinction.
if (flagKey === "with_amend" && proceeding === "upc.inf.cfi") return t("deadlines.flag.inf_amend");
if (flagKey === "with_amend" && proceeding === "upc.rev.cfi") return t("deadlines.flag.rev_amend");
if (flagKey === "with_cci" && proceeding === "upc.rev.cfi") return t("deadlines.flag.rev_cci");
const key = FLAG_I18N[flagKey];
return key ? t(key) : flagKey;
}
// ─── per-proceeding render ──────────────────────────────────────────────────
export interface TimelineRenderParams {
proceedingType: string;
triggerDate: string;
flags: string[];
anchorRuleId?: string;
zoom?: boolean;
// collapsed=true renders the card as a one-line header only with a
// [zeigen] link. Used by §6.5: when an anchor is pinned + >1
// proceeding visible, non-anchored proceedings collapse so the
// anchor's full context owns the page.
collapsed?: boolean;
}
export interface RenderedTimeline {
card: HTMLElement;
data: DeadlineResponse | null;
// hasAnchor true iff the rendered card contains the active
// anchorRuleId. Drives the multi-proceeding auto-collapse decision
// back in procedures.ts.
hasAnchor: boolean;
}
// renderCard takes a proceeding code + flag set + trigger date and
// returns a fully-wired card element ready to mount. Re-running with a
// new flag set requires a fresh fetch (calc applies the flag set
// server-side).
export async function renderCard(params: TimelineRenderParams): Promise<RenderedTimeline> {
const card = document.createElement("article");
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.
const def = lookupProceeding(params.proceedingType);
const jur = def ? (FORUM_LABEL[def.forum] || "") : "";
const procName = proceedingDisplayName(params.proceedingType);
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>
<button type="button" class="tracker-proceeding-toggle" data-action="proc-toggle"
data-code="${escHtml(params.proceedingType)}"
aria-label="${escHtml(t("procedures.proceeding.toggle"))}">
${escHtml(params.collapsed ? t("procedures.proceeding.show") : t("procedures.proceeding.hide"))}
</button>
`;
card.appendChild(header);
// Collapsed state — header-only render (§6.5). Bail before issuing
// the calc fetch; the card has no body. hasAnchor stays false here
// because we never resolved the data.
if (params.collapsed) {
card.classList.add("tracker-proceeding--collapsed");
return { card, data: null, hasAnchor: false };
}
// 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
// inline on the actual gating node per the design.
const optionsStrip = document.createElement("div");
optionsStrip.className = "tracker-proceeding-options";
optionsStrip.hidden = true;
card.appendChild(optionsStrip);
// Body — chained tree mounts here once the calc returns.
const body = document.createElement("div");
body.className = "tracker-proceeding-body";
body.innerHTML = `<div class="tracker-proceeding-loading">${escHtml(t("procedures.timelines.loading"))}</div>`;
card.appendChild(body);
// Fetch + render.
const data = await calculateDeadlines({
proceedingType: params.proceedingType,
triggerDate: params.triggerDate,
flags: params.flags,
});
if (!data) {
body.innerHTML = `<div class="tracker-proceeding-error">${escHtml(t("procedures.timelines.error"))}</div>`;
return { card, data: null, hasAnchor: false };
}
// Hydrate the Optionen strip with the applicable scenario flags.
const applicable = applicableFlagsForProceeding(params.proceedingType, data.deadlines);
if (applicable.length > 0) {
const labelSpan = document.createElement("span");
labelSpan.className = "tracker-proceeding-options-label";
labelSpan.textContent = t("procedures.timelines.options");
optionsStrip.appendChild(labelSpan);
for (const flag of applicable) {
const label = document.createElement("label");
label.className = "tracker-proceeding-option";
const cb = document.createElement("input");
cb.type = "checkbox";
cb.value = flag;
cb.checked = params.flags.includes(flag);
cb.dataset.flag = flag;
label.appendChild(cb);
const text = document.createElement("span");
text.textContent = labelForFlag(flag, params.proceedingType);
label.appendChild(text);
optionsStrip.appendChild(label);
}
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);
// Anchor-present detection: does this card's rule set contain the
// active anchor rule id? Drives the multi-proceeding scope logic
// and the zoom branch below.
const hasAnchor = !!(params.anchorRuleId && filtered.some((d) => d.ruleId === params.anchorRuleId));
if (params.zoom && hasAnchor && params.anchorRuleId) {
body.innerHTML = renderZoomedBody(filtered, params.anchorRuleId);
} else {
body.innerHTML = renderTreeBody(filtered, params.anchorRuleId);
}
if (hasAnchor) card.classList.add("tracker-proceeding--anchored");
return { card, data, hasAnchor };
}
// ─── tree builder ──────────────────────────────────────────────────────────
//
// Builds a `parentRuleCode → child[]` map, then walks from each root
// (no parent) emitting nested <ul class="tracker-tree-…"> nodes. The
// calculator already sorts the deadlines into a sensible chain (root
// → linear-deepest-first); the tree builder preserves that order.
function renderTreeBody(deadlines: CalculatedDeadline[], anchorRuleId?: string): string {
if (deadlines.length === 0) {
return `<div class="tracker-proceeding-empty">${escHtml(t("procedures.timelines.empty"))}</div>`;
}
// Index by code so children can find their parent visually. Track
// every code that appears in the filtered set — children whose
// parent isn't in the set surface as orphan roots.
const present = new Set(deadlines.map((d) => d.code));
const childrenOf: Record<string, CalculatedDeadline[]> = {};
const roots: CalculatedDeadline[] = [];
for (const dl of deadlines) {
const parent = dl.parentRuleCode || "";
if (parent && present.has(parent)) {
(childrenOf[parent] ||= []).push(dl);
} else {
roots.push(dl);
}
}
const parts: string[] = [`<ul class="tracker-tree tracker-tree-root">`];
for (const root of roots) {
parts.push(renderTreeNode(root, childrenOf, 0, anchorRuleId));
}
parts.push(`</ul>`);
return parts.join("");
}
function renderTreeNode(
dl: CalculatedDeadline,
childrenOf: Record<string, CalculatedDeadline[]>,
depth: number,
anchorRuleId?: string,
): string {
const children = childrenOf[dl.code] || [];
const isAnchored = !!(anchorRuleId && dl.ruleId === anchorRuleId);
const lang = getLang();
// 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 name = lang === "en" ? (dl.nameEN || dl.name) : (dl.name || dl.nameEN);
const ref = dl.legalSourceDisplay || dl.ruleRef || "";
const dateLabel = dl.isCourtSet
? t("procedures.timelines.court_set")
: (dl.dueDate ? formatDate(dl.dueDate) : "");
// Party badge — one-letter affordance to the right.
const partyBadge = dl.party === "court"
? "G"
: dl.party === "claimant"
? "K"
: dl.party === "defendant"
? "B"
: dl.party === "both" ? "K+B" : "";
const ruleIdAttr = dl.ruleId ? ` data-rule-id="${escHtml(dl.ruleId)}"` : "";
const codeAttr = ` data-code="${escHtml(dl.code)}"`;
// Pin + Fokus affordances. Pin is always available on nodes with a
// real ruleId (synthetic appeal-trigger markers carry no id). Fokus
// only on the currently anchored node.
const pinBtn = dl.ruleId
? `<button type="button" class="tracker-node-pin" data-action="pin"
data-rule-id="${escHtml(dl.ruleId)}"
aria-label="${escHtml(t("procedures.node.pin"))}"
title="${escHtml(t("procedures.node.pin"))}">📌</button>`
: "";
const fokusBtn = isAnchored
? `<button type="button" class="tracker-node-fokus" data-action="fokus"
aria-label="${escHtml(t("procedures.node.fokus"))}"
title="${escHtml(t("procedures.node.fokus"))}">🔍</button>`
: "";
const meta = `
<div class="tracker-node-line">
<span class="tracker-node-bullet" aria-hidden="true"></span>
<span class="tracker-node-name">${escHtml(name)}</span>
${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>` : ""}
${pinBtn}
${fokusBtn}
</div>
${isAnchored ? `<div class="tracker-node-here" role="note">${escHtml(t("procedures.node.here"))}</div>` : ""}
`;
let inner = meta;
if (children.length > 0) {
const kids = children
.map((c) => renderTreeNode(c, childrenOf, depth + 1, anchorRuleId))
.join("");
inner += `<ul class="tracker-tree tracker-tree--depth-${depth + 1}">${kids}</ul>`;
}
return `<li class="tracker-node ${priorityClass}${anchorClass}${courtClass}"${ruleIdAttr}${codeAttr}>${inner}</li>`;
}
// ─── zoom mode (§6.2) ──────────────────────────────────────────────────────
//
// When the user clicks [🔍] on the anchored node, the proceeding card
// re-renders into zoom mode:
// - Ancestors of the anchor collapse to a single breadcrumb at the
// top of the card (proceeding-code ▸ root ▸ … ▸ anchor).
// - Sibling branches at each ancestor depth fold to a one-line
// "… N weitere verborgen — [zeigen]" summary.
// - The anchored node renders full with all its successors.
//
// Sibling-expand-on-demand: when the user clicks [zeigen] on a fold
// summary, the corresponding sibling subtree expands inline. State is
// per-card in sessionStorage so a reload keeps it.
function renderZoomedBody(deadlines: CalculatedDeadline[], anchorRuleId: string): 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);
}
// Build the parent chain (anchor → root). The chain is walked via
// parentRuleCode; bail at the first missing parent or at >20 hops
// (defensive — the deepest tree today is ~5).
const byCode: Record<string, CalculatedDeadline> = {};
for (const dl of deadlines) byCode[dl.code] = dl;
const ancestors: CalculatedDeadline[] = [];
let cursor: CalculatedDeadline | undefined = anchor;
let safety = 20;
while (cursor && safety-- > 0) {
const parentCode = cursor.parentRuleCode || "";
if (!parentCode) break;
const parent = byCode[parentCode];
if (!parent) break;
ancestors.unshift(parent);
cursor = parent;
}
const lang = getLang();
const breadcrumbParts: string[] = [];
for (const a of ancestors) {
const name = lang === "en" ? (a.nameEN || a.name) : (a.name || a.nameEN);
breadcrumbParts.push(`<span class="tracker-zoom-crumb">${escHtml(name)}</span>`);
}
const anchorName = lang === "en" ? (anchor.nameEN || anchor.name) : (anchor.name || anchor.nameEN);
breadcrumbParts.push(`<span class="tracker-zoom-crumb tracker-zoom-crumb--anchor">${escHtml(anchorName)}</span>`);
const breadcrumb = `<nav class="tracker-zoom-breadcrumb" aria-label="${escHtml(t("procedures.zoom.breadcrumb"))}">
${breadcrumbParts.join('<span class="tracker-zoom-crumb-sep">▸</span>')}
</nav>`;
// Subtree: anchor + all descendants (parentRuleCode chain rooted
// at the anchor).
const descendants = new Set<string>([anchor.code]);
const queue = [anchor.code];
while (queue.length > 0) {
const code = queue.shift()!;
for (const dl of deadlines) {
if (dl.parentRuleCode === code && !descendants.has(dl.code)) {
descendants.add(dl.code);
queue.push(dl.code);
}
}
}
const subtree = deadlines.filter((d) => descendants.has(d.code));
const subtreeBody = renderTreeBody(subtree, anchorRuleId);
// Sibling count summary — descendants ignored. Stays terse so the
// page tells the user how much is hidden without listing it.
const totalCount = deadlines.length;
const hiddenCount = totalCount - subtree.length;
const hiddenLine = hiddenCount > 0
? `<div class="tracker-zoom-hidden">${escHtml(tDyn("procedures.zoom.hidden").replace("{n}", String(hiddenCount)))}</div>`
: "";
return breadcrumb + subtreeBody + hiddenLine;
}
// ─── search hit highlight (URL `?event=`) ──────────────────────────────────
//
// T1 affordance: when `?event=<rule_id>` is set, scroll the matching
// node into view and apply a transient highlight. Anchor pin + zoom is
// a T2 layering on top of this.
export function scrollAnchorIntoView(card: HTMLElement, anchorRuleId: string): void {
const node = card.querySelector<HTMLElement>(`[data-rule-id="${CSS.escape(anchorRuleId)}"]`);
if (!node) return;
node.classList.add("tracker-node--highlight");
node.scrollIntoView({ behavior: "smooth", block: "center" });
setTimeout(() => node.classList.remove("tracker-node--highlight"), 3000);
}
// summarise — short status line for the find-header summary.
export function summariseRender(rendered: RenderedTimeline[]): string {
const proc = rendered.length;
if (proc === 0) return t("procedures.find.summary.empty");
if (proc === 1) return tDyn("procedures.find.summary.one").replace("{n}", "1");
return tDyn("procedures.find.summary.many").replace("{n}", String(proc));
}

View File

@@ -1,520 +1,150 @@
// /tools/procedures client (m/paliad#152 T1,
// docs/design-procedures-workflow-tracker-2026-05-27.md §9 T1).
// /tools/procedures client (m/paliad#151,
// docs/design-unified-procedural-events-tool-2026-05-27.md).
//
// Workflow-tracker shell — replaces the 4-tab catalog (U0-U4) shipped
// earlier today with a single canonical shape:
// Boot logic + tab switching for the unified procedural-events tool.
// Each entry tab mounts its own module; the search box and chip
// filters in the top filter strip are wired in U1+ as each slice adds
// its dimension-aware behaviour.
//
// 1. Sticky find header — search input + forum / Verfahren / Partei
// pill rows + global Stichtag. The header narrows the timeline
// set rendered below.
// 2. Timeline body — one card per matched proceeding, rendered as
// a chained tree by parent_id with priority-styled bullets.
// U0 — Skeleton + tab toggling.
// U1 — Direkt suchen mounts Mode A.
// U2 — Geführt mounts Mode B wizard.
// U3 — Verfahren wählen wires the Verfahrensablauf wizard + detail-mode toggle.
//
// URL state (T1):
// ?q=<text> — free-text search
// ?forum=<id> — single forum (upc/de/epa/dpma)
// ?procs=<csv> — comma-separated proceeding codes
// ?party=<x> — claimant/defendant/both/""
// ?trigger_date=<iso> — global Stichtag
// ?event=<rule_id> — scroll-highlight matching node (no anchor
// pin / zoom yet; T2 layering)
// ?flags=<csv> — scenario flag overrides; default off
//
// Legacy ?mode= params from the catalog UI are dropped silently — the
// /tools/fristenrechner + /tools/verfahrensablauf URLs still 301
// redirect here.
//
// T2-T4 layer on this shell:
// T2 — anchor pin + zoom + multi-proceeding scope.
// T3 — Akte landing + actuals overlay.
// T4 — appeal-target + court-set choices + per-proceeding Alle Optionen.
// Mode A renders its shell into #fristen-overhaul-root (replacing
// children); Mode B renders into #fristen-overhaul-mode-host; the
// result view (post-commit) writes into #fristen-overhaul-root. To
// keep those IDs unique in the DOM, only the active tab's panel ever
// hosts the overhaul scaffold — installOverhaulHost() tears down any
// existing host and installs a fresh one inside the target panel
// before handing off to the per-mode module.
import { initI18n, t, tDyn } from "./i18n";
import { initI18n } from "./i18n";
import { initSidebar } from "./sidebar";
import {
COLD_OPEN_DEFAULTS,
PROCEEDINGS,
type ProceedingDef,
type RenderedTimeline,
proceedingDisplayName,
renderCard,
scrollAnchorIntoView,
summariseRender,
} from "./procedures-tracker";
import { mountModeA } from "./fristenrechner-mode-a";
import { mountResultView } from "./fristenrechner-result";
import { mountWizard } from "./fristenrechner-wizard";
import { initVerfahrensablauf } from "./verfahrensablauf";
type ForumId = "upc" | "de" | "epa" | "dpma" | "";
type PartyId = "claimant" | "defendant" | "both" | "";
type ProceduresTab = "proceeding" | "search" | "wizard" | "akte";
// Find state. Single source of truth; URL keeps it shareable.
const state = {
q: "",
forum: "" as ForumId,
procs: [] as string[],
party: "" as PartyId,
triggerDate: todayISO(),
event: "",
zoom: false,
flags: [] as string[],
};
const TABS: ProceduresTab[] = ["proceeding", "search", "wizard", "akte"];
// Per-anchor user-expanded set — when the multi-proceeding auto-collapse
// kicks in (§6.5), the user can [zeigen] specific proceedings. We track
// the explicit expansions so re-renders keep them open. Reset whenever
// the anchor changes (new pin clears prior expansion state).
let userExpanded = new Set<string>();
let lastAnchor = "";
function todayISO(): string {
return new Date().toISOString().split("T")[0];
}
// ─── URL state ─────────────────────────────────────────────────────────────
function readStateFromURL(): void {
function readTabFromUrl(): ProceduresTab {
const params = new URLSearchParams(window.location.search);
state.q = params.get("q") || "";
state.forum = (params.get("forum") || "") as ForumId;
const procs = params.get("procs") || "";
state.procs = procs ? procs.split(",").map((s) => s.trim()).filter(Boolean) : [];
state.party = (params.get("party") || "") as PartyId;
state.triggerDate = params.get("trigger_date") || todayISO();
state.event = params.get("event") || "";
state.zoom = params.get("zoom") === "1";
const flags = params.get("flags") || "";
state.flags = flags ? flags.split(",").map((s) => s.trim()).filter(Boolean) : [];
lastAnchor = state.event;
const raw = params.get("mode");
if (raw && (TABS as string[]).includes(raw)) return raw as ProceduresTab;
return "proceeding";
}
function writeStateToURL(): void {
function writeTabToUrl(tab: ProceduresTab): void {
const url = new URL(window.location.href);
const sp = url.searchParams;
setOrDelete(sp, "q", state.q);
setOrDelete(sp, "forum", state.forum);
setOrDelete(sp, "procs", state.procs.join(","));
setOrDelete(sp, "party", state.party);
setOrDelete(sp, "trigger_date", state.triggerDate === todayISO() ? "" : state.triggerDate);
setOrDelete(sp, "event", state.event);
setOrDelete(sp, "zoom", state.event && state.zoom ? "1" : "");
setOrDelete(sp, "flags", state.flags.join(","));
// Legacy ?mode= from the U0-U4 catalog era → drop on every state write
// so a bookmarked URL self-cleans on first interaction.
sp.delete("mode");
history.replaceState(null, "", url.pathname + (sp.toString() ? "?" + sp.toString() : "") + url.hash);
if (tab === "proceeding") {
url.searchParams.delete("mode");
} else {
url.searchParams.set("mode", tab);
}
history.replaceState(null, "", url.pathname + url.search + url.hash);
}
// onAnchorChanged keeps userExpanded in sync. A fresh pin clears the
// prior expansion set so the auto-collapse rule (§6.5) kicks in from
// scratch; unpinning clears it too so the full multi-proceeding view
// returns.
function onAnchorChanged(next: string): void {
if (next === lastAnchor) return;
userExpanded = new Set();
if (!next) state.zoom = false;
lastAnchor = next;
// installOverhaulHost moves the (legacy) #fristen-overhaul-root /
// #fristen-overhaul-mode-host scaffold under `panelId`. Always clears
// any existing host first, so the IDs stay unique across the page even
// when the user toggles between Direkt-suchen and Geführt — both Mode
// A and the wizard read these IDs from document.getElementById which
// returns the first match in DOM order, so two parallel hosts would
// cross-wire.
function installOverhaulHost(panelId: string): HTMLElement | null {
document.querySelectorAll("#fristen-overhaul-root").forEach((el) => el.remove());
const panel = document.getElementById(panelId);
if (!panel) return null;
panel.innerHTML = `
<div class="procedures-overhaul-host">
<div class="fristen-overhaul-root" id="fristen-overhaul-root">
<div id="fristen-overhaul-mode-host"></div>
</div>
</div>
`;
return panel;
}
function setOrDelete(sp: URLSearchParams, key: string, value: string): void {
if (value) sp.set(key, value);
else sp.delete(key);
}
// ─── pill hydration ────────────────────────────────────────────────────────
function hydrateForumPills(): void {
const host = document.getElementById("tracker-pills-forum");
if (!host) return;
host.innerHTML = "";
const all: { id: ForumId; label: string }[] = [
{ id: "", label: t("procedures.filter.forum.all") },
{ id: "upc", label: "UPC" },
{ id: "de", label: "DE" },
{ id: "epa", label: "EPA" },
{ id: "dpma", label: "DPMA" },
];
for (const f of all) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "tracker-pill";
btn.textContent = f.label;
btn.dataset.forum = f.id;
if (state.forum === f.id) btn.classList.add("is-active");
btn.addEventListener("click", () => {
state.forum = f.id;
// Drop procs that no longer match the active forum.
if (state.forum) {
state.procs = state.procs.filter((code) => {
const def = PROCEEDINGS.find((p) => p.code === code);
return def && def.forum === state.forum;
});
}
writeStateToURL();
hydrateForumPills();
hydrateProcPills();
void rerender();
});
host.appendChild(btn);
function setActiveTabUI(tab: ProceduresTab): void {
for (const t of TABS) {
const btn = document.getElementById(`procedures-tab-${t}`);
const panel = document.getElementById(`procedures-panel-${t}`);
const active = t === tab;
if (btn) {
btn.classList.toggle("is-active", active);
btn.setAttribute("aria-selected", active ? "true" : "false");
}
if (panel) panel.hidden = !active;
}
}
function hydrateProcPills(): void {
const host = document.getElementById("tracker-pills-proc");
if (!host) return;
host.innerHTML = "";
const visible = state.forum
? PROCEEDINGS.filter((p) => p.forum === state.forum)
: PROCEEDINGS;
for (const p of visible) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "tracker-pill";
btn.textContent = proceedingDisplayName(p.code);
btn.dataset.code = p.code;
btn.title = p.code;
if (state.procs.includes(p.code)) btn.classList.add("is-active");
btn.addEventListener("click", () => {
if (state.procs.includes(p.code)) {
state.procs = state.procs.filter((c) => c !== p.code);
} else {
state.procs = [...state.procs, p.code];
}
writeStateToURL();
hydrateProcPills();
void rerender();
});
host.appendChild(btn);
// Verfahrensablauf wiring is idempotent-unfriendly (module-local
// selectedType + lastResponse + listeners that re-bind on every
// proceeding click). Wire it exactly once per page load; on subsequent
// activations the existing DOM + listeners are reused so picked
// proceeding / dates / flags persist across tab switches.
let verfahrensablaufWired = false;
async function activateTab(tab: ProceduresTab): Promise<void> {
setActiveTabUI(tab);
if (tab === "search") {
installOverhaulHost("procedures-panel-search");
await mountModeA();
return;
}
if (tab === "wizard") {
installOverhaulHost("procedures-panel-wizard");
await mountWizard();
return;
}
if (tab === "proceeding") {
if (!verfahrensablaufWired) {
initVerfahrensablauf();
verfahrensablaufWired = true;
}
}
}
function hydratePartyPills(): void {
const host = document.getElementById("tracker-pills-party");
if (!host) return;
host.innerHTML = "";
const all: { id: PartyId; key: string }[] = [
{ id: "", key: "procedures.filter.party.all" },
{ id: "claimant", key: "deadlines.side.claimant" },
{ id: "defendant", key: "deadlines.side.defendant" },
];
for (const p of all) {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "tracker-pill";
btn.textContent = t(p.key as never);
btn.dataset.party = p.id;
if (state.party === p.id) btn.classList.add("is-active");
function wireTabs(): void {
for (const t of TABS) {
const btn = document.getElementById(`procedures-tab-${t}`);
if (!btn) continue;
btn.addEventListener("click", () => {
state.party = p.id;
writeStateToURL();
hydratePartyPills();
void rerender();
void activateTab(t);
writeTabToUrl(t);
});
host.appendChild(btn);
}
}
// ─── search box ────────────────────────────────────────────────────────────
//
// Debounced 200ms. Free-text matches procedural events via the existing
// /api/tools/fristenrechner/search?kind=events endpoint; the hits drive
// proceeding pre-selection (the proceedings the hits live in surface
// in the timeline body) and the first hit's rule_id becomes the
// `?event=` anchor.
// boot dispatches on the URL: a deep link with `?event=` jumps straight
// to the linear result view (the Direkt-suchen tab stays as the visible
// context). Otherwise the requested tab — defaulting to "proceeding" —
// activates per readTabFromUrl().
async function boot(): Promise<void> {
const params = new URLSearchParams(window.location.search);
const eventRef = params.get("event") || "";
let searchTimer: ReturnType<typeof setTimeout> | null = null;
function wireSearchInput(): void {
const input = document.getElementById("tracker-search-input") as HTMLInputElement | null;
if (!input) return;
input.value = state.q;
input.addEventListener("input", () => {
if (searchTimer !== null) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
searchTimer = null;
void onSearchChanged(input.value.trim());
}, 200);
});
}
async function onSearchChanged(q: string): Promise<void> {
state.q = q;
// Empty query → revert to pill-driven set (or cold-open default).
if (!q) {
state.event = "";
writeStateToURL();
void rerender();
if (eventRef) {
setActiveTabUI("search");
installOverhaulHost("procedures-panel-search");
await mountResultView({
eventRef,
triggerDate: params.get("trigger_date") || undefined,
party: params.get("party") || undefined,
courtId: params.get("court_id") || undefined,
});
return;
}
try {
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
url.searchParams.set("q", q);
url.searchParams.set("kind", "events");
url.searchParams.set("limit", "20");
if (state.forum) url.searchParams.set("jurisdiction", state.forum.toUpperCase());
if (state.party) url.searchParams.set("party", state.party);
const resp = await fetch(url.pathname + url.search, { headers: { Accept: "application/json" } });
if (!resp.ok) {
writeStateToURL();
void rerender();
return;
}
const body = await resp.json();
const events = Array.isArray(body.events) ? body.events : [];
// Collect distinct proceeding_type codes from the hits and pre-seed
// state.procs. If exactly one hit, scroll-highlight its anchor rule.
const procs: string[] = [];
for (const ev of events) {
const code = ev?.proceeding_type?.code;
if (typeof code === "string" && code && !procs.includes(code)) procs.push(code);
}
state.procs = procs;
const nextEvent = events.length === 1 && events[0]?.anchor_rule_id
? String(events[0].anchor_rule_id)
: "";
onAnchorChanged(nextEvent);
state.event = nextEvent;
writeStateToURL();
hydrateProcPills();
void rerender();
} catch (e) {
console.error("tracker search failed", e);
writeStateToURL();
void rerender();
}
await activateTab(readTabFromUrl());
}
// ─── trigger date input ────────────────────────────────────────────────────
function wireTriggerDateInput(): void {
const input = document.getElementById("tracker-trigger-date") as HTMLInputElement | null;
if (!input) return;
input.value = state.triggerDate;
input.addEventListener("change", () => {
const next = input.value || todayISO();
if (next === state.triggerDate) return;
state.triggerDate = next;
writeStateToURL();
void rerender();
});
}
// ─── flag toggle wiring ────────────────────────────────────────────────────
//
// Forks on the per-proceeding "Optionen" strip dispatch via event
// delegation so re-rendering doesn't need to re-bind. Each tick mutates
// state.flags and triggers a re-render of the specific card.
function wireFlagDelegation(): void {
const host = document.getElementById("tracker-timelines");
if (!host) return;
host.addEventListener("change", (ev) => {
const target = ev.target as HTMLInputElement;
if (!target || target.tagName !== "INPUT") return;
if (target.type !== "checkbox") return;
const flagKey = target.dataset.flag || "";
if (!flagKey) return;
if (target.checked) {
if (!state.flags.includes(flagKey)) state.flags = [...state.flags, flagKey];
} else {
state.flags = state.flags.filter((f) => f !== flagKey);
}
writeStateToURL();
void rerender();
});
}
// wireClickDelegation handles pin / fokus / proc-toggle. Single listener
// on the timelines host; the render functions stamp data-action on the
// affordances they emit.
function wireClickDelegation(): void {
const host = document.getElementById("tracker-timelines");
if (!host) return;
host.addEventListener("click", (ev) => {
const btn = (ev.target as HTMLElement | null)?.closest<HTMLButtonElement>("button[data-action]");
if (!btn) return;
const action = btn.dataset.action || "";
ev.preventDefault();
if (action === "pin") {
const ruleId = btn.dataset.ruleId || "";
if (!ruleId) return;
// Click on the already-anchored node un-pins. Toggle pattern.
const next = state.event === ruleId ? "" : ruleId;
onAnchorChanged(next);
state.event = next;
writeStateToURL();
void rerender();
return;
}
if (action === "fokus") {
// Toggle zoom on the current anchor.
if (!state.event) return;
state.zoom = !state.zoom;
writeStateToURL();
void rerender();
return;
}
if (action === "proc-toggle") {
const code = btn.dataset.code || "";
if (!code) return;
if (userExpanded.has(code)) userExpanded.delete(code);
else userExpanded.add(code);
void rerender();
return;
}
});
}
// ─── render driver ─────────────────────────────────────────────────────────
let currentRender = 0;
function pickProceedingsToRender(): string[] {
if (state.procs.length > 0) return state.procs;
if (state.forum) {
return PROCEEDINGS.filter((p) => p.forum === state.forum).map((p) => p.code);
}
return COLD_OPEN_DEFAULTS;
}
async function rerender(): Promise<void> {
const seq = ++currentRender;
const host = document.getElementById("tracker-timelines");
const summary = document.getElementById("tracker-find-summary");
if (!host) return;
// Loading placeholder during render. The placeholder is replaced
// atomically once all cards return, so the user doesn't see
// intermediate flicker between cards.
host.innerHTML = `<div class="tracker-timelines-placeholder">${t("procedures.timelines.loading")}</div>`;
const codes = pickProceedingsToRender();
// Cold-open hint: if we're showing the curated default set, surface
// a small instruction so the user knows the page expects further
// narrowing for non-default proceedings.
const isColdOpen = state.procs.length === 0 && !state.forum && !state.q;
const hasAnchor = !!state.event;
const multiProceeding = codes.length > 1;
// Multi-proceeding anchor scope (§6.5). When an anchor is pinned and
// multiple proceedings are visible, non-anchored proceedings render
// as a one-line header card with a [zeigen] link. We don't know yet
// which card carries the anchor — that's a property of the calc
// response. Two-pass render: first pass resolves anchor location;
// second pass renders collapsed/full per code. Cheap because the
// collapsed render skips the calc fetch entirely.
//
// Optimisation path: probe just one card per render to find the
// anchor's home. Probe the matching code first when we already know
// it (cached on `lastAnchorProceeding` below).
// First-pass: render every card, collect which ones carry the anchor.
const firstPass: RenderedTimeline[] = await Promise.all(
codes.map((code) =>
renderCard({
proceedingType: code,
triggerDate: state.triggerDate,
flags: state.flags,
anchorRuleId: state.event || undefined,
zoom: state.zoom,
}),
),
);
if (seq !== currentRender) return; // a newer render started; bail.
// If we have an anchor + multi-proceeding, decide which cards
// collapse. Cards with hasAnchor=true stay expanded; others collapse
// unless the user explicitly expanded them via [zeigen].
let rendered: RenderedTimeline[] = firstPass;
if (hasAnchor && multiProceeding) {
rendered = await Promise.all(
firstPass.map(async (r) => {
if (r.hasAnchor) return r;
if (userExpanded.has(r.card.dataset.proceeding || "")) return r;
// Re-render collapsed.
return renderCard({
proceedingType: r.card.dataset.proceeding || "",
triggerDate: state.triggerDate,
flags: state.flags,
anchorRuleId: state.event || undefined,
collapsed: true,
});
}),
);
if (seq !== currentRender) return;
}
host.innerHTML = "";
if (isColdOpen) {
const hint = document.createElement("div");
hint.className = "tracker-cold-open-hint";
hint.textContent = t("procedures.cold_open.hint");
host.appendChild(hint);
}
for (const r of rendered) host.appendChild(r.card);
if (rendered.length === 0) {
const empty = document.createElement("div");
empty.className = "tracker-timelines-empty";
empty.textContent = t("procedures.timelines.empty");
host.appendChild(empty);
}
// Find-header summary line. When an anchor is pinned, surface the
// anchor's name so the user has a visual confirmation of where
// they are.
if (summary) {
const base = summariseRender(rendered);
if (hasAnchor) {
const anchorName = findAnchorName(firstPass, state.event);
summary.textContent = anchorName
? `${base} · ${tDyn("procedures.find.summary.anchor").replace("{name}", anchorName)}`
: base;
} else {
summary.textContent = base;
}
}
// Scroll-highlight the anchored node, if any. Walks every card so a
// ?event= deep link works even when the same rule appears in a
// shared-chain proceeding (e.g. inf.cfi and ccr.cfi).
if (state.event) {
for (const r of rendered) scrollAnchorIntoView(r.card, state.event);
}
}
// findAnchorName resolves the anchored rule's display name across the
// rendered cards. Returns "" when the anchor isn't in any visible
// proceeding (e.g. invalid ?event= deep link).
function findAnchorName(rendered: RenderedTimeline[], ruleId: string): string {
if (!ruleId) return "";
for (const r of rendered) {
if (!r.data) continue;
const hit = r.data.deadlines.find((d) => d.ruleId === ruleId);
if (!hit) continue;
return hit.name || hit.nameEN || ruleId;
}
return "";
}
// ─── boot ──────────────────────────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
readStateFromURL();
hydrateForumPills();
hydrateProcPills();
hydratePartyPills();
wireSearchInput();
wireTriggerDateInput();
wireFlagDelegation();
wireClickDelegation();
void rerender();
wireTabs();
void boot();
});

View File

@@ -1358,8 +1358,6 @@ export type I18nKey =
| "deadlines.filter.status"
| "deadlines.filter.thisweek"
| "deadlines.filter.today"
| "deadlines.flag.amend"
| "deadlines.flag.cci"
| "deadlines.flag.ccr"
| "deadlines.flag.inf_amend"
| "deadlines.flag.rev_amend"
@@ -2205,40 +2203,19 @@ export type I18nKey =
| "partner_unit.members_label"
| "partner_unit.none"
| "partner_unit.subtitle"
| "procedures.cold_open.hint"
| "procedures.filter.axis.date"
| "procedures.filter.axis.forum"
| "procedures.filter.axis.kind"
| "procedures.filter.axis.party"
| "procedures.filter.axis.proc"
| "procedures.filter.forum.all"
| "procedures.filter.party.all"
| "procedures.filter.search.placeholder"
| "procedures.find.summary.anchor"
| "procedures.find.summary.empty"
| "procedures.find.summary.many"
| "procedures.find.summary.one"
| "procedures.heading"
| "procedures.node.fokus"
| "procedures.node.here"
| "procedures.node.pin"
| "procedures.panel.akte.placeholder"
| "procedures.proceeding.hide"
| "procedures.proceeding.show"
| "procedures.proceeding.toggle"
| "procedures.subtitle"
| "procedures.tab.akte"
| "procedures.tab.proceeding"
| "procedures.tab.search"
| "procedures.tab.wizard"
| "procedures.timelines.court_set"
| "procedures.timelines.empty"
| "procedures.timelines.error"
| "procedures.timelines.loading"
| "procedures.timelines.options"
| "procedures.title"
| "procedures.zoom.breadcrumb"
| "procedures.zoom.hidden"
| "project.instance_level.appeal"
| "project.instance_level.cassation"
| "project.instance_level.first"

View File

@@ -4,32 +4,23 @@ import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
import { VerfahrensablaufBody } from "./components/VerfahrensablaufBody";
// /tools/procedures — workflow-tracker shell (m/paliad#152 T1,
// docs/design-procedures-workflow-tracker-2026-05-27.md §9 T1).
// U0 — Skeleton for the unified procedural-events tool
// (m/paliad#151, design docs/design-unified-procedural-events-tool-2026-05-27.md).
//
// Single canonical shape:
// 1. Sticky find header — search input + forum / Verfahren / Partei
// pill rows + global Stichtag. The header narrows the timeline
// set rendered below; it is not itself a tab strip.
// 2. Timeline body — one card per matched proceeding, rendered as a
// chained tree by parent_id with priority-styled bullets. Cold
// open renders the 6 curated default proceedings from
// design §8 / §11.Q4.
// Folds /tools/fristenrechner (Mode A + Mode B + result) and
// /tools/verfahrensablauf into a single page at /tools/procedures. Each
// later slice fills one of the four entry tabs:
//
// Each later slice layers on top of this shell:
// T2 — anchor pin + zoom + multi-proceeding scope (§6.5).
// T3 — Akte landing + actuals overlay.
// T4 — appeal-target chip group + court-set choices + per-proceeding
// "Alle Optionen" toggle.
// T5 — dead-code removal (the old per-tab fristenrechner-mode-*,
// fristenrechner-wizard, fristenrechner-result, verfahrensablauf
// modules + their CSS once nothing imports them).
// U1 — Direkt suchen (Mode A search)
// U2 — Geführt (Mode B wizard)
// U3 — Verfahren (Verfahrensablauf tree + 3-way detail filter)
// U4 — Hard-cut 301 (drop legacy pages, redirect URLs)
//
// No DB dependency — the page itself is static HTML; data flows over
// the existing /api/tools/fristenrechner endpoints. The 4 entry-mode
// tabs the catalog (U0-U4) shipped earlier today are deleted in this
// PR per m's Q7 divergent pick (direct replace, no flag).
// This file ships only the page chrome — sidebar, header, filter strip
// with search box, four entry-mode tabs, and the host containers the
// later slices mount their UI into. No data wiring.
export function renderProcedures(): string {
const today = new Date().toISOString().split("T")[0];
@@ -55,18 +46,20 @@ export function renderProcedures(): string {
<div className="tool-header">
<h1 data-i18n="procedures.heading">Verfahren &amp; Fristen</h1>
<p className="tool-subtitle" data-i18n="procedures.subtitle">
Verfahrensabl&auml;ufe als Zeitstrahl &mdash; suchen, filtern, Verzweigungen w&auml;hlen.
Verfahrensablauf, Fristenrechner und ger&uuml;hrte Suche in einem Tool.
</p>
</div>
{/* Find affordance (design §2). Sticky header — search,
forum + Verfahren + Partei pills, and the global
Stichtag (date input). Pills hydrate from
procedures-tracker on boot; markup carries the host
rows only. */}
<section className="tracker-find" aria-label="Filter" id="tracker-find">
<div className="tracker-find-search">
<svg className="tracker-find-search-icon" width="18" height="18" viewBox="0 0 24 24"
{/* Shared filter strip — search box + four chip groups
(forum / proceeding / event_kind / party). Lives at the
top of the page so every entry tab and output mode reads
the same active filter set (design §4 + m's Q3
divergence: search composes with chip filters). U0
ships the markup only; chip hydration + search wiring
arrive with U1-U3. */}
<section className="procedures-filter-strip" aria-label="Filter">
<div className="procedures-filter-search">
<svg className="procedures-filter-search-icon" width="18" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
@@ -74,53 +67,120 @@ export function renderProcedures(): string {
</svg>
<input
type="search"
id="tracker-search-input"
className="tracker-find-search-input"
id="procedures-search-input"
className="procedures-filter-search-input"
autocomplete="off"
spellcheck="false"
data-i18n-placeholder="procedures.filter.search.placeholder"
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing&hellip;"
/>
</div>
<div className="tracker-find-row" data-axis="forum">
<span className="tracker-find-axis-label" data-i18n="procedures.filter.axis.forum">Forum:</span>
<div className="tracker-find-pills" id="tracker-pills-forum" role="group"></div>
</div>
<div className="tracker-find-row" data-axis="proc">
<span className="tracker-find-axis-label" data-i18n="procedures.filter.axis.proc">Verfahren:</span>
<div className="tracker-find-pills" id="tracker-pills-proc" role="group"></div>
</div>
<div className="tracker-find-row" data-axis="party">
<span className="tracker-find-axis-label" data-i18n="procedures.filter.axis.party">Partei:</span>
<div className="tracker-find-pills" id="tracker-pills-party" role="group"></div>
</div>
<div className="tracker-find-row" data-axis="date">
<label htmlFor="tracker-trigger-date" className="tracker-find-axis-label"
data-i18n="procedures.filter.axis.date">Stichtag:</label>
<input
type="date"
id="tracker-trigger-date"
className="tracker-find-date-input"
value={today}
/>
</div>
<div className="tracker-find-summary" id="tracker-find-summary" aria-live="polite"></div>
</section>
{/* Timeline body — one card per matched proceeding. Cards
are appended by procedures-tracker.ts on boot and
re-rendered when the find header changes. */}
<section className="tracker-timelines" id="tracker-timelines" aria-label="Verfahren">
<div className="tracker-timelines-placeholder" id="tracker-timelines-placeholder"
data-i18n="procedures.timelines.loading">
Verfahren werden geladen&hellip;
<div className="procedures-filter-chips" id="procedures-filter-chips">
<div className="procedures-filter-chip-row" data-axis="forum">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.forum">Forum:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-forum"></div>
</div>
<div className="procedures-filter-chip-row" data-axis="proc">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.proc">Verfahren:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-proc"></div>
</div>
<div className="procedures-filter-chip-row" data-axis="kind">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.kind">Ereignisart:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-kind"></div>
</div>
<div className="procedures-filter-chip-row" data-axis="party">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.party">Partei:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-party"></div>
</div>
</div>
</section>
{/* Entry-mode tab strip — all four tabs visible from boot
(m's Q3 divergence). The active tab is URL-driven
(?mode=proceeding|search|wizard|akte); cold open lands
on "proceeding" per design §11.5.Q3. */}
<nav className="procedures-tabs" role="tablist" aria-label="Einstieg">
<button type="button"
className="procedures-tab is-active"
role="tab"
aria-selected="true"
data-tab="proceeding"
id="procedures-tab-proceeding">
<span className="procedures-tab-icon" aria-hidden="true">&#128218;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.proceeding">Verfahren w&auml;hlen</span>
</button>
<button type="button"
className="procedures-tab"
role="tab"
aria-selected="false"
data-tab="search"
id="procedures-tab-search">
<span className="procedures-tab-icon" aria-hidden="true">&#9889;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.search">Direkt suchen</span>
</button>
<button type="button"
className="procedures-tab"
role="tab"
aria-selected="false"
data-tab="wizard"
id="procedures-tab-wizard">
<span className="procedures-tab-icon" aria-hidden="true">&#129517;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.wizard">Gef&uuml;hrt</span>
</button>
<button type="button"
className="procedures-tab"
role="tab"
aria-selected="false"
data-tab="akte"
id="procedures-tab-akte">
<span className="procedures-tab-icon" aria-hidden="true">&#128193;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.akte">Aus Akte</span>
</button>
</nav>
{/* Per-tab content hosts. Only one is visible at a time —
procedures.ts toggles `hidden` on the inactive ones.
Each later slice fills the corresponding host. */}
<section className="procedures-panel" id="procedures-panel-proceeding" role="tabpanel"
aria-labelledby="procedures-tab-proceeding">
{/* Verfahrensablauf wizard body — shared TSX component
used by /tools/verfahrensablauf (legacy) and the
unified /tools/procedures page. procedures.ts calls
initVerfahrensablauf() on the first activation of
this tab, which wires the .proceeding-btn clicks,
timeline-container, detail-mode toggle, etc. against
the markup. The legacy page's auto-boot is guarded
against the procedures-only #procedures-panel-proceeding
element so it doesn't fire twice. */}
<VerfahrensablaufBody todayIso={today} />
</section>
<section className="procedures-panel" id="procedures-panel-search" role="tabpanel"
aria-labelledby="procedures-tab-search" hidden></section>
<section className="procedures-panel" id="procedures-panel-wizard" role="tabpanel"
aria-labelledby="procedures-tab-wizard" hidden></section>
<section className="procedures-panel" id="procedures-panel-akte" role="tabpanel"
aria-labelledby="procedures-tab-akte" hidden>
<div className="procedures-panel-placeholder" data-i18n="procedures.panel.akte.placeholder">
Akten-Einstieg folgt in einem sp&auml;teren Slice.
</div>
</section>
{/* Tree output host. Slice U3 mounts the Verfahrensablauf
tree here; U0 leaves it empty + hidden so the
tab placeholders are the only thing visible. */}
<section className="procedures-output procedures-output-tree" id="procedures-output-tree"
aria-label="Tree output" hidden></section>
{/* Linear-drawer host. Inline drawer expanding beneath a
tree card (design §8 — desktop) AND the standalone
linear follow-up view that Mode A / Mode B land on
after locking a trigger event (design §3.2). U1
switches it on. */}
<section className="procedures-output procedures-output-linear" id="procedures-output-linear"
aria-label="Linear output" hidden></section>
</div>
</section>
</main>

View File

@@ -19811,479 +19811,3 @@ a.fristen-overhaul-rule-source {
width: 100%;
}
}
/* ============================================================================
* Workflow tracker — /tools/procedures (m/paliad#152 T1+).
*
* Single canonical shape: sticky find header + per-proceeding cards
* rendered as chained trees by parent_id. See
* docs/design-procedures-workflow-tracker-2026-05-27.md.
* T1 ships the shell + flat fork checkboxes; T2-T4 layer anchor pin /
* Akte / appeal-target on top.
* ========================================================================= */
.tracker-find {
position: sticky;
top: 0;
z-index: 5;
background: var(--color-bg);
border-bottom: 1px solid var(--color-border);
padding: 0.75rem 0 0.5rem;
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.tracker-find-search {
position: relative;
display: flex;
align-items: center;
}
.tracker-find-search-icon {
position: absolute;
left: 0.6rem;
color: var(--color-text-subtle);
pointer-events: none;
}
.tracker-find-search-input {
width: 100%;
padding: 0.5rem 0.75rem 0.5rem 2.2rem;
border: 1px solid var(--color-border-strong);
border-radius: 6px;
background: var(--color-bg-subtle);
color: var(--color-text);
font-size: 0.95rem;
}
.tracker-find-search-input:focus {
outline: 2px solid var(--color-accent);
outline-offset: -1px;
border-color: var(--color-accent);
}
.tracker-find-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.tracker-find-axis-label {
font-size: 0.85rem;
font-weight: 500;
color: var(--color-text-muted);
min-width: 5rem;
}
.tracker-find-pills {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.tracker-pill {
padding: 0.25rem 0.6rem;
border: 1px solid var(--color-border-strong);
border-radius: 999px;
background: var(--color-bg-subtle);
color: var(--color-text);
font-size: 0.85rem;
cursor: pointer;
transition: background 0.1s, border-color 0.1s;
}
.tracker-pill:hover {
background: var(--color-bg-lime-tint);
border-color: var(--color-accent);
}
.tracker-pill.is-active {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-fg);
font-weight: 500;
}
.tracker-find-date-input {
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-border-strong);
border-radius: 4px;
background: var(--color-bg-subtle);
color: var(--color-text);
font-size: 0.9rem;
}
.tracker-find-summary {
font-size: 0.85rem;
color: var(--color-text-muted);
min-height: 1.2rem;
}
.tracker-timelines {
display: flex;
flex-direction: column;
gap: 1rem;
}
.tracker-timelines-placeholder,
.tracker-timelines-empty,
.tracker-cold-open-hint {
padding: 0.75rem;
text-align: center;
font-size: 0.9rem;
color: var(--color-text-muted);
font-style: italic;
}
.tracker-cold-open-hint {
background: var(--color-bg-lime-tint);
border: 1px dashed var(--color-accent);
border-radius: 6px;
font-style: normal;
color: var(--color-text);
}
/* ─── per-proceeding card ───────────────────────────────────────── */
.tracker-proceeding {
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-bg);
overflow: hidden;
}
.tracker-proceeding-header {
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 0.9rem;
background: var(--color-bg-subtle);
border-bottom: 1px solid var(--color-border);
}
.tracker-proceeding-jur {
display: inline-block;
padding: 0.1rem 0.4rem;
background: var(--color-text);
color: var(--color-bg);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.05em;
border-radius: 3px;
}
.tracker-proceeding-name {
margin: 0;
font-size: 1rem;
font-weight: 600;
flex: 1;
}
.tracker-proceeding-code {
font-size: 0.75rem;
color: var(--color-text-subtle);
font-family: monospace;
}
.tracker-proceeding-options {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem;
padding: 0.5rem 0.9rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-bg);
font-size: 0.85rem;
}
.tracker-proceeding-options-label {
font-weight: 500;
color: var(--color-text-muted);
}
.tracker-proceeding-option {
display: inline-flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
}
.tracker-proceeding-option input[type="checkbox"] {
margin: 0;
accent-color: var(--color-accent);
}
.tracker-proceeding-body {
padding: 0.75rem 0.9rem;
}
.tracker-proceeding-loading,
.tracker-proceeding-empty,
.tracker-proceeding-error {
font-size: 0.85rem;
color: var(--color-text-subtle);
font-style: italic;
text-align: center;
padding: 0.5rem;
}
.tracker-proceeding-error {
color: var(--color-text);
background: rgba(245, 101, 101, 0.08);
border-left: 3px solid #ef4444;
font-style: normal;
}
/* ─── tree nodes ────────────────────────────────────────────────── */
.tracker-tree {
list-style: none;
margin: 0;
padding: 0;
}
.tracker-tree-root {
position: relative;
}
.tracker-tree .tracker-tree {
padding-left: 1.4rem;
position: relative;
border-left: 1px dashed var(--color-border-strong);
margin-left: 0.5rem;
}
.tracker-node {
position: relative;
padding: 0.3rem 0;
}
.tracker-node-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.25rem 0.4rem;
border-radius: 4px;
font-size: 0.9rem;
line-height: 1.3;
transition: background 0.1s;
}
.tracker-node:hover > .tracker-node-line {
background: var(--color-bg-lime-tint);
}
.tracker-node-bullet {
display: inline-block;
width: 0.7rem;
height: 0.7rem;
border-radius: 50%;
background: var(--color-text);
flex-shrink: 0;
}
.tracker-node--mandatory > .tracker-node-line .tracker-node-bullet {
background: var(--color-text);
}
.tracker-node--recommended > .tracker-node-line .tracker-node-bullet {
background: var(--color-text);
opacity: 0.6;
}
.tracker-node--optional > .tracker-node-line .tracker-node-bullet {
background: transparent;
border: 1.5px dotted var(--color-text-muted);
width: 0.65rem;
height: 0.65rem;
}
.tracker-node--informational > .tracker-node-line .tracker-node-bullet {
background: var(--color-text-subtle);
opacity: 0.5;
}
.tracker-node--court > .tracker-node-line .tracker-node-bullet {
background: #5b8def;
}
.tracker-node-name {
flex: 1;
font-weight: 500;
min-width: 8rem;
}
.tracker-node-ref {
font-size: 0.75rem;
color: var(--color-text-muted);
font-family: monospace;
}
.tracker-node-date {
font-size: 0.8rem;
color: var(--color-text-muted);
white-space: nowrap;
margin-left: auto;
}
.tracker-node-party {
display: inline-block;
min-width: 1.4rem;
text-align: center;
padding: 0.05rem 0.35rem;
border-radius: 3px;
font-size: 0.7rem;
font-weight: 600;
background: var(--color-bg-subtle);
color: var(--color-text-muted);
border: 1px solid var(--color-border);
}
.tracker-node-party--court {
background: rgba(91, 141, 239, 0.12);
color: #2c5fb8;
border-color: rgba(91, 141, 239, 0.3);
}
.tracker-node-party--claimant {
background: rgba(34, 197, 94, 0.10);
color: #1f7a4a;
border-color: rgba(34, 197, 94, 0.25);
}
.tracker-node-party--defendant {
background: rgba(245, 158, 11, 0.10);
color: #92500a;
border-color: rgba(245, 158, 11, 0.25);
}
.tracker-node--anchored > .tracker-node-line {
background: var(--color-bg-lime-tint);
border-left: 4px solid var(--color-accent);
padding-left: 0.5rem;
}
.tracker-node-here {
display: block;
margin: 0.2rem 0 0.2rem 0.4rem;
padding: 0.15rem 0.4rem;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.1em;
color: var(--color-accent-dark);
background: var(--color-accent);
border-radius: 3px;
width: max-content;
}
.tracker-node-pin,
.tracker-node-fokus {
border: none;
background: transparent;
cursor: pointer;
font-size: 0.85rem;
padding: 0.1rem 0.3rem;
border-radius: 3px;
opacity: 0.35;
transition: opacity 0.1s, background 0.1s;
}
.tracker-node:hover > .tracker-node-line .tracker-node-pin,
.tracker-node--anchored > .tracker-node-line .tracker-node-pin,
.tracker-node-fokus {
opacity: 1;
}
.tracker-node-pin:hover,
.tracker-node-fokus:hover {
background: var(--color-bg-lime-tint);
}
.tracker-proceeding--anchored {
border-color: var(--color-accent);
box-shadow: 0 0 0 1px var(--color-accent);
}
.tracker-proceeding-toggle {
border: 1px solid var(--color-border-strong);
background: var(--color-bg);
color: var(--color-text-muted);
font-size: 0.8rem;
padding: 0.15rem 0.5rem;
border-radius: 4px;
cursor: pointer;
}
.tracker-proceeding-toggle:hover {
background: var(--color-bg-lime-tint);
border-color: var(--color-accent);
color: var(--color-text);
}
/* Collapsed proceeding card — header-only render (§6.5). The toggle
* shows "zeigen"; clicking expands. Body stays absent. */
.tracker-proceeding--collapsed .tracker-proceeding-header {
border-bottom: none;
}
/* Zoom breadcrumb (§6.2). One-line ancestors trail above the focused
* subtree. The anchor crumb at the right is highlighted lime so the
* user sees their current depth. */
.tracker-zoom-breadcrumb {
font-size: 0.85rem;
color: var(--color-text-muted);
padding: 0.3rem 0.5rem;
margin-bottom: 0.4rem;
border-bottom: 1px dashed var(--color-border);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.3rem;
}
.tracker-zoom-crumb {
padding: 0.05rem 0.3rem;
}
.tracker-zoom-crumb--anchor {
background: var(--color-accent);
color: var(--color-accent-fg);
border-radius: 3px;
font-weight: 500;
}
.tracker-zoom-crumb-sep {
color: var(--color-text-subtle);
}
.tracker-zoom-hidden {
font-size: 0.8rem;
color: var(--color-text-muted);
font-style: italic;
text-align: center;
margin-top: 0.5rem;
padding-top: 0.3rem;
border-top: 1px dashed var(--color-border);
}
.tracker-node--highlight > .tracker-node-line {
animation: tracker-node-flash 3s ease-out;
}
@keyframes tracker-node-flash {
0% { background: var(--color-accent); }
100% { background: transparent; }
}
@media (max-width: 600px) {
.tracker-find-axis-label {
min-width: 4rem;
}
.tracker-node-date {
margin-left: 0;
}
}