feat(procedures): T3 — Akte landing + actuals overlay (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

Wires the workflow tracker to projects via ?project=<uuid>, per design
§6.4 + §11.Q5:

- loadAkte fetches /api/projects/{id}, /api/projects/{id}/timeline
  and /api/projects/{id}/scenario-flags in parallel:
    1. Project title + proceeding_type — pre-seeds the Verfahren pill.
    2. Timeline events → ActualsMap keyed by deadline_rule_id with
       status (done / overdue / open / court_set), due / completed
       date, and deadline / appointment ids.
    3. scenario_flags → seeds state.flags so the gating-flag checkboxes
       render in the persisted state. Per-rule rule:<uuid> flags stay
       out of the calc payload (they drive priority deviations via
       isRuleSelected, handled by the existing detail-mode filter).
- Auto-pin: the first render with no explicit ?event= pins the most
  recent status='done' deadline. URL pin (shared link) is preserved.
- Per-node overlay: each node carries the actuals badge — ✓ (done +
  strike-through), ⚠ (overdue + red wash), 📅 (open ≠ projected), ◇
  (open ≡ projected). Date column shows the actual date.
- Fork write-back: PATCH /api/projects/{id}/scenario-flags fires on
  every flag toggle so Mode B / Verlauf / dashboard re-render with the
  same scenario on next visit. Fire-and-forget; UI doesn't wait.
- Find-header summary chips: "Akte: <title>" alongside "Anker: <name>"
  + "{n} Verfahren".

Out of T3 (deferred):
- ?project= picker UI (today's user navigates here from /projects/{id}
  via deep-link).
- Per-rule rule:<uuid> flag write-back (priority deviations) — the
  detail-mode filter doesn't take an interactive toggle yet.
- Cross-surface scenario-flag-changed CustomEvent listener — patching
  fires the event, the tracker just doesn't yet re-render on incoming
  ones (T4 polish).

t-paliad-338
This commit is contained in:
mAi
2026-05-27 21:51:42 +02:00
parent 7945bfb364
commit 1ed75c56e3
5 changed files with 312 additions and 20 deletions

View File

@@ -228,6 +228,10 @@ const translations: Record<Lang, Record<string, string>> = {
"procedures.find.summary.one": "{n} Verfahren",
"procedures.find.summary.many": "{n} Verfahren",
"procedures.find.summary.anchor": "Anker: {name}",
"procedures.find.summary.akte": "Akte: {name}",
"procedures.node.actual.done": "Erledigt",
"procedures.node.actual.overdue": "Überfällig",
"procedures.node.actual.open": "Offen",
"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",
@@ -3458,6 +3462,10 @@ const translations: Record<Lang, Record<string, string>> = {
"procedures.find.summary.one": "{n} proceeding",
"procedures.find.summary.many": "{n} proceedings",
"procedures.find.summary.anchor": "Anchor: {name}",
"procedures.find.summary.akte": "Matter: {name}",
"procedures.node.actual.done": "Done",
"procedures.node.actual.overdue": "Overdue",
"procedures.node.actual.open": "Open",
"procedures.node.pin": "Pin this event as the anchor",
"procedures.node.fokus": "Focus — hide sibling branches",
"procedures.node.here": "── YOU ARE HERE ──",

View File

@@ -171,6 +171,22 @@ function labelForFlag(flagKey: string, proceeding: string): string {
// ─── per-proceeding render ──────────────────────────────────────────────────
// ActualStatus is the per-rule overlay derived from paliad.deadlines /
// paliad.appointments for an Akte (§6.4). The tracker reads this map
// when ?project= is set and stamps a status badge on each node.
export interface ActualStatus {
status: "done" | "open" | "overdue" | "court_set";
// dueDate / completedAt fall back to the calculator's projected date
// when not set (open / future). Format is ISO date.
dueDate?: string;
completedAt?: string;
// deadlineId lets the renderer deep-link to /projects/{p}/deadlines/{id}.
deadlineId?: string;
appointmentId?: string;
}
export type ActualsMap = Map<string, ActualStatus>;
export interface TimelineRenderParams {
proceedingType: string;
triggerDate: string;
@@ -182,6 +198,11 @@ export interface TimelineRenderParams {
// proceeding visible, non-anchored proceedings collapse so the
// anchor's full context owns the page.
collapsed?: boolean;
// actuals carries the per-rule overlay when the page is bound to an
// Akte via ?project=. Empty map / undefined = template render.
actuals?: ActualsMap;
// projectId carries through to deep-links and write-back paths.
projectId?: string;
}
export interface RenderedTimeline {
@@ -293,9 +314,9 @@ export async function renderCard(params: TimelineRenderParams): Promise<Rendered
const hasAnchor = !!(params.anchorRuleId && filtered.some((d) => d.ruleId === params.anchorRuleId));
if (params.zoom && hasAnchor && params.anchorRuleId) {
body.innerHTML = renderZoomedBody(filtered, params.anchorRuleId);
body.innerHTML = renderZoomedBody(filtered, params.anchorRuleId, params.actuals);
} else {
body.innerHTML = renderTreeBody(filtered, params.anchorRuleId);
body.innerHTML = renderTreeBody(filtered, params.anchorRuleId, params.actuals);
}
if (hasAnchor) card.classList.add("tracker-proceeding--anchored");
@@ -310,7 +331,11 @@ export async function renderCard(params: TimelineRenderParams): Promise<Rendered
// 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 {
function renderTreeBody(
deadlines: CalculatedDeadline[],
anchorRuleId?: string,
actuals?: ActualsMap,
): string {
if (deadlines.length === 0) {
return `<div class="tracker-proceeding-empty">${escHtml(t("procedures.timelines.empty"))}</div>`;
}
@@ -333,7 +358,7 @@ function renderTreeBody(deadlines: CalculatedDeadline[], anchorRuleId?: string):
const parts: string[] = [`<ul class="tracker-tree tracker-tree-root">`];
for (const root of roots) {
parts.push(renderTreeNode(root, childrenOf, 0, anchorRuleId));
parts.push(renderTreeNode(root, childrenOf, 0, anchorRuleId, actuals));
}
parts.push(`</ul>`);
return parts.join("");
@@ -344,6 +369,7 @@ function renderTreeNode(
childrenOf: Record<string, CalculatedDeadline[]>,
depth: number,
anchorRuleId?: string,
actuals?: ActualsMap,
): string {
const children = childrenOf[dl.code] || [];
const isAnchored = !!(anchorRuleId && dl.ruleId === anchorRuleId);
@@ -356,9 +382,37 @@ function renderTreeNode(
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) : "");
// Actuals overlay (§6.4). When the page is Akte-bound and this rule
// has an actuals row, the badge replaces the priority bullet's
// status — done = ✓, overdue = ⚠, open ≠ projected = 📅, open ≡
// projected = ◇. Date column shows the actual date when present.
const actual = (actuals && dl.ruleId) ? actuals.get(dl.ruleId) : undefined;
let dateLabel = "";
let statusBadge = "";
let actualClass = "";
if (actual) {
actualClass = ` tracker-node--actual-${actual.status}`;
if (actual.status === "done") {
statusBadge = `<span class="tracker-node-actual tracker-node-actual--done" title="${escHtml(t("procedures.node.actual.done"))}">✓</span>`;
dateLabel = actual.completedAt ? formatDate(actual.completedAt) : (actual.dueDate ? formatDate(actual.dueDate) : "");
} else if (actual.status === "overdue") {
statusBadge = `<span class="tracker-node-actual tracker-node-actual--overdue" title="${escHtml(t("procedures.node.actual.overdue"))}">⚠</span>`;
dateLabel = actual.dueDate ? formatDate(actual.dueDate) : "";
} else if (actual.status === "open") {
// Open + actual due differs from projected = 📅, else ◇.
const projectedDate = dl.dueDate || "";
const actualDate = actual.dueDate || "";
const differs = projectedDate && actualDate && projectedDate !== actualDate;
statusBadge = `<span class="tracker-node-actual tracker-node-actual--open" title="${escHtml(t("procedures.node.actual.open"))}">${differs ? "📅" : "◇"}</span>`;
dateLabel = actualDate ? formatDate(actualDate) : (projectedDate ? formatDate(projectedDate) : "");
}
}
if (!dateLabel) {
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"
@@ -390,6 +444,7 @@ function renderTreeNode(
const meta = `
<div class="tracker-node-line">
<span class="tracker-node-bullet" aria-hidden="true"></span>
${statusBadge}
<span class="tracker-node-name">${escHtml(name)}</span>
${ref ? `<span class="tracker-node-ref">${escHtml(ref)}</span>` : ""}
${dateLabel ? `<span class="tracker-node-date">${escHtml(dateLabel)}</span>` : ""}
@@ -403,12 +458,12 @@ function renderTreeNode(
let inner = meta;
if (children.length > 0) {
const kids = children
.map((c) => renderTreeNode(c, childrenOf, depth + 1, anchorRuleId))
.map((c) => renderTreeNode(c, childrenOf, depth + 1, anchorRuleId, actuals))
.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>`;
return `<li class="tracker-node ${priorityClass}${anchorClass}${courtClass}${actualClass}"${ruleIdAttr}${codeAttr}>${inner}</li>`;
}
// ─── zoom mode (§6.2) ──────────────────────────────────────────────────────
@@ -425,13 +480,17 @@ function renderTreeNode(
// 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 {
function renderZoomedBody(
deadlines: CalculatedDeadline[],
anchorRuleId: string,
actuals?: ActualsMap,
): 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);
return renderTreeBody(deadlines, anchorRuleId, actuals);
}
// Build the parent chain (anchor → root). The chain is walked via
@@ -478,7 +537,7 @@ function renderZoomedBody(deadlines: CalculatedDeadline[], anchorRuleId: string)
}
}
const subtree = deadlines.filter((d) => descendants.has(d.code));
const subtreeBody = renderTreeBody(subtree, anchorRuleId);
const subtreeBody = renderTreeBody(subtree, anchorRuleId, actuals);
// Sibling count summary — descendants ignored. Stays terse so the
// page tells the user how much is hidden without listing it.

View File

@@ -34,13 +34,15 @@ import { initSidebar } from "./sidebar";
import {
COLD_OPEN_DEFAULTS,
PROCEEDINGS,
type ProceedingDef,
type ActualStatus,
type ActualsMap,
type RenderedTimeline,
proceedingDisplayName,
renderCard,
scrollAnchorIntoView,
summariseRender,
} from "./procedures-tracker";
import { fetchScenarioFlags, patchScenarioFlags } from "./scenario-flags";
type ForumId = "upc" | "de" | "epa" | "dpma" | "";
type PartyId = "claimant" | "defendant" | "both" | "";
@@ -55,6 +57,14 @@ const state = {
event: "",
zoom: false,
flags: [] as string[],
// T3 Akte state — loaded on demand from /api/projects/{id}/timeline
// when ?project= is set in the URL. Kept off the URL writer so a
// shared link without ?project= doesn't accidentally leak.
projectId: "",
projectTitle: "",
projectProceeding: "",
actuals: new Map() as ActualsMap,
akteLoaded: false,
};
// Per-anchor user-expanded set — when the multi-proceeding auto-collapse
@@ -82,6 +92,7 @@ function readStateFromURL(): void {
state.zoom = params.get("zoom") === "1";
const flags = params.get("flags") || "";
state.flags = flags ? flags.split(",").map((s) => s.trim()).filter(Boolean) : [];
state.projectId = params.get("project") || "";
lastAnchor = state.event;
}
@@ -96,6 +107,7 @@ function writeStateToURL(): void {
setOrDelete(sp, "event", state.event);
setOrDelete(sp, "zoom", state.event && state.zoom ? "1" : "");
setOrDelete(sp, "flags", state.flags.join(","));
setOrDelete(sp, "project", state.projectId);
// 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");
@@ -319,6 +331,16 @@ function wireFlagDelegation(): void {
state.flags = state.flags.filter((f) => f !== flagKey);
}
writeStateToURL();
// T3: when bound to a project, persist the flag delta via
// patchScenarioFlags so a reload (or another surface — Mode B
// Fristenrechner / Verlauf) sees the same scenario. Fire-and-
// forget; the cross-surface re-sync fires a CustomEvent that
// doesn't reach back here today (the tracker has no listener),
// but the persistence side-effect is what matters.
if (state.projectId) {
void patchScenarioFlags(state.projectId, { [flagKey]: target.checked });
}
void rerender();
});
}
@@ -420,6 +442,8 @@ async function rerender(): Promise<void> {
flags: state.flags,
anchorRuleId: state.event || undefined,
zoom: state.zoom,
actuals: state.akteLoaded ? state.actuals : undefined,
projectId: state.projectId || undefined,
}),
),
);
@@ -442,6 +466,8 @@ async function rerender(): Promise<void> {
flags: state.flags,
anchorRuleId: state.event || undefined,
collapsed: true,
actuals: state.akteLoaded ? state.actuals : undefined,
projectId: state.projectId || undefined,
});
}),
);
@@ -470,15 +496,17 @@ async function rerender(): Promise<void> {
// anchor's name so the user has a visual confirmation of where
// they are.
if (summary) {
const base = summariseRender(rendered);
const parts: string[] = [summariseRender(rendered)];
if (state.akteLoaded && state.projectTitle) {
parts.push(tDyn("procedures.find.summary.akte").replace("{name}", state.projectTitle));
}
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;
if (anchorName) {
parts.push(tDyn("procedures.find.summary.anchor").replace("{name}", anchorName));
}
}
summary.textContent = parts.join(" · ");
}
// Scroll-highlight the anchored node, if any. Walks every card so a
@@ -503,6 +531,147 @@ function findAnchorName(rendered: RenderedTimeline[], ruleId: string): string {
return "";
}
// ─── Akte landing (§6.4) ───────────────────────────────────────────────────
//
// When ?project=<uuid> is in the URL, we load:
// 1. /api/projects/{id} — title + proceeding_type for header context
// 2. /api/projects/{id}/timeline — actuals (deadlines + appointments)
// 3. /api/projects/{id}/scenario-flags — seeds state.flags + provides
// write-back path
//
// The actuals overlay maps deadline_rule_id → ActualStatus. The
// fristenrechner calc returns ruleId on each TimelineEntry; the
// tracker stamps the matching badge on each node.
//
// On first load, the anchor auto-pins to the latest status='done'
// deadline (design Q5). Subsequent renders preserve the user's pin.
async function loadAkte(projectId: string): Promise<void> {
if (!projectId) {
state.actuals = new Map();
state.akteLoaded = false;
state.projectTitle = "";
state.projectProceeding = "";
return;
}
// 1. Project header / proceeding_type.
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}`, {
headers: { Accept: "application/json" },
});
if (resp.ok) {
const proj = await resp.json();
state.projectTitle = String(proj?.title || proj?.name || "");
const procCode = String(proj?.proceeding_type?.code || proj?.proceeding_type_code || "");
state.projectProceeding = procCode;
if (procCode && !state.procs.includes(procCode)) {
state.procs = [procCode];
}
}
} catch (e) {
console.warn("project fetch failed", e);
}
// 2. Timeline → actuals map.
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/timeline`, {
headers: { Accept: "application/json" },
});
if (resp.ok) {
const body = await resp.json();
const events = Array.isArray(body?.events) ? body.events : Array.isArray(body) ? body : [];
state.actuals = buildActualsMap(events);
// Auto-pin anchor: latest status='done' (most recent completed
// deadline). Only when no anchor is set yet — preserve URL
// ?event= for shared links.
if (!state.event) {
const anchor = pickLatestDoneAnchor(events);
if (anchor) {
state.event = anchor;
lastAnchor = anchor;
}
}
}
} catch (e) {
console.warn("project timeline fetch failed", e);
}
// 3. Scenario flags — seed state.flags + future write-back.
try {
const view = await fetchScenarioFlags(projectId);
if (view && view.flags) {
const onFlags: string[] = [];
for (const [k, v] of Object.entries(view.flags)) {
// Filter to top-level scenario flags (not per-rule deviations).
// Per-rule flags are keyed `rule:<uuid>` and not consumed by
// the calc's flags[] payload.
if (k.startsWith("rule:")) continue;
if (v === true) onFlags.push(k);
}
state.flags = onFlags;
}
} catch (e) {
console.warn("scenario-flags fetch failed", e);
}
state.akteLoaded = true;
}
function buildActualsMap(events: unknown[]): ActualsMap {
const map: ActualsMap = new Map();
for (const ev of events) {
const e = ev as Record<string, unknown> | null;
if (!e) continue;
const ruleId = String(e.deadline_rule_id || "");
if (!ruleId) continue;
const kind = String(e.kind || "");
const status = String(e.status || "");
const date = typeof e.date === "string" ? e.date.split("T")[0] : "";
// Map SmartTimeline statuses to ActualStatus.status.
let mapped: ActualStatus["status"];
if (status === "done" || kind === "appointment") {
mapped = "done";
} else if (status === "overdue") {
mapped = "overdue";
} else if (status === "court_set") {
mapped = "court_set";
} else if (status === "open") {
mapped = "open";
} else {
// projected / predicted / off_script — don't overlay.
continue;
}
const entry: ActualStatus = { status: mapped };
if (mapped === "done") entry.completedAt = date || undefined;
else entry.dueDate = date || undefined;
if (typeof e.deadline_id === "string") entry.deadlineId = e.deadline_id;
if (typeof e.appointment_id === "string") entry.appointmentId = e.appointment_id;
map.set(ruleId, entry);
}
return map;
}
function pickLatestDoneAnchor(events: unknown[]): string {
let latest = "";
let latestDate = "";
for (const ev of events) {
const e = ev as Record<string, unknown> | null;
if (!e) continue;
if (e.status !== "done") continue;
const ruleId = String(e.deadline_rule_id || "");
if (!ruleId) continue;
const date = typeof e.date === "string" ? e.date : "";
if (!latest || date > latestDate) {
latest = ruleId;
latestDate = date;
}
}
return latest;
}
// ─── boot ──────────────────────────────────────────────────────────────────
document.addEventListener("DOMContentLoaded", () => {
@@ -516,5 +685,17 @@ document.addEventListener("DOMContentLoaded", () => {
wireTriggerDateInput();
wireFlagDelegation();
wireClickDelegation();
void rerender();
// T3: when bound to an Akte via ?project=, load actuals + scenario
// flags + auto-pin to latest done deadline BEFORE the first render.
// Otherwise the first render fires on template data and re-renders
// once the Akte resolves — visible flicker on a slow connection.
if (state.projectId) {
void loadAkte(state.projectId).then(() => {
hydrateProcPills();
void rerender();
});
} else {
void rerender();
}
});

View File

@@ -2214,11 +2214,15 @@ export type I18nKey =
| "procedures.filter.forum.all"
| "procedures.filter.party.all"
| "procedures.filter.search.placeholder"
| "procedures.find.summary.akte"
| "procedures.find.summary.anchor"
| "procedures.find.summary.empty"
| "procedures.find.summary.many"
| "procedures.find.summary.one"
| "procedures.heading"
| "procedures.node.actual.done"
| "procedures.node.actual.open"
| "procedures.node.actual.overdue"
| "procedures.node.fokus"
| "procedures.node.here"
| "procedures.node.pin"

View File

@@ -20270,6 +20270,46 @@ a.fristen-overhaul-rule-source {
border-top: 1px dashed var(--color-border);
}
/* ─── Akte actuals overlay (§6.4 / T3) ─────────────────────────── */
.tracker-node-actual {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 1.2rem;
height: 1.2rem;
font-size: 0.85rem;
font-weight: 700;
border-radius: 3px;
padding: 0 0.2rem;
}
.tracker-node-actual--done {
background: rgba(34, 197, 94, 0.15);
color: #1f7a4a;
}
.tracker-node-actual--overdue {
background: rgba(239, 68, 68, 0.15);
color: #b91c1c;
}
.tracker-node-actual--open {
background: var(--color-bg-subtle);
color: var(--color-text-muted);
}
.tracker-node--actual-done > .tracker-node-line .tracker-node-name {
text-decoration: line-through;
text-decoration-color: rgba(31, 122, 74, 0.5);
text-decoration-thickness: 1.5px;
color: var(--color-text-muted);
}
.tracker-node--actual-overdue > .tracker-node-line {
background: rgba(239, 68, 68, 0.05);
}
.tracker-node--highlight > .tracker-node-line {
animation: tracker-node-flash 3s ease-out;
}