diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 1d6312a..c416ca7 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -212,6 +212,33 @@ const translations: Record> = { "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", @@ -3416,6 +3443,32 @@ const translations: Record> = { "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", diff --git a/frontend/src/client/procedures-tracker.ts b/frontend/src/client/procedures-tracker.ts new file mode 100644 index 0000000..046f98b --- /dev/null +++ b/frontend/src/client/procedures-tracker.ts @@ -0,0 +1,514 @@ +// 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 = { + 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": ""} — 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): void { + if (!node || typeof node !== "object") return; + const n = node as Record; + 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(); + 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 = { + "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 = { + 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 { + 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 = ` + ${escHtml(jur)} +

${escHtml(procName)}

+ ${escHtml(params.proceedingType)} + + `; + 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 = `
${escHtml(t("procedures.timelines.loading"))}
`; + card.appendChild(body); + + // Fetch + render. + const data = await calculateDeadlines({ + proceedingType: params.proceedingType, + triggerDate: params.triggerDate, + flags: params.flags, + }); + + if (!data) { + body.innerHTML = `
${escHtml(t("procedures.timelines.error"))}
`; + 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
    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 `
    ${escHtml(t("procedures.timelines.empty"))}
    `; + } + + // 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 = {}; + 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[] = [`
      `]; + for (const root of roots) { + parts.push(renderTreeNode(root, childrenOf, 0, anchorRuleId)); + } + parts.push(`
    `); + return parts.join(""); +} + +function renderTreeNode( + dl: CalculatedDeadline, + childrenOf: Record, + 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 + ? `` + : ""; + const fokusBtn = isAnchored + ? `` + : ""; + + const meta = ` +
    + + ${escHtml(name)} + ${ref ? `${escHtml(ref)}` : ""} + ${dateLabel ? `${escHtml(dateLabel)}` : ""} + ${partyBadge ? `${escHtml(partyBadge)}` : ""} + ${pinBtn} + ${fokusBtn} +
    + ${isAnchored ? `
    ${escHtml(t("procedures.node.here"))}
    ` : ""} + `; + + let inner = meta; + if (children.length > 0) { + const kids = children + .map((c) => renderTreeNode(c, childrenOf, depth + 1, anchorRuleId)) + .join(""); + inner += `
      ${kids}
    `; + } + + return `
  • ${inner}
  • `; +} + +// ─── 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 = {}; + 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(`${escHtml(name)}`); + } + const anchorName = lang === "en" ? (anchor.nameEN || anchor.name) : (anchor.name || anchor.nameEN); + breadcrumbParts.push(`${escHtml(anchorName)}`); + + const breadcrumb = ``; + + // Subtree: anchor + all descendants (parentRuleCode chain rooted + // at the anchor). + const descendants = new Set([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 + ? `
    ${escHtml(tDyn("procedures.zoom.hidden").replace("{n}", String(hiddenCount)))}
    ` + : ""; + + return breadcrumb + subtreeBody + hiddenLine; +} + +// ─── search hit highlight (URL `?event=`) ────────────────────────────────── +// +// T1 affordance: when `?event=` 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(`[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)); +} diff --git a/frontend/src/client/procedures.ts b/frontend/src/client/procedures.ts index 0723a22..76a76cc 100644 --- a/frontend/src/client/procedures.ts +++ b/frontend/src/client/procedures.ts @@ -1,150 +1,520 @@ -// /tools/procedures client (m/paliad#151, -// docs/design-unified-procedural-events-tool-2026-05-27.md). +// /tools/procedures client (m/paliad#152 T1, +// docs/design-procedures-workflow-tracker-2026-05-27.md §9 T1). // -// 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. +// Workflow-tracker shell — replaces the 4-tab catalog (U0-U4) shipped +// earlier today with a single canonical shape: // -// 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. +// 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. // -// 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. +// URL state (T1): +// ?q= — free-text search +// ?forum= — single forum (upc/de/epa/dpma) +// ?procs= — comma-separated proceeding codes +// ?party= — claimant/defendant/both/"" +// ?trigger_date= — global Stichtag +// ?event= — scroll-highlight matching node (no anchor +// pin / zoom yet; T2 layering) +// ?flags= — 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. -import { initI18n } from "./i18n"; +import { initI18n, t, tDyn } from "./i18n"; import { initSidebar } from "./sidebar"; -import { mountModeA } from "./fristenrechner-mode-a"; -import { mountResultView } from "./fristenrechner-result"; -import { mountWizard } from "./fristenrechner-wizard"; -import { initVerfahrensablauf } from "./verfahrensablauf"; +import { + COLD_OPEN_DEFAULTS, + PROCEEDINGS, + type ProceedingDef, + type RenderedTimeline, + proceedingDisplayName, + renderCard, + scrollAnchorIntoView, + summariseRender, +} from "./procedures-tracker"; -type ProceduresTab = "proceeding" | "search" | "wizard" | "akte"; +type ForumId = "upc" | "de" | "epa" | "dpma" | ""; +type PartyId = "claimant" | "defendant" | "both" | ""; -const TABS: 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[], +}; -function readTabFromUrl(): ProceduresTab { - const params = new URLSearchParams(window.location.search); - const raw = params.get("mode"); - if (raw && (TABS as string[]).includes(raw)) return raw as ProceduresTab; - return "proceeding"; +// 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(); +let lastAnchor = ""; + +function todayISO(): string { + return new Date().toISOString().split("T")[0]; } -function writeTabToUrl(tab: ProceduresTab): void { +// ─── URL state ───────────────────────────────────────────────────────────── + +function readStateFromURL(): void { + 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; +} + +function writeStateToURL(): void { const url = new URL(window.location.href); - if (tab === "proceeding") { - url.searchParams.delete("mode"); - } else { - url.searchParams.set("mode", tab); - } - history.replaceState(null, "", url.pathname + url.search + url.hash); + 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); } -// 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 = ` -
    -
    -
    -
    -
    - `; - return panel; +// 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; } -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 setOrDelete(sp: URLSearchParams, key: string, value: string): void { + if (value) sp.set(key, value); + else sp.delete(key); } -// 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; +// ─── pill hydration ──────────────────────────────────────────────────────── -async function activateTab(tab: ProceduresTab): Promise { - 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 wireTabs(): void { - for (const t of TABS) { - const btn = document.getElementById(`procedures-tab-${t}`); - if (!btn) continue; +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", () => { - void activateTab(t); - writeTabToUrl(t); + 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); } } -// 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 { - const params = new URLSearchParams(window.location.search); - const eventRef = params.get("event") || ""; - - 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, +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); + } +} + +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"); + btn.addEventListener("click", () => { + state.party = p.id; + writeStateToURL(); + hydratePartyPills(); + void rerender(); + }); + 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. + +let searchTimer: ReturnType | 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 { + state.q = q; + // Empty query → revert to pill-driven set (or cold-open default). + if (!q) { + state.event = ""; + writeStateToURL(); + void rerender(); return; } - await activateTab(readTabFromUrl()); + 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(); + } } +// ─── 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("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 { + 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 = `
    ${t("procedures.timelines.loading")}
    `; + + 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(); - wireTabs(); - void boot(); + readStateFromURL(); + hydrateForumPills(); + hydrateProcPills(); + hydratePartyPills(); + wireSearchInput(); + wireTriggerDateInput(); + wireFlagDelegation(); + wireClickDelegation(); + void rerender(); }); diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 55befa6..7628896 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -1358,6 +1358,8 @@ 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" @@ -2203,19 +2205,40 @@ 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" diff --git a/frontend/src/procedures.tsx b/frontend/src/procedures.tsx index a4bea0f..5e0519b 100644 --- a/frontend/src/procedures.tsx +++ b/frontend/src/procedures.tsx @@ -4,23 +4,32 @@ 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"; -// U0 — Skeleton for the unified procedural-events tool -// (m/paliad#151, design docs/design-unified-procedural-events-tool-2026-05-27.md). +// /tools/procedures — workflow-tracker shell (m/paliad#152 T1, +// docs/design-procedures-workflow-tracker-2026-05-27.md §9 T1). // -// 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: +// 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. // -// 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) +// 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). // -// 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. +// 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). export function renderProcedures(): string { const today = new Date().toISOString().split("T")[0]; @@ -46,20 +55,18 @@ export function renderProcedures(): string {

    Verfahren & Fristen

    - Verfahrensablauf, Fristenrechner und gerührte Suche in einem Tool. + Verfahrensabläufe als Zeitstrahl — suchen, filtern, Verzweigungen wählen.

    - {/* 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. */} -
    -
    - +
    +
    -
    -
    - Forum: -
    -
    -
    - Verfahren: -
    -
    -
    - Ereignisart: -
    -
    -
    - Partei: -
    -
    + +
    + Forum: +
    +
    + +
    + Verfahren: +
    +
    + +
    + Partei: +
    +
    + +
    + + +
    + +
    +
    + + {/* Timeline body — one card per matched proceeding. Cards + are appended by procedures-tracker.ts on boot and + re-rendered when the find header changes. */} +
    +
    + Verfahren werden geladen…
    - - {/* 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. */} - - - {/* 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. */} -
    - {/* 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. */} - -
    - - - - - - - - {/* Tree output host. Slice U3 mounts the Verfahrensablauf - tree here; U0 leaves it empty + hidden so the - tab placeholders are the only thing visible. */} - - - {/* 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. */} - diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 7de21e5..b1cac3c 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -19811,3 +19811,479 @@ 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; + } +}