Files
paliad/frontend/src/client/builder.ts
mAi 264cc39a6b
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
chore(builder): B6 — mobile basic-read + dead U0-U4 cleanup + i18n finalise (t-paliad-350)
Litigation Builder slice B6 (m/paliad#153 PRD §7.1 + §7.4 + §10) — last
slice of the train; the Builder is now complete.

Mobile basic-read (<640px, PRD §10):
- builder.ts wireMobileGuard — a capture-phase document click listener that,
  only when matchMedia("(max-width:640px)") matches, intercepts taps on
  mutating affordances (rename/share/promote/new-scenario/add-proceeding +
  every in-triplet button/input/select), preventDefault+stopPropagation,
  and surfaces a "Auf größerem Bildschirm öffnen" toast. Desktop code paths
  are untouched (guard early-returns off-mobile). Column-triplets already
  collapse to a single column via the reused .fr-columns-view @640px rule;
  reading (open/switch scenarios, search, mode tabs) stays fully functional.
- global.css — .builder-mobile-toast + full-bleed modal on phones.

Dead U0-U4 catalog cleanup (PRD §7.4) — deleted, no remaining references
(grep "from.*fristenrechner-|from.*verfahrensablauf" shows only the kept
verfahrensablauf-core + verfahrensablauf-detail-mode the builder reuses):
- client/fristenrechner-mode-a.ts, fristenrechner-result.ts(+test),
  fristenrechner-wizard.ts(+test)
- client/verfahrensablauf.ts, client/views/event-card-choices.ts,
  client/views/verfahrensablauf-state.ts(+test)
- components/VerfahrensablaufBody.tsx
(/tools/fristenrechner + /tools/verfahrensablauf stay as 301 redirects to
/tools/procedures — handlers already redirectToProcedures.)

i18n finalised (DE+EN): removed 4 duplicate deadlines.party.* keys per
block (behaviour-preserving — the later, winning copy is kept) and added
the missing DE "cal.today". Codegen clean, no dupes, full DE/EN parity.

go build/vet clean; bun build + 227 frontend tests pass. Playwright-
verified: at 375px the triplet collapses to one column + the scenario
list reads, while "+ Verfahren hinzufügen" and "Teilen" are blocked
(toast shown, no action); at 1280px the same actions work normally.
2026-05-29 20:44:40 +02:00

1572 lines
60 KiB
TypeScript

// Litigation Builder client (m/paliad#153 PRD §3, B1).
//
// Boots /tools/procedures. Talks to the B0 surface
// (/api/builder/scenarios/*) for persistence and reuses
// verfahrensablauf-core for the per-triplet calc + 3-column render.
//
// B1 ships:
// - Cold-open empty canvas + "Neues Szenario starten" CTA + recent list.
// - Scenario picker, name action, Stichtag, auto-save (500ms debounce).
// - Add-proceeding picker (Forum chip row → Verfahren chip row → Hinzufügen).
// - Single triplet renders end-to-end with calc.
// - Side panel "Meine Szenarien" with Aktiv bucket.
//
// B2 extends:
// - Multi-triplet stack with `+ Verfahren hinzufügen`.
// - Per-triplet perspective + flag strip.
// - Spawn child triplets render inline.
// - 3-state event cards (planned/filed/skipped) + per-card optional horizon.
import { t, getLang } from "./i18n";
import {
calculateDeadlines,
renderColumnsBody,
type DeadlineResponse,
type Side,
} from "./views/verfahrensablauf-core";
import { filterByDetailMode, type DetailMode } from "./verfahrensablauf-detail-mode";
import { mountAddProceedingPicker, type ProceedingTypeMeta } from "./builder-picker";
import { overlayEventStates, renderTriplet, type ScenarioFlagCatalogEntry } from "./builder-triplet";
import {
mountBuilderSearch,
type EventSearchHit,
type ScenarioSearchHit,
type ProjectSearchHit,
} from "./builder-search";
import {
mountAktePicker,
createScenarioFromProject,
renderAkteBanner,
type AktePickerHandle,
} from "./builder-akte";
import {
onScenarioFlagsChanged,
SCENARIO_FLAG_CHANGED_EVENT,
type ScenarioFlagChangedDetail,
} from "./scenario-flags";
import { openShareModal, type BuilderShareRow } from "./builder-shares";
import { openPromoteWizard } from "./builder-promote";
// Spawn map (PRD §3.6). When a scenario_flag transitions OFF → ON on a
// parent proceeding, the builder auto-creates a child proceeding row
// linked via parent_scenario_proceeding_id. When the flag flips back
// OFF, the child is deleted (its events cascade via the schema's ON
// DELETE CASCADE). Today's data has 2-deep nesting at most, with
// `with_ccr` on `upc.inf.cfi` as the load-bearing case; the map is
// data-driven so future flags slot in by adding rows.
//
// Entries are picked up by syncSpawnChildren after each successful
// flag PATCH. Falsy entries are simple flag-only flips with no spawn
// effect.
const SPAWN_MAP: Record<string, Record<string, string>> = {
// Parent proceeding code → flag_key → child proceeding code.
"upc.inf.cfi": { with_ccr: "upc.ccr.cfi" },
};
// ────────────────────────────────────────────────────────────────────────────
// Wire types — mirror internal/services/scenario_builder_service.go
// ────────────────────────────────────────────────────────────────────────────
export interface BuilderScenario {
id: string;
owner_id?: string;
name: string;
status: "active" | "archived" | "promoted";
origin_project_id?: string;
promoted_project_id?: string;
stichtag?: string;
notes?: string;
created_at: string;
updated_at: string;
}
export interface BuilderProceeding {
id: string;
scenario_id: string;
proceeding_type_id: number;
primary_party?: "claimant" | "defendant";
scenario_flags: Record<string, unknown>;
parent_scenario_proceeding_id?: string;
spawn_anchor_event_id?: string;
ordinal: number;
stichtag?: string;
detailgrad: "selected" | "all_options";
appeal_target?: string;
collapsed: boolean;
created_at: string;
updated_at: string;
}
export interface BuilderEvent {
id: string;
scenario_proceeding_id: string;
sequencing_rule_id?: string;
procedural_event_id?: string;
custom_label?: string;
state: "planned" | "filed" | "skipped";
actual_date?: string;
skip_reason?: string;
notes?: string;
horizon_optional: number;
created_at: string;
updated_at: string;
}
export interface BuilderScenarioDeep extends BuilderScenario {
proceedings: BuilderProceeding[];
events: BuilderEvent[];
shares: unknown[];
}
// ────────────────────────────────────────────────────────────────────────────
// Module state — single active scenario per tab.
// ────────────────────────────────────────────────────────────────────────────
type EntryMode = "cold" | "event" | "akte";
interface State {
active: BuilderScenarioDeep | null;
list: BuilderScenario[];
// B5 — scenarios shared read-only with me (the "Geteilt mit mir"
// bucket). Disjoint from `list` (which is owner-scoped). readonly is
// true when the active scenario is one of these OR is promoted —
// either way every mutating affordance is disabled + a watermark shows.
shared: BuilderScenario[];
readonly: boolean;
// owner display-name cache for the read-only watermark.
ownerNameById: Map<string, string>;
procTypes: ProceedingTypeMeta[];
procTypesById: Map<number, ProceedingTypeMeta>;
procTypesByCode: Map<string, ProceedingTypeMeta>;
flagCatalog: ScenarioFlagCatalogEntry[];
saveTimer: number | null;
// Pending field-level deltas merged before each PATCH flush. Avoids
// racing PATCHes overwriting each other when the user changes more
// than one field inside a 500ms window.
pending: { name?: string; stichtag?: string; notes?: string };
// B3: entry mode + anchor highlight. PRD §2.2 — when the user picks
// an event from universal search, the canvas renders one triplet
// with the picked rule highlighted (lime band + "DU BIST HIER"
// divider). anchorRuleID survives across re-renders within a single
// mode session; switching mode away from "event" clears it.
mode: EntryMode;
anchorRuleID: string | null;
searchCtl: { focus: () => void; close: () => void } | null;
// B4 — Akte-mode picker handle. Null until the page mounts; once
// mountAktePicker resolves, the handle lets us keep the picker
// selection reflective of the active scenario (origin_project_id).
aktePicker: AktePickerHandle | null;
}
const state: State = {
active: null,
list: [],
shared: [],
readonly: false,
ownerNameById: new Map(),
procTypes: [],
procTypesById: new Map(),
procTypesByCode: new Map(),
flagCatalog: [],
saveTimer: null,
pending: {},
mode: "cold",
anchorRuleID: null,
searchCtl: null,
aktePicker: null,
};
// ────────────────────────────────────────────────────────────────────────────
// Fetch helpers
// ────────────────────────────────────────────────────────────────────────────
async function fetchJSON<T>(input: RequestInfo, init?: RequestInit): Promise<T | null> {
try {
const resp = await fetch(input, init);
if (!resp.ok) {
const body = await resp.text().catch(() => "");
console.error("builder fetch error:", resp.status, input, body);
return null;
}
if (resp.status === 204) return null;
return (await resp.json()) as T;
} catch (err) {
console.error("builder network error:", input, err);
return null;
}
}
async function fetchScenarios(): Promise<BuilderScenario[]> {
// B5 — pull every status so the side panel can bucket into Aktiv /
// Promoted / Archiviert. The picker + recent list filter to active.
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios?status=all");
return Array.isArray(out) ? out : [];
}
async function fetchSharedScenarios(): Promise<BuilderScenario[]> {
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios/shared");
return Array.isArray(out) ? out : [];
}
// fetchOwnerNames lazily loads the user directory once so the read-only
// watermark can render "Geteilt von <Name>". Failures degrade to showing
// the owner uuid; the watermark is informational, not load-bearing.
async function ensureOwnerNames(): Promise<void> {
if (state.ownerNameById.size > 0) return;
const users = await fetchJSON<Array<{ id: string; display_name?: string; email: string }>>("/api/users");
if (!Array.isArray(users)) return;
for (const u of users) {
state.ownerNameById.set(u.id, (u.display_name || "").trim() || u.email);
}
}
async function fetchScenarioDeep(id: string): Promise<BuilderScenarioDeep | null> {
return await fetchJSON<BuilderScenarioDeep>("/api/builder/scenarios/" + encodeURIComponent(id));
}
async function fetchProceedingTypes(): Promise<ProceedingTypeMeta[]> {
// PRD v1 is UPC-only; later jurisdictions plug into the same picker
// shape (Forum chip row gates the Verfahren chip row).
const out = await fetchJSON<ProceedingTypeMeta[]>(
"/api/tools/proceeding-types?jurisdiction=UPC&kind=proceeding",
);
return Array.isArray(out) ? out : [];
}
async function fetchFlagCatalog(): Promise<ScenarioFlagCatalogEntry[]> {
const out = await fetchJSON<ScenarioFlagCatalogEntry[]>("/api/builder/scenario-flag-catalog");
return Array.isArray(out) ? out : [];
}
async function createScenario(name?: string): Promise<BuilderScenario | null> {
const body: Record<string, unknown> = {};
if (name) body.name = name;
return await fetchJSON<BuilderScenario>("/api/builder/scenarios", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
async function patchScenario(id: string, body: Record<string, unknown>): Promise<BuilderScenario | null> {
return await fetchJSON<BuilderScenario>("/api/builder/scenarios/" + encodeURIComponent(id), {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
}
async function addProceeding(
scenarioID: string,
body: {
proceeding_type_id: number;
primary_party?: string;
parent_scenario_proceeding_id?: string;
spawn_anchor_event_id?: string;
},
): Promise<BuilderProceeding | null> {
return await fetchJSON<BuilderProceeding>(
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) + "/proceedings",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
}
async function patchProceeding(
scenarioID: string,
proceedingID: string,
body: Record<string, unknown>,
): Promise<BuilderProceeding | null> {
return await fetchJSON<BuilderProceeding>(
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
"/proceedings/" + encodeURIComponent(proceedingID),
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
}
async function deleteProceeding(scenarioID: string, proceedingID: string): Promise<boolean> {
try {
const resp = await fetch(
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
"/proceedings/" + encodeURIComponent(proceedingID),
{ method: "DELETE" },
);
return resp.ok;
} catch (err) {
console.error("builder delete proceeding error:", err);
return false;
}
}
async function addEvent(
scenarioID: string,
proceedingID: string,
body: {
sequencing_rule_id?: string;
state?: string;
actual_date?: string;
skip_reason?: string;
horizon_optional?: number;
},
): Promise<BuilderEvent | null> {
return await fetchJSON<BuilderEvent>(
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
"/proceedings/" + encodeURIComponent(proceedingID) + "/events",
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
}
async function patchEvent(
scenarioID: string,
eventID: string,
body: Record<string, unknown>,
): Promise<BuilderEvent | null> {
return await fetchJSON<BuilderEvent>(
"/api/builder/scenarios/" + encodeURIComponent(scenarioID) +
"/events/" + encodeURIComponent(eventID),
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
},
);
}
// ────────────────────────────────────────────────────────────────────────────
// URL state
// ────────────────────────────────────────────────────────────────────────────
function readScenarioFromUrl(): string | null {
return new URLSearchParams(window.location.search).get("scenario");
}
function writeScenarioToUrl(id: string | null): void {
const url = new URL(window.location.href);
if (id) url.searchParams.set("scenario", id);
else url.searchParams.delete("scenario");
history.replaceState(null, "", url.pathname + url.search + url.hash);
}
// ────────────────────────────────────────────────────────────────────────────
// Save indicator
// ────────────────────────────────────────────────────────────────────────────
type SaveState = "idle" | "saving" | "saved" | "error";
function setSaveState(s: SaveState): void {
const el = document.getElementById("builder-save-status");
if (!el) return;
el.setAttribute("data-state", s);
const span = el.querySelector("span");
if (!span) return;
const text =
s === "saving" ? t("builder.save.saving") :
s === "saved" ? t("builder.save.saved") :
s === "error" ? t("builder.save.error") :
t("builder.save.idle");
const key =
s === "saving" ? "builder.save.saving" :
s === "saved" ? "builder.save.saved" :
s === "error" ? "builder.save.error" :
"builder.save.idle";
span.setAttribute("data-i18n", key);
span.textContent = text;
}
// ────────────────────────────────────────────────────────────────────────────
// Auto-save (500ms debounce per PRD §4.2 + §10).
// ────────────────────────────────────────────────────────────────────────────
function scheduleAutoSave(): void {
if (!state.active) return;
setSaveState("saving");
if (state.saveTimer !== null) {
window.clearTimeout(state.saveTimer);
}
state.saveTimer = window.setTimeout(() => {
void flushAutoSave();
}, 500);
}
async function flushAutoSave(): Promise<void> {
state.saveTimer = null;
if (!state.active) return;
const body = { ...state.pending };
state.pending = {};
if (Object.keys(body).length === 0) {
setSaveState("saved");
return;
}
const updated = await patchScenario(state.active.id, body);
if (!updated) {
setSaveState("error");
return;
}
state.active.name = updated.name;
state.active.status = updated.status;
state.active.stichtag = updated.stichtag;
state.active.notes = updated.notes;
state.active.updated_at = updated.updated_at;
setSaveState("saved");
// Refresh the side panel so the just-saved scenario floats to top.
await refreshScenarioList();
}
// ────────────────────────────────────────────────────────────────────────────
// Side panel + dropdown
// ────────────────────────────────────────────────────────────────────────────
async function refreshScenarioList(): Promise<void> {
// Owned (all statuses) + shared-with-me run in parallel.
const [owned, shared] = await Promise.all([fetchScenarios(), fetchSharedScenarios()]);
state.list = owned;
state.shared = shared;
renderScenarioList();
renderScenarioPicker();
}
// renderBucket paints one side-panel bucket UL + toggles its wrapper's
// hidden attribute when empty. The Aktiv bucket always renders (shows the
// empty hint); the others hide when they have no rows.
function renderBucket(listId: string, wrapId: string | null, scenarios: BuilderScenario[], alwaysShow: boolean): void {
const ul = document.getElementById(listId);
if (!ul) return;
if (wrapId) {
const wrap = document.getElementById(wrapId);
if (wrap) wrap.hidden = !alwaysShow && scenarios.length === 0;
}
if (scenarios.length === 0) {
ul.innerHTML = alwaysShow
? `<li class="builder-scenario-list-empty" data-i18n="builder.panel.empty">Noch keine Szenarien.</li>`
: "";
return;
}
const activeId = state.active?.id;
ul.innerHTML = scenarios.map((sc) => {
const isActive = sc.id === activeId;
return (
`<li class="builder-scenario-list-item${isActive ? " is-active" : ""}"` +
` data-scenario-id="${escAttr(sc.id)}">` +
`<button type="button" class="builder-scenario-list-link">` +
`<span class="builder-scenario-list-name">${escHtml(sc.name)}</span>` +
`</button></li>`
);
}).join("");
ul.querySelectorAll<HTMLElement>(".builder-scenario-list-item").forEach((li) => {
const id = li.getAttribute("data-scenario-id");
if (!id) return;
li.addEventListener("click", () => {
void loadScenario(id);
});
});
}
function renderScenarioList(): void {
renderBucket("builder-scenario-list-active", null,
state.list.filter((s) => s.status === "active"), true);
renderBucket("builder-scenario-list-shared", "builder-bucket-shared", state.shared, false);
renderBucket("builder-scenario-list-promoted", "builder-bucket-promoted",
state.list.filter((s) => s.status === "promoted"), false);
renderBucket("builder-scenario-list-archived", "builder-bucket-archived",
state.list.filter((s) => s.status === "archived"), false);
}
function renderScenarioPicker(): void {
const sel = document.getElementById("builder-scenario-picker") as HTMLSelectElement | null;
if (!sel) return;
const placeholderText = t("builder.picker.placeholder");
const opts: string[] = [`<option value="">${escHtml(placeholderText)}</option>`];
// Picker shows openable scenarios: active owned + shared-with-me.
const pickable = [
...state.list.filter((s) => s.status === "active"),
...state.shared,
];
// Ensure the currently-active scenario is selectable even if promoted/
// archived (so the dropdown reflects reality when one is open).
if (state.active && !pickable.some((s) => s.id === state.active!.id)) {
pickable.unshift(state.active);
}
for (const sc of pickable) {
const selected = sc.id === state.active?.id ? " selected" : "";
opts.push(`<option value="${escAttr(sc.id)}"${selected}>${escHtml(sc.name)}</option>`);
}
sel.innerHTML = opts.join("");
}
// ────────────────────────────────────────────────────────────────────────────
// Canvas rendering
// ────────────────────────────────────────────────────────────────────────────
function showEmpty(): void {
const canvas = document.getElementById("builder-canvas");
if (!canvas) return;
canvas.innerHTML = "";
const empty = document.createElement("div");
empty.id = "builder-empty";
empty.className = "builder-empty";
empty.innerHTML = `
<p class="builder-empty-headline">${escHtml(t("builder.empty.headline"))}</p>
<p class="builder-empty-hint">${escHtml(t("builder.empty.hint"))}</p>
<button type="button" id="builder-cta-new" class="builder-cta-new">
${escHtml(t("builder.empty.cta"))}
</button>
${renderRecentList()}
`;
canvas.appendChild(empty);
document.getElementById("builder-cta-new")?.addEventListener("click", () => {
void onNewScenarioClick();
});
empty.querySelectorAll<HTMLElement>(".builder-recent-item").forEach((li) => {
const id = li.getAttribute("data-scenario-id");
if (!id) return;
li.addEventListener("click", () => {
void loadScenario(id);
});
});
}
function renderRecentList(): string {
if (state.list.length === 0) return "";
const recent = state.list.slice(0, 5);
const items = recent.map((sc) => (
`<li class="builder-recent-item" data-scenario-id="${escAttr(sc.id)}">` +
`<span class="builder-recent-name">${escHtml(sc.name)}</span>` +
`</li>`
)).join("");
return (
`<div class="builder-recent">` +
`<h3 class="builder-recent-title">${escHtml(t("builder.empty.recent"))}</h3>` +
`<ul class="builder-recent-list">${items}</ul>` +
`</div>`
);
}
function renderCanvas(): void {
if (!state.active) {
showEmpty();
return;
}
const canvas = document.getElementById("builder-canvas");
if (!canvas) return;
canvas.innerHTML = "";
// Top-level proceedings sorted by ordinal (parent_scenario_proceeding_id IS NULL).
const topLevel = state.active.proceedings
.filter((p) => !p.parent_scenario_proceeding_id)
.sort((a, b) => a.ordinal - b.ordinal);
for (const proc of topLevel) {
renderProceedingTripletInto(canvas, proc, /*isChild*/ false);
// Inline child triplets (spawn nesting). PRD §3.6.
const children = state.active.proceedings
.filter((p) => p.parent_scenario_proceeding_id === proc.id)
.sort((a, b) => a.ordinal - b.ordinal);
for (const child of children) {
renderProceedingTripletInto(canvas, child, /*isChild*/ true);
}
}
// Add-proceeding affordance always at the bottom of the stack.
const addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.className = "builder-add-proceeding-btn";
addBtn.id = "builder-add-proceeding-btn";
addBtn.textContent = t("builder.canvas.add_proceeding");
addBtn.addEventListener("click", () => {
openAddProceedingPicker(addBtn);
});
canvas.appendChild(addBtn);
}
function renderProceedingTripletInto(
canvas: HTMLElement,
proc: BuilderProceeding,
isChild: boolean,
): void {
const host = document.createElement("article");
host.className = "builder-triplet-host";
host.setAttribute("data-proceeding-id", proc.id);
if (isChild) host.setAttribute("data-child", "true");
canvas.appendChild(host);
void hydrateTriplet(proc, host, isChild);
}
async function hydrateTriplet(
proc: BuilderProceeding,
host: HTMLElement,
isChild: boolean,
): Promise<void> {
const meta = state.procTypesById.get(proc.proceeding_type_id);
if (!meta) {
host.innerHTML = `<div class="builder-triplet-error">${escHtml(
t("builder.triplet.unknown_proceeding"),
)}</div>`;
return;
}
const stichtag = proc.stichtag || state.active?.stichtag || todayISO();
// t-paliad-348 — port engine semantics to the Builder triplet calc:
// - `includeOptional` follows the proceeding's detailgrad. The
// "selected" default suppresses optional rules server-side
// (engine drops them); "all_options" opts in so the dimmed
// optional cards can be rendered for the lawyer to opt into.
// - `filterByDetailMode` then runs client-side over what the
// engine emitted, dropping isConditional rows (rules whose
// `trigger_event_id` anchor wasn't supplied) when the lawyer
// is on "selected"/"mandatory_only" — those rules belong to the
// "naked proceeding with options but not always displayed"
// mental model and shouldn't pollute the backbone view.
const detailgrad: DetailMode = (proc.detailgrad as DetailMode) || "selected";
const data: DeadlineResponse | null = await calculateDeadlines({
proceedingType: meta.code,
triggerDate: stichtag,
flags: scenarioFlagsToArray(proc.scenario_flags),
includeOptional: detailgrad === "all_options",
});
const side: Side = (proc.primary_party as Side) || null;
const eventsByRule = buildEventsByRule(proc.id);
const scenarioFlagsBool = scenarioFlagsToBoolMap(proc.scenario_flags);
const filteredData: DeadlineResponse | null = data
? { ...data, deadlines: filterByDetailMode(data.deadlines, detailgrad, scenarioFlagsBool) }
: null;
const columnsHtml = filteredData
? renderColumnsBody(filteredData, { editable: false, side, showDurations: false })
: "";
host.innerHTML = renderTriplet({
proceeding: proc,
meta,
data,
side,
flagCatalog: state.flagCatalog,
eventsByRule,
columnsHtml,
isChild,
});
wireTripletInteractions(host, proc, meta);
overlayEventStates(host, eventsByRule, {
onAction: (ruleId, action, payload) => {
void onEventAction(proc, ruleId, action, payload);
},
onHorizon: (ruleId, delta) => {
void onEventHorizon(proc, ruleId, delta);
},
});
// B3 — apply the event-anchor highlight + "DU BIST HIER" divider on
// the matching card. Only fires when the active mode is "event" and
// an anchor rule was set by the search-pick flow.
if (state.mode === "event" && state.anchorRuleID) {
applyAnchorHighlight(host, state.anchorRuleID);
}
}
// applyAnchorHighlight spotlights the picked event card on a freshly
// rendered triplet. PRD §2.2: lime band on the card + a horizontal
// "━━ DU BIST HIER ━━" divider above the next-coming events. The
// divider spans all 3 columns of the grid via grid-column: 1 / -1.
function applyAnchorHighlight(host: HTMLElement, ruleId: string): void {
const ruleKey = ruleId.toLowerCase();
// Attribute-value comparison via JS rather than a CSS selector so
// either-cased UUIDs from the server still match.
const anchorCard = Array.from(
host.querySelectorAll<HTMLElement>(".fr-col-item[data-rule-id]"),
).find((el) => (el.getAttribute("data-rule-id") || "").toLowerCase() === ruleKey);
if (!anchorCard) return;
anchorCard.classList.add("builder-anchor-card");
// Locate the anchor's row in the .fr-columns-view grid. The grid
// is row-major with 3 cells per row after the 3 column headers
// (ours/court/opponent). The cell containing the anchor card is
// the closest .fr-col-cell ancestor; once we know its index in the
// grid's cell list, the row index is floor((idx - 3) / 3) (the
// -3 accounts for the header row).
const cell = anchorCard.closest<HTMLElement>(".fr-col-cell");
if (!cell) return;
const grid = cell.parentElement;
if (!grid || !grid.classList.contains("fr-columns-view")) return;
const cells = Array.from(grid.children) as HTMLElement[];
const idx = cells.indexOf(cell);
if (idx < 0) return;
// Insertion point: after the last cell of the anchor's row. The
// grid renders cells in row-major order; we step forward from the
// anchor cell to the end of the current row (2 cells past, modulo
// header row offset). A guarded fall-through inserts at the end of
// the grid if positions don't line up cleanly.
// Header row contributes 3 cells; body rows are 3 cells each.
const headerCells = 3;
if (idx < headerCells) return; // anchor on a header? shouldn't happen, no-op.
const bodyIdx = idx - headerCells;
const rowStart = headerCells + Math.floor(bodyIdx / 3) * 3;
const rowEnd = rowStart + 2; // inclusive
const after = cells[rowEnd] || cells[cells.length - 1];
const divider = document.createElement("div");
divider.className = "builder-anchor-divider";
divider.textContent = t("builder.search.anchor.divider");
divider.setAttribute("aria-hidden", "true");
after.insertAdjacentElement("afterend", divider);
}
function buildEventsByRule(proceedingID: string): Map<string, BuilderEvent> {
const out = new Map<string, BuilderEvent>();
if (!state.active) return out;
for (const ev of state.active.events) {
if (ev.scenario_proceeding_id !== proceedingID) continue;
if (!ev.sequencing_rule_id) continue;
out.set(ev.sequencing_rule_id.toLowerCase(), ev);
}
return out;
}
function wireTripletInteractions(
host: HTMLElement,
proc: BuilderProceeding,
meta: ProceedingTypeMeta,
): void {
// Perspective radio
host.querySelectorAll<HTMLElement>("[data-action='perspective']").forEach((btn) => {
btn.addEventListener("click", () => {
const value = btn.getAttribute("data-value") || "";
void onPerspectiveChange(proc, value);
});
});
// Detailgrad toggle
host.querySelectorAll<HTMLElement>("[data-action='detailgrad']").forEach((btn) => {
btn.addEventListener("click", () => {
const value = btn.getAttribute("data-value") || "selected";
void onDetailgradChange(proc, value);
});
});
// Remove
host.querySelectorAll<HTMLElement>("[data-action='remove']").forEach((btn) => {
btn.addEventListener("click", () => {
void onRemoveProceeding(proc);
});
});
// Flag checkboxes
host.querySelectorAll<HTMLInputElement>("[data-action='flag']").forEach((box) => {
box.addEventListener("change", () => {
const key = box.getAttribute("data-flag-key");
if (!key) return;
void onFlagChange(proc, meta, key, box.checked);
});
});
}
async function onPerspectiveChange(proc: BuilderProceeding, value: string): Promise<void> {
if (!state.active) return;
const updated = await patchProceeding(state.active.id, proc.id, {
primary_party: value,
});
if (!updated) {
setSaveState("error");
return;
}
applyProceedingPatch(updated);
setSaveState("saved");
renderCanvas();
}
async function onDetailgradChange(proc: BuilderProceeding, value: string): Promise<void> {
if (!state.active) return;
const updated = await patchProceeding(state.active.id, proc.id, {
detailgrad: value,
});
if (!updated) {
setSaveState("error");
return;
}
applyProceedingPatch(updated);
setSaveState("saved");
renderCanvas();
}
async function onRemoveProceeding(proc: BuilderProceeding): Promise<void> {
if (!state.active) return;
const confirmed = window.confirm(t("builder.triplet.remove") + " — " + state.procTypesById.get(proc.proceeding_type_id)?.code);
if (!confirmed) return;
const ok = await deleteProceeding(state.active.id, proc.id);
if (!ok) {
setSaveState("error");
return;
}
// Cascade in-memory: drop the proceeding + its child proceedings + events.
state.active.proceedings = state.active.proceedings.filter(
(p) => p.id !== proc.id && p.parent_scenario_proceeding_id !== proc.id,
);
state.active.events = state.active.events.filter(
(e) => state.active!.proceedings.some((p) => p.id === e.scenario_proceeding_id),
);
setSaveState("saved");
renderCanvas();
}
async function onFlagChange(
proc: BuilderProceeding,
meta: ProceedingTypeMeta,
flagKey: string,
enabled: boolean,
): Promise<void> {
if (!state.active) return;
const newFlags: Record<string, unknown> = { ...proc.scenario_flags, [flagKey]: enabled };
const updated = await patchProceeding(state.active.id, proc.id, {
scenario_flags: newFlags,
});
if (!updated) {
setSaveState("error");
return;
}
applyProceedingPatch(updated);
// Spawn handling — flip the child triplet in or out based on the
// SPAWN_MAP entry for this parent.
const spawnChildCode = SPAWN_MAP[meta.code]?.[flagKey];
if (spawnChildCode) {
await syncSpawnChild(updated, spawnChildCode, enabled);
}
// B4 — Akte-backed flag toggles dual-write to projects.scenario_flags
// on the server. Mirror the cross-surface CustomEvent here so peer
// surfaces (Verfahrensablauf strip, project Verlauf, dashboard
// panes) listening via `scenario-flags.ts` re-sync without a fresh
// GET. The PATCH /api/builder/.../proceedings hop bypasses
// patchScenarioFlags (which is what normally dispatches), so we
// recreate the event here when the active scenario is project-
// backed AND the patched triplet is top-level.
if (
state.active.origin_project_id &&
!updated.parent_scenario_proceeding_id
) {
const detail: ScenarioFlagChangedDetail = {
projectId: state.active.origin_project_id,
flags: builderFlagsToBoolMap(updated.scenario_flags),
changedKeys: [flagKey],
};
document.dispatchEvent(new CustomEvent(SCENARIO_FLAG_CHANGED_EVENT, { detail }));
}
setSaveState("saved");
renderCanvas();
}
// builderFlagsToBoolMap converts the builder's loose
// Record<string, unknown> (the wire shape of scenario_proceedings.
// scenario_flags) into the strict Record<string, boolean> shape peer
// surfaces expect. Non-bool values are dropped — defensive parsing
// for the same reason the server's flagDeltaFromBuilder skips them.
function builderFlagsToBoolMap(flags: Record<string, unknown>): Record<string, boolean> {
const out: Record<string, boolean> = {};
for (const [k, v] of Object.entries(flags)) {
if (typeof v === "boolean") out[k] = v;
}
return out;
}
async function syncSpawnChild(
parent: BuilderProceeding,
childCode: string,
enabled: boolean,
): Promise<void> {
if (!state.active) return;
const existing = state.active.proceedings.find(
(p) => p.parent_scenario_proceeding_id === parent.id,
);
if (enabled && !existing) {
const childMeta = state.procTypesByCode.get(childCode);
if (!childMeta) return;
const child = await addProceeding(state.active.id, {
proceeding_type_id: childMeta.id,
parent_scenario_proceeding_id: parent.id,
});
if (child) state.active.proceedings.push(child);
} else if (!enabled && existing) {
const ok = await deleteProceeding(state.active.id, existing.id);
if (ok) {
state.active.proceedings = state.active.proceedings.filter((p) => p.id !== existing.id);
state.active.events = state.active.events.filter(
(e) => e.scenario_proceeding_id !== existing.id,
);
}
}
}
function applyProceedingPatch(updated: BuilderProceeding): void {
if (!state.active) return;
const idx = state.active.proceedings.findIndex((p) => p.id === updated.id);
if (idx >= 0) state.active.proceedings[idx] = updated;
}
async function onEventAction(
proc: BuilderProceeding,
ruleId: string,
action: "file" | "skip" | "reset",
payload?: { date?: string; reason?: string },
): Promise<void> {
if (!state.active) return;
const ruleKey = ruleId.toLowerCase();
const existing = state.active.events.find(
(e) => e.scenario_proceeding_id === proc.id &&
e.sequencing_rule_id?.toLowerCase() === ruleKey,
);
if (action === "file") {
const date = payload?.date || todayISO();
if (existing) {
const upd = await patchEvent(state.active.id, existing.id, {
state: "filed",
actual_date: date,
});
if (upd) replaceEvent(upd);
} else {
const ev = await addEvent(state.active.id, proc.id, {
sequencing_rule_id: ruleId,
state: "filed",
actual_date: date,
});
if (ev) state.active.events.push(ev);
}
} else if (action === "skip") {
if (existing) {
const upd = await patchEvent(state.active.id, existing.id, {
state: "skipped",
skip_reason: payload?.reason ?? "",
});
if (upd) replaceEvent(upd);
} else {
const ev = await addEvent(state.active.id, proc.id, {
sequencing_rule_id: ruleId,
state: "skipped",
skip_reason: payload?.reason ?? "",
});
if (ev) state.active.events.push(ev);
}
} else {
// reset → either patch back to planned or delete the row outright.
// PATCH is simpler and keeps any horizon_optional the user had set;
// a separate "clear horizon" affordance handles full removal.
if (existing) {
const upd = await patchEvent(state.active.id, existing.id, { state: "planned" });
if (upd) replaceEvent(upd);
}
}
setSaveState("saved");
renderCanvas();
}
async function onEventHorizon(
proc: BuilderProceeding,
ruleId: string,
delta: 1 | -1,
): Promise<void> {
if (!state.active) return;
const ruleKey = ruleId.toLowerCase();
const existing = state.active.events.find(
(e) => e.scenario_proceeding_id === proc.id &&
e.sequencing_rule_id?.toLowerCase() === ruleKey,
);
if (existing) {
const newHorizon = Math.max(0, existing.horizon_optional + delta);
const upd = await patchEvent(state.active.id, existing.id, {
horizon_optional: newHorizon,
});
if (upd) replaceEvent(upd);
} else if (delta > 0) {
const ev = await addEvent(state.active.id, proc.id, {
sequencing_rule_id: ruleId,
horizon_optional: 1,
state: "planned",
});
if (ev) state.active.events.push(ev);
}
setSaveState("saved");
renderCanvas();
}
function replaceEvent(updated: BuilderEvent): void {
if (!state.active) return;
const idx = state.active.events.findIndex((e) => e.id === updated.id);
if (idx >= 0) state.active.events[idx] = updated;
else state.active.events.push(updated);
}
function scenarioFlagsToArray(flags: Record<string, unknown>): string[] {
// The calc API still consumes the historical flat-flag array form
// (string slug per active flag). Builder scenario_flags is the
// jsonb {flag_name: true|false|null} shape — translate by picking
// every truthy key.
const out: string[] = [];
for (const [k, v] of Object.entries(flags)) {
if (v === true) out.push(k);
}
return out;
}
// scenarioFlagsToBoolMap narrows the jsonb-shape scenario_flags blob
// (`{key: true|false|null|other}`) to the strict `Record<string, boolean>`
// shape filterByDetailMode consumes. The rule:<uuid>=true|false per-rule
// deviation keys flow through verbatim (their truthiness IS the override
// signal isRuleSelected reads).
function scenarioFlagsToBoolMap(flags: Record<string, unknown>): Record<string, boolean> {
const out: Record<string, boolean> = {};
for (const [k, v] of Object.entries(flags)) {
if (typeof v === "boolean") out[k] = v;
}
return out;
}
// ────────────────────────────────────────────────────────────────────────────
// Actions
// ────────────────────────────────────────────────────────────────────────────
async function loadScenario(id: string): Promise<void> {
const deep = await fetchScenarioDeep(id);
if (!deep) {
setSaveState("error");
return;
}
// Defensive: Go's encoding/json serialises a nil slice as `null`, not
// `[]`. The server initialises these arrays today, but normalising on
// the client too means a future regression (or an older deployed
// build) can't crash renderCanvas with `null.filter(...)`.
if (!Array.isArray(deep.proceedings)) deep.proceedings = [];
if (!Array.isArray(deep.events)) deep.events = [];
if (!Array.isArray(deep.shares)) deep.shares = [];
state.active = deep;
state.pending = {};
// B5 — read-only when the scenario is shared with me (I'm not the
// owner) or already promoted (server blocks mutations either way).
const isShared = state.shared.some((s) => s.id === id);
state.readonly = isShared || deep.status === "promoted";
writeScenarioToUrl(id);
setSaveState("saved");
// Sync header inputs to scenario state.
const stichtagInput = document.getElementById("builder-stichtag-input") as HTMLInputElement | null;
if (stichtagInput && deep.stichtag) stichtagInput.value = deep.stichtag.slice(0, 10);
await applyScenarioChrome(deep, isShared);
// B4 — reflect the scenario's Akte link on the page-header picker
// + banner. Project-backed scenarios reveal the source project so
// the user knows the builder writes feed into that Akte; non-Akte
// (cold-open / event-triggered) scenarios reset the picker to
// "— ohne —" + hide the banner.
if (state.aktePicker) {
state.aktePicker.setSelectedProject(deep.origin_project_id ?? null);
}
renderAkteBanner(deep.origin_project_id ?? null);
renderScenarioPicker();
renderScenarioList();
renderCanvas();
}
async function onNewScenarioClick(): Promise<void> {
// Scratch scenario per PRD §2.1 — anonymous until "Benennen". Server
// applies the default name "Unbenanntes Szenario".
const sc = await createScenario();
if (!sc) {
setSaveState("error");
return;
}
state.list.unshift(sc);
await loadScenario(sc.id);
// Open the add-proceeding picker so the user lands on the next action.
const btn = document.getElementById("builder-add-proceeding-btn") as HTMLElement | null;
if (btn) openAddProceedingPicker(btn);
}
function openAddProceedingPicker(anchor: HTMLElement): void {
if (!state.active) return;
mountAddProceedingPicker(anchor, state.procTypes, async (meta) => {
if (!state.active) return;
// Guard against a wire-shape regression: if the proceeding-types
// endpoint stops returning `id`, `meta.id` is undefined and
// JSON.stringify silently drops the field, the server rejects the
// POST with a 400, and fetchJSON swallows the error — the user
// sees "nothing happens" (t-paliad-345). Fail loud instead.
if (typeof meta.id !== "number" || meta.id <= 0) {
console.error("builder: missing proceeding_type id in picker meta", meta);
setSaveState("error");
return;
}
const proc = await addProceeding(state.active.id, {
proceeding_type_id: meta.id,
});
if (!proc) {
setSaveState("error");
return;
}
state.active.proceedings.push(proc);
setSaveState("saved");
renderCanvas();
});
}
// applyScenarioChrome sets the page-header action buttons + read-only
// watermark + body class for the freshly-loaded scenario. Editable
// scenarios get rename / share / promote enabled; read-only ones (shared
// with me, or promoted) lock all three and show the watermark. The body
// class drives the CSS that neutralises in-canvas mutating affordances.
async function applyScenarioChrome(deep: BuilderScenarioDeep, isShared: boolean): Promise<void> {
document.body.classList.toggle("builder-readonly", state.readonly);
const rename = document.getElementById("builder-rename-btn") as HTMLButtonElement | null;
const share = document.getElementById("builder-share-btn") as HTMLButtonElement | null;
const promote = document.getElementById("builder-promote-btn") as HTMLButtonElement | null;
if (rename) rename.disabled = state.readonly;
if (share) share.disabled = state.readonly;
if (promote) promote.disabled = state.readonly;
const wm = document.getElementById("builder-readonly-watermark");
if (!wm) return;
if (!state.readonly) {
wm.hidden = true;
wm.textContent = "";
return;
}
if (isShared) {
await ensureOwnerNames();
const owner = (deep.owner_id && state.ownerNameById.get(deep.owner_id)) || deep.owner_id || "?";
wm.textContent = t("builder.readonly.watermark").replace("{owner}", owner);
} else {
// Promoted (owned) scenario — read-only reference.
wm.textContent = t("builder.bucket.promoted");
}
wm.hidden = false;
}
// resetScenarioChrome clears the page-header action state + watermark
// when no scenario is active (cold-open / picker cleared).
function resetScenarioChrome(): void {
document.body.classList.remove("builder-readonly");
for (const id of ["builder-rename-btn", "builder-share-btn", "builder-promote-btn"]) {
const b = document.getElementById(id) as HTMLButtonElement | null;
if (b) b.disabled = true;
}
const wm = document.getElementById("builder-readonly-watermark");
if (wm) {
wm.hidden = true;
wm.textContent = "";
}
}
// onShareClick opens the share modal for the active (owned, editable)
// scenario. PRD §2.5.
function onShareClick(): void {
if (!state.active || state.readonly) return;
void openShareModal({
scenarioId: state.active.id,
ownerId: state.active.owner_id,
currentShares: (state.active.shares as BuilderShareRow[]) ?? [],
onChanged: (shares) => {
if (state.active) state.active.shares = shares;
},
});
}
// onPromoteClick gathers the summary numbers for wizard step 1 and opens
// the promote-to-project wizard. PRD §2.4. The primary proceeding (lowest-
// ordinal top-level) + its spawned descendants are what the server
// promotes into one case file; additional standalone proceedings are
// reported in the summary as staying behind.
function onPromoteClick(): void {
if (!state.active || state.readonly) return;
const sc = state.active;
const topLevel = sc.proceedings
.filter((p) => !p.parent_scenario_proceeding_id)
.sort((a, b) => a.ordinal - b.ordinal);
const primary = topLevel[0];
if (!primary) {
setSaveState("error");
return;
}
// Collect primary + descendants to scope the event counts.
const subtree = new Set<string>([primary.id]);
for (let changed = true; changed; ) {
changed = false;
for (const p of sc.proceedings) {
if (p.parent_scenario_proceeding_id && subtree.has(p.parent_scenario_proceeding_id) && !subtree.has(p.id)) {
subtree.add(p.id);
changed = true;
}
}
}
const evs = sc.events.filter((e) => subtree.has(e.scenario_proceeding_id));
const meta = state.procTypesById.get(primary.proceeding_type_id);
const label = meta ? meta.name || meta.code : "?";
const defaultParty = (primary.primary_party as "claimant" | "defendant" | undefined) ?? null;
void openPromoteWizard({
scenarioId: sc.id,
ownerId: sc.owner_id,
proceedingLabel: label,
filedCount: evs.filter((e) => e.state === "filed").length,
plannedCount: evs.filter((e) => e.state === "planned").length,
flagCount: Object.values(primary.scenario_flags).filter((v) => v === true).length,
extraTopLevel: topLevel.length - 1,
defaultOurSide: defaultParty,
defaultTitle: sc.name && sc.name !== "Unbenanntes Szenario" ? sc.name : "",
onSuccess: (projectId) => {
window.location.href = "/projects/" + encodeURIComponent(projectId);
},
});
}
async function onRenameClick(): Promise<void> {
if (!state.active) return;
const current = state.active.name;
const next = window.prompt(t("builder.action.rename.prompt"), current);
if (next === null) return;
const trimmed = next.trim();
if (!trimmed || trimmed === current) return;
state.pending.name = trimmed;
scheduleAutoSave();
state.active.name = trimmed;
renderScenarioPicker();
renderScenarioList();
}
function onStichtagChange(value: string): void {
if (!state.active) return;
state.active.stichtag = value;
state.pending.stichtag = value;
scheduleAutoSave();
// Re-render: the triplet's calc result depends on stichtag.
renderCanvas();
}
// ────────────────────────────────────────────────────────────────────────────
// Wiring
// ────────────────────────────────────────────────────────────────────────────
// ────────────────────────────────────────────────────────────────────────────
// Entry-mode + universal search (B3)
// ────────────────────────────────────────────────────────────────────────────
function setMode(mode: EntryMode): void {
if (mode === state.mode) return;
// PRD §2.2 step 6: filter pills reset on mode switch. The current
// surface doesn't yet render forum/proc/party/kind chips, so the
// reset is a search-input clear; once the filter strip lands in a
// follow-up slice the chip state attaches here too.
const searchInput = document.getElementById("builder-search-input") as HTMLInputElement | null;
if (searchInput) searchInput.value = "";
state.searchCtl?.close();
state.mode = mode;
state.anchorRuleID = null;
// Reflect the mode on the radio strip.
document.querySelectorAll<HTMLElement>(".builder-mode[data-mode]").forEach((btn) => {
const isActive = btn.getAttribute("data-mode") === mode;
btn.classList.toggle("is-active", isActive);
btn.setAttribute("aria-selected", String(isActive));
});
// On entering Ereignis mode, autofocus the search input per PRD §2.2.
if (mode === "event" && state.searchCtl) {
state.searchCtl.focus();
}
// On leaving event mode, redraw the canvas so any anchor highlights
// are dropped.
if (mode !== "event") {
renderCanvas();
}
}
async function onPickEvent(hit: EventSearchHit): Promise<void> {
// PRD §2.2 — picking an event creates a scratch scenario with one
// triplet anchored on that event's proceeding type, with the event
// card auto-anchored (lime band + DU BIST HIER divider).
const sc = await createScenario();
if (!sc) {
setSaveState("error");
return;
}
state.list.unshift(sc);
// Load the new (empty) scenario, then add the anchored proceeding.
await loadScenario(sc.id);
if (!state.active) return;
state.anchorRuleID = hit.anchor_rule_id;
const proc = await addProceeding(state.active.id, {
proceeding_type_id: hit.proceeding_type.id,
});
if (!proc) {
setSaveState("error");
return;
}
state.active.proceedings.push(proc);
setSaveState("saved");
renderCanvas();
}
async function onPickScenarioFromSearch(hit: ScenarioSearchHit): Promise<void> {
await loadScenario(hit.id);
}
async function onPickProjectFromSearch(hit: ProjectSearchHit): Promise<void> {
// PRD §2.3 — picking a project from universal search puts the
// builder into Akte mode and creates a project-backed scenario.
// Switch the mode chip + sync the page-header Akte picker so the
// user sees the consistent state.
if (state.mode !== "akte") setMode("akte");
await loadAkteScenario(hit.id);
}
// loadAkteScenario is the canonical path that turns a project_id into
// a live builder scenario. POST /api/builder/scenarios/from-project
// returns the new scenario id; we then reuse loadScenario() (which
// fetches the deep payload + sets state.active + paints the canvas).
// Refreshes the scenario list afterwards so the side panel + picker
// pick up the new row.
async function loadAkteScenario(projectId: string): Promise<void> {
setSaveState("saving");
const created = await createScenarioFromProject(projectId);
if (!created) {
setSaveState("error");
return;
}
await refreshScenarioList();
await loadScenario(created.id);
if (state.aktePicker) state.aktePicker.setSelectedProject(projectId);
renderAkteBanner(projectId);
}
function wireModeBar(): void {
document.querySelectorAll<HTMLElement>(".builder-mode[data-mode]").forEach((btn) => {
if (btn.hasAttribute("disabled")) return;
btn.addEventListener("click", () => {
const m = btn.getAttribute("data-mode") as EntryMode | null;
if (m) setMode(m);
});
});
}
function wireSearch(): void {
const input = document.getElementById("builder-search-input") as HTMLInputElement | null;
if (!input) return;
state.searchCtl = mountBuilderSearch(input, {
onPickEvent: (hit) => {
// Picking an event implicitly switches to event mode if not
// already there — keeps the affordance honest when the user
// searches without first clicking "Ereignis".
if (state.mode !== "event") setMode("event");
void onPickEvent(hit);
},
onPickScenario: (hit) => {
void onPickScenarioFromSearch(hit);
},
onPickProject: (hit) => {
onPickProjectFromSearch(hit);
},
});
}
function wirePageHeader(): void {
document.getElementById("builder-rename-btn")?.addEventListener("click", () => {
void onRenameClick();
});
document.getElementById("builder-share-btn")?.addEventListener("click", () => {
onShareClick();
});
document.getElementById("builder-promote-btn")?.addEventListener("click", () => {
onPromoteClick();
});
document.getElementById("builder-new-scenario-btn")?.addEventListener("click", () => {
void onNewScenarioClick();
});
document.getElementById("builder-cta-new")?.addEventListener("click", () => {
void onNewScenarioClick();
});
const picker = document.getElementById("builder-scenario-picker") as HTMLSelectElement | null;
picker?.addEventListener("change", () => {
const id = picker.value;
if (id) void loadScenario(id);
else {
state.active = null;
state.readonly = false;
writeScenarioToUrl(null);
resetScenarioChrome();
renderCanvas();
}
});
const stichtag = document.getElementById("builder-stichtag-input") as HTMLInputElement | null;
stichtag?.addEventListener("change", () => {
onStichtagChange(stichtag.value);
});
}
// wireScenarioFlagsListener keeps the builder in sync with cross-
// surface scenario_flag mutations. When a peer surface (Verfahrensablauf
// strip, project-detail page, project Verlauf) PATCHes
// /api/projects/{id}/scenario-flags, scenario-flags.ts dispatches a
// CustomEvent on document. If the changed project is the active
// scenario's origin_project_id, we refetch the scenario deep payload
// so the builder's top-level triplet picks up the new flags + any
// spawn children re-render. Non-matching events (unrelated project,
// or scenario isn't Akte-backed) are ignored.
function wireScenarioFlagsListener(): void {
onScenarioFlagsChanged(async (detail: ScenarioFlagChangedDetail) => {
const sc = state.active;
if (!sc || !sc.origin_project_id) return;
if (sc.origin_project_id !== detail.projectId) return;
// Avoid feedback loops: the builder's own PATCH (via patchScenario
// Flags inside scenario-flags.ts) fires this event too. We can't
// distinguish our own dispatch from a peer's, but the refetch
// path is idempotent — fetchScenarioDeep returns the same data
// we already have on first paint, and the canvas re-render is
// cheap. The save state stays "saved" because no fields are
// dirty.
const fresh = await fetchScenarioDeep(sc.id);
if (!fresh) return;
if (!Array.isArray(fresh.proceedings)) fresh.proceedings = [];
if (!Array.isArray(fresh.events)) fresh.events = [];
if (!Array.isArray(fresh.shares)) fresh.shares = [];
state.active = fresh;
renderCanvas();
});
}
// ────────────────────────────────────────────────────────────────────────────
// B6 — mobile basic-read guard (PRD §10 + §7.1)
// ────────────────────────────────────────────────────────────────────────────
// Mutating affordances that get gated on narrow viewports. Reading
// (open a scenario from the side panel, switch via the picker, search,
// switch entry mode) stays fully functional — only scenario-mutating
// taps are intercepted.
const MOBILE_MUTATING_SELECTOR = [
"#builder-rename-btn",
"#builder-share-btn",
"#builder-promote-btn",
"#builder-new-scenario-btn",
"#builder-cta-new",
"#builder-add-proceeding-btn",
".builder-triplet-host button",
".builder-triplet-host input",
".builder-triplet-host select",
].join(",");
function isNarrowViewport(): boolean {
return typeof window.matchMedia === "function" &&
window.matchMedia("(max-width: 640px)").matches;
}
let mobileToastTimer: number | null = null;
function showMobileBlockedToast(): void {
let toast = document.getElementById("builder-mobile-toast");
if (!toast) {
toast = document.createElement("div");
toast.id = "builder-mobile-toast";
toast.className = "builder-mobile-toast";
toast.setAttribute("role", "status");
toast.setAttribute("aria-live", "polite");
document.body.appendChild(toast);
}
toast.textContent = t("builder.mobile.blocked");
toast.classList.add("is-visible");
if (mobileToastTimer !== null) window.clearTimeout(mobileToastTimer);
mobileToastTimer = window.setTimeout(() => {
document.getElementById("builder-mobile-toast")?.classList.remove("is-visible");
}, 2600);
}
// wireMobileGuard intercepts taps on mutating affordances when the
// viewport is narrow (<640px), surfacing the "Auf größerem Bildschirm
// öffnen" toast instead of running the action. Capture phase so it
// pre-empts the control's own (bubble-phase) handler; calling
// preventDefault on a checkbox click also blocks its toggle + change
// event. Desktop is untouched — the guard early-returns unless the media
// query matches, so the desktop interaction code paths stay identical
// (PRD §10).
function wireMobileGuard(): void {
document.addEventListener(
"click",
(e) => {
if (!isNarrowViewport()) return;
const target = e.target as HTMLElement | null;
if (!target || !target.closest(MOBILE_MUTATING_SELECTOR)) return;
e.preventDefault();
e.stopPropagation();
showMobileBlockedToast();
},
true,
);
}
export async function mountBuilder(): Promise<void> {
wirePageHeader();
wireModeBar();
wireSearch();
wireScenarioFlagsListener();
wireMobileGuard();
// Parallel boot — proceeding type catalog (Forum=UPC, Kind=proceeding)
// for the add-proceeding picker + scenario_flag_catalog for the
// per-triplet flag strip. PRD §0.4 — UPC v1.
const [procTypes, flagCatalog] = await Promise.all([
fetchProceedingTypes(),
fetchFlagCatalog(),
]);
state.procTypes = procTypes;
state.procTypesById = new Map(state.procTypes.map((p) => [p.id, p]));
state.procTypesByCode = new Map(state.procTypes.map((p) => [p.code, p]));
state.flagCatalog = flagCatalog;
await refreshScenarioList();
// B4 — mount the Akte picker in parallel with the scenario load.
// The picker fetches /api/projects?type=case; the call is gated on
// requireDB so dev/test environments without a DB pool return [].
// Independent of scenario state — we mount it before deciding
// whether to load a scenario so the picker is interactive even if
// the requested scenario fails to load.
state.aktePicker = await mountAktePicker(async (projectId) => {
if (state.mode !== "akte") setMode("akte");
await loadAkteScenario(projectId);
});
const requested = readScenarioFromUrl();
// Deep-link auto-load covers both owned scenarios and ones shared with
// me (so a "Geteilt mit mir" link opens straight into the read-only
// view, not the cold-open canvas). loadScenario derives read-only from
// state.shared, so the share watermark + locked affordances apply.
const isKnown =
requested != null &&
(state.list.some((s) => s.id === requested) || state.shared.some((s) => s.id === requested));
if (isKnown) {
await loadScenario(requested as string);
} else {
renderCanvas();
}
setSaveState("idle");
}
// ────────────────────────────────────────────────────────────────────────────
// helpers
// ────────────────────────────────────────────────────────────────────────────
function todayISO(): string {
return new Date().toISOString().slice(0, 10);
}
export function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
export function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Re-export getLang so the per-page bundle pulls i18n into the dep
// graph (the i18n module's side-effect-free initialiser otherwise
// gets tree-shaken when only string keys are referenced).
export { getLang };