Compare commits

..

13 Commits

Author SHA1 Message Date
mAi
1c77cb6e67 fix(builder): surface proceeding_type id so add-proceeding POST works (t-paliad-345)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (pull_request) Has been cancelled
Paliad CI gate / test-go (pull_request) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / deploy (pull_request) Has been cancelled
The Litigation Builder's "+ Verfahren hinzufügen" silently failed in
prod after t-paliad-343 B2 shipped — clicking a Verfahren chip in the
picker did nothing, no visible error.

Root cause: the wire shape FristenrechnerType (the response of
/api/tools/proceeding-types) carried code+name+nameEN+group but not
id. Builder.ts mountAddProceedingPicker's callback POSTed
`{proceeding_type_id: meta.id}` to
/api/builder/scenarios/{id}/proceedings — meta.id was undefined,
JSON.stringify dropped the key, the server returned 400 ("invalid
input: proceeding_type_id is required"), and fetchJSON swallowed the
error to console. The user saw "nothing happens".

Fix:
- Add `ID int json:"id"` to lp.FristenrechnerType.
- SELECT id in FristenrechnerService.ListProceedings + Scan into the
  new field.
- Defensive guard in builder.ts openAddProceedingPicker — refuse to
  POST without a positive integer id and log a clear error, so a
  future wire-shape regression cannot recreate the silent-fail.
- Regression test in pkg/litigationplanner/types_wire_test.go pins the
  contract (id present in JSON, round-trips as integer).

Side-benefit: fristenrechner-wizard.ts:599-628 documented this exact
gap as a known limitation ("S5/follow-up can extend the wire shape to
include id"). That workaround can now be retired in a follow-up.

Refs m/paliad#153 (Litigation Builder)
2026-05-28 09:48:32 +02:00
mAi
1f6e586c63 Merge: t-paliad-344 — fix stale deadlines.rule_id refs + builder null-guards (m/paliad#154)
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
2026-05-28 00:48:17 +02:00
mAi
a4b865d6bd fix(builder): initialise scenario sub-arrays + client null-guard (t-paliad-344)
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
GetScenarioDeep returned nil slices for proceedings/events/shares when
a scenario had zero rows, which Go's encoding/json serialises as `null`
rather than `[]`. The builder's renderCanvas then unconditionally calls
`state.active.proceedings.filter(...)` on a null and dies with
`procedures.js:101 TypeError: Cannot read properties of null
(reading 'filter')` — every cold-open scenario crashed the page before
the empty canvas could render.

Backend (root cause): initialise Proceedings / Events / Shares to empty
slices in BuilderScenarioDeep before SelectContext, so the wire shape
is always arrays. Existing rows still load via SelectContext, which
truncates the placeholder and refills from the DB.

Frontend (defence in depth): on loadScenario(), normalise each of the
three arrays to `[]` if the server response is not an array. Catches a
future regression (or an older deployed build) without re-introducing
the same crash class.

bun build clean, go vet + go test ./... green.
2026-05-28 00:47:19 +02:00
mAi
a905911cf4 fix(deadlines): restore /api/events deadline rail after mig 140 column drop (t-paliad-344)
Two SELECTs still referenced paliad.deadlines.rule_id after mig 140
(Slice B.4) dropped that column in favour of sequencing_rule_id:

  - internal/services/deadline_service.go:268 — DeadlineService.
    ListVisibleForUser. Powers /api/events?type=deadline (dashboard
    deadline rail, /deadlines page, every status bucket). Threw
    `pq: column f.rule_id does not exist` on every request → 500
    for any authenticated user hitting the dashboard.

  - internal/services/projection_service.go:1250 — collectActualsForOverrides.
    Same column on `paliad.deadlines d`. Logged once per projection
    pass (`ERROR service: projection: deadlines: ...`); aliased the
    rename to `rule_id` so the receiving struct tag still scans.

Live container logs confirmed the failure mode — a 60-row burst of
`pq: column f.rule_id does not exist at position 3:36 (42703)` starting
the minute the post-B0 container came up (mig 140 had applied to the
DB but the SELECT still used the dropped name). EXPLAIN against the
live schema after the edit plans cleanly; the LEFT JOIN to
paliad.deadline_rules_unified on sequencing_rule_id was already correct
(only the SELECT projection was stale).

Root cause: mig 140 commit (1129bab) renamed the JOIN to
`f.sequencing_rule_id` but left the SELECT clause on the older name.
The model tag is already `db:"sequencing_rule_id" json:"rule_id"`, so
the wire shape is unchanged — only the column reference flips.

bun build clean, go vet ./... clean, go test ./... green.
2026-05-28 00:47:08 +02:00
mAi
88c03e922f Merge: t-paliad-343 B2 — multi-triplet + spawn + per-event state (m/paliad#153)
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
2026-05-28 00:29:50 +02:00
mAi
6bcac2dd20 Merge: t-paliad-343 B1 — Litigation Builder shell + cold-open (m/paliad#153) 2026-05-28 00:29:50 +02:00
mAi
46dc4ec94b feat(builder): B2 — multi-triplet stack + spawn nesting + per-event state (m/paliad#153)
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
Builds on B1 (commit 6c1d8cc). After this slice a user can compose a
multi-proceeding scenario kontextfrei: stack proceedings, flip
perspective per-triplet, toggle scenario flags, auto-spawn child
proceedings on flag transitions, and mark individual event cards as
planned / filed / skipped — all auto-saved to paliad.scenario_*.

PRD §7.1 B2 acceptance shipped:
  - Multi-triplet stack: top-level proceedings sorted by ordinal,
    child proceedings nested inline with a left lime border.
  - Per-triplet controls bar: perspective radio (none / claimant /
    defendant), Detailgrad pill (selected / all options), Entfernen
    action. Each control PATCHes the proceeding row and re-renders the
    affected triplet.
  - Per-triplet flag strip: every paliad.scenario_flag_catalog row
    rendered as a checkbox, bound to scenario_proceedings.scenario_flags.
    Active flags also surface as chips in the triplet header for quick
    legibility.
  - Spawn nesting: when `with_ccr` flips ON on upc.inf.cfi the builder
    auto-POSTs an upc.ccr.cfi child proceeding linked via
    parent_scenario_proceeding_id; flip OFF deletes the child (events
    cascade via the schema). The SPAWN_MAP table is data-driven so
    future spawn flags slot in.
  - 3-state event cards (planned / filed / skipped):
    overlayEventStates walks the rendered .fr-col-item nodes (the
    data-rule-id hook added to verfahrensablauf-core in this slice)
    and stamps each card with data-builder-state + per-state action
    buttons (File / Skip / Reset to planned). Filed cards prompt for
    a date; skipped cards prompt for an optional reason. POSTs or
    PATCHes paliad.scenario_events keyed by sequencing_rule_id.
  - Per-card optional horizon chip: stores horizon_optional on the
    scenario_event row, increment / decrement chip on every card.
    The full surface awaits a calc-engine "optionals available"
    counter (PRD §3.4 follow-up); the persistence layer + UX hook are
    in place so the wiring lands without another schema touch.
  - Page-header Stichtag drives default dates for every triplet (the
    triplet's per-stichtag override path is wired but the per-triplet
    Stichtag input is a B3+ affordance).

verfahrensablauf-core.renderColumnsBody now stamps data-rule-id (and
data-submission-code as a future hook) on every .fr-col-item root —
non-breaking enhancement; the legacy /tools/* pages don't read either
attribute. Verified by re-running the existing 57-test suite.

Backend: one new read-only endpoint
GET /api/builder/scenario-flag-catalog passes through
ScenarioFlagsService.ListCatalog so the builder doesn't need a
per-project round-trip to render flag toggles.

bun run build clean (3050 i18n keys), go vet ./... clean, go test ./...
clean, frontend bun test (verfahrensablauf-core suite) 57 / 57 pass.
2026-05-28 00:28:48 +02:00
mAi
6c1d8cc0cf feat(builder): B1 — Litigation Builder shell + cold-open mode (m/paliad#153)
Replaces cronus's U0-U4 catalog at /tools/procedures with a
persistence-backed builder shell on top of B0's API surface
(/api/builder/scenarios/*, t-paliad-340).

PRD §7.1 B1 acceptance shipped:
  - Page header: scenario picker, name action, Akte picker stub,
    Stichtag input, search input, save status indicator.
  - Entry-mode radio (cold-open active; event-triggered + akte
    rendered disabled for B3/B4 layout stability).
  - Empty canvas with "Neues Szenario starten" CTA and a 5-most-recent
    list rendered when the user has saved scenarios.
  - Side panel "Meine Szenarien" with the Aktiv bucket; clicking an
    item loads the scenario into the canvas.
  - Add-proceeding inline picker (Forum chip row → Verfahren chip row
    → Hinzufügen). UPC v1; other forums chipped but disabled.
  - First proceeding triplet renders end-to-end via
    verfahrensablauf-core.calculateDeadlines + renderColumnsBody (the
    existing 3-column proaktiv|court|reaktiv body, read-only in B1).
  - Auto-save with 500ms debounce on name + stichtag patches; save
    status flips idle → saving → saved/error in the page header.

New client modules under frontend/src/client/:
  - builder.ts       — orchestrator (URL state, fetch, auto-save loop,
                       canvas render, scenario-list re-paint).
  - builder-picker.ts — inline Forum/Verfahren popover for the
                       add-proceeding affordance.
  - builder-triplet.ts — single-triplet header + body wrapper.

procedures.tsx rewritten as the shell scaffolding (sidebar, page
header, mode radio, two-column body); procedures.ts now boots the
builder instead of toggling the 4-tab catalog.

Legacy U0-U4 modules (verfahrensablauf.ts, verfahrensablauf-state.ts,
VerfahrensablaufBody.tsx, procedures' tab toggle in client/procedures.ts,
fristenrechner-* mounts) are no longer reachable from /tools/procedures
but kept in the tree for the B6 cleanup sweep per PRD §7.4.

i18n.ts grew 60 keys × 2 langs under builder.*. global.css grew a
self-contained .builder-* block at the file tail.

bun run build, go vet ./..., and go test ./... all green.
2026-05-28 00:20:46 +02:00
mAi
0c857026a2 Merge: pkg/litigationplanner respect trigger_event_id + suppress optional from default (yoUPC#178 + #2568/#2570)
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
2026-05-28 00:05:37 +02:00
mAi
3c840c0366 fix(litigationplanner): respect trigger_event_id + suppress optional from default (yoUPC#178 + #2568/#2570)
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
Two paired engine semantics fixes:

1. trigger_event_id is now the authoritative semantic anchor. When a
   rule carries trigger_event_id, the engine no longer falls back to
   the proceeding's trigger date — it resolves the anchor via
   CalcOptions.TriggerEventAnchors keyed by paliad.trigger_events.code.
   Missing anchor renders the rule as IsConditional (empty date) and
   propagates via courtSet so descendants also surface as conditional.
   Fixes the RoP.109.5 bug where the engine fabricated a date 2 weeks
   before the user's SoC instead of waiting for the oral_hearing date.

2. priority='optional' rules are suppressed from the default
   Calculate output. Callers (paliad /tools/procedures,
   youpc.org/deadlines) opt in via CalcOptions.IncludeOptional=true to
   restore the legacy "show optional applications" behaviour. The
   suppression cascades through skippedIDs so child rules drop too.

Wire shape additions:

  - CalcOptions.IncludeOptional bool
  - CalcOptions.TriggerEventAnchors map[string]string
  - Timeline.RulesAwaitingAnchor int (count of suppressed-by-missing-
    anchor rules, for caller telemetry / "N rules need an anchor" UX)

Existing before-court-set-anchor tests opt in to IncludeOptional=true
to preserve their non-optional-related test intent.

Refs: youpcorg/head delegations #2568 + #2570, m/paliad#153 (Litigation
Builder PRD path).
2026-05-28 00:04:30 +02:00
mAi
1b4b2e4758 Merge: submission-md placeholder underscores preserved
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
2026-05-28 00:01:30 +02:00
mAi
b78a984a7c fix(submission-md): preserve {{...}} placeholders verbatim through inline scanner
The Markdown inline scanner (parseInlineSpans) treats _ and * as
italic delimiters. A placeholder like {{project.case_number}} fed
through the scanner had its underscores consumed as italic markers,
leaving {{project.casenumber}} in the composed OOXML. The v1
placeholder pass then looked up the wrong key, surfacing
[KEIN WERT: project.casenumber] in the preview. The form ↔ preview
highlighting also stopped working because data-var attributes
mismatched between the input (snake_case) and the rendered span
(stripped).

parseInlineSpans now detects {{ at the cursor and skips ahead to
the matching }}, copying the entire placeholder verbatim into the
current text run. Unmatched {{ falls through to the existing
character handling so legal prose with stray braces still renders.

Tests: regression test for underscored keys (single + multiple +
mixed-with-italics), direct guard on parseInlineSpans, and an
italic-around-placeholder structural test.
2026-05-28 00:01:30 +02:00
mAi
1844df3ae6 Merge: t-paliad-340 B0 — Scenario DB foundation (m/paliad#153)
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
2026-05-27 23:53:49 +02:00
22 changed files with 3129 additions and 293 deletions

View File

@@ -0,0 +1,147 @@
// Add-proceeding inline picker for the Litigation Builder.
//
// PRD §3 + §3.1: "+ Verfahren hinzufügen" button at the bottom of the
// triplet stack opens an inline picker. Forum chip row (UPC for v1)
// gates the Verfahren chip row, click → callback. Designed for B1's
// single-triplet flow and B2's multi-triplet stacking with no shape
// change between slices.
import { t } from "./i18n";
export interface ProceedingTypeMeta {
id: number;
code: string;
name: string;
nameEN: string;
// group / jurisdiction. The proceeding-types API returns "UPC" /
// "DE" / etc. as the canonical jurisdiction; for v1 the picker
// only renders UPC.
group?: string;
jurisdiction?: string;
}
type OnPick = (meta: ProceedingTypeMeta) => void | Promise<void>;
let activePopover: HTMLElement | null = null;
export function mountAddProceedingPicker(
anchor: HTMLElement,
types: ProceedingTypeMeta[],
onPick: OnPick,
): void {
closeActive();
const pop = document.createElement("div");
pop.className = "builder-picker-popover";
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-label", t("builder.picker.aria"));
const header = document.createElement("div");
header.className = "builder-picker-header";
header.innerHTML = `
<strong class="builder-picker-title">${escHtml(t("builder.picker.title"))}</strong>
<button type="button" class="builder-picker-close" aria-label="${escAttr(t("builder.picker.close"))}">×</button>
`;
pop.appendChild(header);
// Forum row — UPC only for v1. Disabled chips render greyed.
const forumRow = document.createElement("div");
forumRow.className = "builder-picker-row";
forumRow.innerHTML = `
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.forum"))}</span>
<div class="builder-picker-chips">
<button type="button" class="builder-picker-chip is-active" data-forum="UPC">UPC</button>
<button type="button" class="builder-picker-chip" data-forum="DE" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DE</button>
<button type="button" class="builder-picker-chip" data-forum="EPA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">EPA</button>
<button type="button" class="builder-picker-chip" data-forum="DPMA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DPMA</button>
</div>
`;
pop.appendChild(forumRow);
const procRow = document.createElement("div");
procRow.className = "builder-picker-row";
procRow.innerHTML = `
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.proc"))}</span>
<div class="builder-picker-chips builder-picker-chips--wrap" id="builder-picker-proc-chips"></div>
`;
pop.appendChild(procRow);
const empty = document.createElement("p");
empty.className = "builder-picker-empty";
empty.hidden = true;
empty.textContent = t("builder.picker.empty");
pop.appendChild(empty);
const procHost = pop.querySelector("#builder-picker-proc-chips") as HTMLElement;
const lang = document.documentElement.lang === "en" ? "en" : "de";
for (const meta of types) {
const label = lang === "en" ? (meta.nameEN || meta.name) : meta.name;
const chip = document.createElement("button");
chip.type = "button";
chip.className = "builder-picker-chip builder-picker-chip--proc";
chip.setAttribute("data-code", meta.code);
chip.innerHTML = `<span class="builder-picker-chip-code">${escHtml(meta.code)}</span>
<span class="builder-picker-chip-name">${escHtml(label)}</span>`;
chip.addEventListener("click", () => {
closeActive();
void onPick(meta);
});
procHost.appendChild(chip);
}
if (types.length === 0) empty.hidden = false;
header.querySelector(".builder-picker-close")?.addEventListener("click", () => {
closeActive();
});
// Position the popover under the anchor button.
positionUnder(pop, anchor);
document.body.appendChild(pop);
activePopover = pop;
document.addEventListener("click", onOutsideClick, true);
document.addEventListener("keydown", onEscape, true);
}
function positionUnder(pop: HTMLElement, anchor: HTMLElement): void {
const rect = anchor.getBoundingClientRect();
pop.style.position = "absolute";
const top = rect.bottom + window.scrollY + 6;
// Default left = anchor's left; clamp so popover stays in viewport.
const left = Math.max(8, rect.left + window.scrollX);
pop.style.top = `${top}px`;
pop.style.left = `${left}px`;
pop.style.maxWidth = "min(640px, calc(100vw - 24px))";
pop.style.zIndex = "60";
}
function onOutsideClick(ev: Event): void {
if (!activePopover) return;
const target = ev.target as Node;
if (activePopover.contains(target)) return;
closeActive();
}
function onEscape(ev: KeyboardEvent): void {
if (ev.key === "Escape") closeActive();
}
function closeActive(): void {
if (activePopover) {
activePopover.remove();
activePopover = null;
}
document.removeEventListener("click", onOutsideClick, true);
document.removeEventListener("keydown", onEscape, true);
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View File

@@ -0,0 +1,271 @@
// ProceedingTriplet renderer for the Litigation Builder.
//
// PRD §3.3 + §3.4 + §6.1: one triplet = jurisdiction badge + name +
// perspective + Detailgrad + columnar `proaktiv | court | reaktiv`
// body.
//
// B2 wires the live controls — perspective radio, scenario-flag strip,
// remove button, collapse — and the per-event-card overlays (3-state
// machine, action buttons, optional-horizon chip). The 3-column body
// itself is still produced by verfahrensablauf-core.renderColumnsBody;
// per-card overlays are layered on top after innerHTML write via the
// data-rule-id hooks added in the same slice.
import { t, tDyn, getLang } from "./i18n";
import type { DeadlineResponse, Side } from "./views/verfahrensablauf-core";
import type { BuilderProceeding, BuilderEvent } from "./builder";
import type { ProceedingTypeMeta } from "./builder-picker";
export interface ScenarioFlagCatalogEntry {
flag_key: string;
label_de: string;
label_en: string;
description?: string;
hidden_unless_set: boolean;
}
export interface TripletViewInput {
proceeding: BuilderProceeding;
meta: ProceedingTypeMeta;
data: DeadlineResponse | null;
side: Side;
// Flag catalog filtered to the keys the active proceeding actually
// references via its rules' condition_expr. B2 passes the global
// catalog and lets the user toggle any — flags that don't gate any
// rule are simply no-ops on this triplet.
flagCatalog: ScenarioFlagCatalogEntry[];
// Map keyed by sequencing_rule_id (lowercased UUID) → BuilderEvent
// for the per-card state machine. Cards whose rule is absent default
// to "planned".
eventsByRule: Map<string, BuilderEvent>;
// Per-card optional-horizon registry. Each rule with optional
// children carries a `+N Optionen` chip; the chip's count comes from
// here (defaults to scenario_events.horizon_optional, falls back to
// proceeding-level when not stored per-card).
columnsHtml: string;
isChild: boolean;
}
// Triplet header + controls + columns body. Pure-string render; the
// caller (builder.ts) wires click handlers on top.
export function renderTriplet(input: TripletViewInput): string {
const lang = getLang();
const procLabel = lang === "en"
? (input.meta.nameEN || input.meta.name)
: (input.meta.name || input.meta.nameEN);
const flagsBadge = activeFlagsBadge(input.proceeding.scenario_flags);
const body = input.data
? input.columnsHtml
: `<div class="builder-triplet-loading">${escHtml(t("builder.triplet.loading"))}</div>`;
const controls = renderControls(input);
const flagStrip = renderFlagStrip(input);
return `
<header class="builder-triplet-header">
<span class="builder-triplet-jurisdiction">${escHtml(jurisdictionFor(input.meta))}</span>
<span class="builder-triplet-code">${escHtml(input.meta.code)}</span>
<span class="builder-triplet-name">${escHtml(procLabel)}</span>
${flagsBadge}
</header>
${controls}
${flagStrip}
<div class="builder-triplet-body">
${body}
</div>
`;
}
function renderControls(input: TripletViewInput): string {
const perspective = input.side ?? "";
const detailgrad = input.proceeding.detailgrad || "selected";
const radio = (value: string, key: string, current: string): string => {
const active = value === current ? " is-active" : "";
return `<button type="button" class="builder-triplet-perspective-btn${active}"
data-action="perspective" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
};
const detailBtn = (value: string, key: string, current: string): string => {
const active = value === current ? " is-active" : "";
return `<button type="button" class="builder-triplet-detailgrad-btn${active}"
data-action="detailgrad" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
};
return `<div class="builder-triplet-controls">
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.perspective.label"))}</span>
<div class="builder-triplet-perspective">
${radio("", "builder.triplet.perspective.none", perspective)}
${radio("claimant", "builder.triplet.perspective.claimant", perspective)}
${radio("defendant", "builder.triplet.perspective.defendant", perspective)}
</div>
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.detailgrad.label"))}</span>
<div class="builder-triplet-detailgrad">
${detailBtn("selected", "builder.triplet.detailgrad.selected", detailgrad)}
${detailBtn("all_options", "builder.triplet.detailgrad.all_options", detailgrad)}
</div>
<button type="button" class="builder-triplet-remove" data-action="remove">
${escHtml(t("builder.triplet.remove"))}
</button>
</div>`;
}
function renderFlagStrip(input: TripletViewInput): string {
// B2 ships the full global catalog. Flags that don't gate any of the
// active proceeding's rules are still toggle-able but have no effect
// on the calc result (the engine simply doesn't read them).
const lang = getLang();
const flags = input.proceeding.scenario_flags || {};
if (input.flagCatalog.length === 0) {
return `<div class="builder-triplet-flagstrip">
<span class="builder-triplet-flag-empty">${escHtml(t("builder.triplet.no_flags"))}</span>
</div>`;
}
const toggles = input.flagCatalog.map((entry) => {
const label = lang === "en" ? entry.label_en : entry.label_de;
const isOn = flags[entry.flag_key] === true;
return `<label class="builder-triplet-flag-toggle">
<input type="checkbox"
data-action="flag"
data-flag-key="${escAttr(entry.flag_key)}"
${isOn ? "checked" : ""} />
<span>${escHtml(label)}</span>
</label>`;
}).join("");
return `<div class="builder-triplet-flagstrip">${toggles}</div>`;
}
function jurisdictionFor(meta: ProceedingTypeMeta): string {
if (meta.jurisdiction) return meta.jurisdiction;
if (meta.group) return meta.group;
const dot = meta.code.indexOf(".");
if (dot > 0) return meta.code.slice(0, dot).toUpperCase();
return meta.code.toUpperCase();
}
function activeFlagsBadge(flags: Record<string, unknown>): string {
const active = Object.entries(flags).filter(([, v]) => v === true).map(([k]) => k);
if (active.length === 0) return "";
const label = t("builder.triplet.flags.label");
const chips = active.map((f) =>
`<span class="builder-triplet-flag-chip">${escHtml(f)}</span>`,
).join("");
return `<span class="builder-triplet-flags">${escHtml(label)} ${chips}</span>`;
}
// overlayEventStates walks the rendered .fr-col-item nodes and:
// - sets data-builder-state from eventsByRule lookup;
// - appends a per-card action row (file / skip / reset);
// - shows a +N Optionen chip when the rule has optional children
// (the chip placeholder; B2 ships the per-card horizon control —
// the actual horizon-count→render expansion lands when the calc
// engine surfaces "available optionals" for a parent rule, which
// pasteur's Options.IncludeOptional flag already exposes server-
// side; full wiring is a follow-up). Cards without optional
// children get no chip.
export function overlayEventStates(
root: HTMLElement,
eventsByRule: Map<string, BuilderEvent>,
on: {
onAction: (ruleId: string, action: "file" | "skip" | "reset", payload?: { date?: string; reason?: string }) => void;
onHorizon: (ruleId: string, delta: 1 | -1) => void;
},
): void {
const items = root.querySelectorAll<HTMLElement>(".fr-col-item[data-rule-id]");
items.forEach((item) => {
const ruleId = item.getAttribute("data-rule-id");
if (!ruleId) return;
const ev = eventsByRule.get(ruleId.toLowerCase());
const state = ev?.state || "planned";
item.setAttribute("data-builder-state", state);
// Append actions (idempotent: clear any prior overlay first).
item.querySelectorAll(".builder-event-actions, .builder-event-horizon-chip").forEach((n) => n.remove());
const actions = document.createElement("div");
actions.className = "builder-event-actions";
actions.innerHTML = actionButtonsHtml(state);
item.appendChild(actions);
actions.addEventListener("click", (ev) => {
const btn = (ev.target as HTMLElement).closest<HTMLElement>(".builder-event-action");
if (!btn) return;
const action = btn.getAttribute("data-action") as "file" | "skip" | "reset" | null;
if (!action) return;
ev.stopPropagation();
if (action === "file") {
const today = new Date().toISOString().slice(0, 10);
const v = window.prompt(t("builder.event.actual_date.prompt"), today);
if (v === null) return;
on.onAction(ruleId, "file", { date: v.trim() || today });
} else if (action === "skip") {
const reason = window.prompt(t("builder.event.skip_reason.prompt"), "");
if (reason === null) return;
on.onAction(ruleId, "skip", { reason: reason.trim() });
} else {
on.onAction(ruleId, "reset");
}
});
// Per-card optional horizon chip. The PRD §3.4 places the chip on
// every card with optional children; until the calc surface exposes
// an "optionals available count" on each parent rule, the chip is
// shown only when the card has a stored non-zero horizon (so the
// user can see and reduce a previously-set horizon). This is the
// graceful B2 baseline; the full surface lands once the engine
// emits an optionalsAvailable counter (PRD §3.4 follow-up).
const horizonCount = ev?.horizon_optional ?? 0;
if (horizonCount > 0) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "builder-event-horizon-chip";
chip.setAttribute("data-action", "horizon-toggle");
chip.textContent = t("builder.event.horizon.label").replace("{n}", String(horizonCount));
chip.addEventListener("click", (e) => {
e.stopPropagation();
on.onHorizon(ruleId, -1);
});
item.appendChild(chip);
} else {
// Inline "+ Optionen" affordance — adds a horizon entry when
// first clicked. Tagged as data-builder-feature so the cleanup
// sweep can rip it out if the calc surface lands a counter.
const chip = document.createElement("button");
chip.type = "button";
chip.className = "builder-event-horizon-chip";
chip.setAttribute("data-action", "horizon-add");
chip.setAttribute("data-builder-feature", "horizon-add");
chip.textContent = "+ " + t("builder.event.horizon.label").replace("+{n} ", "");
chip.addEventListener("click", (e) => {
e.stopPropagation();
on.onHorizon(ruleId, 1);
});
item.appendChild(chip);
}
});
}
function actionButtonsHtml(state: BuilderEvent["state"]): string {
// Re-render the action row per state. Cards in the planned state
// show "File / Skip"; filed/skipped cards show "Reset to planned".
if (state === "planned") {
return `
<button type="button" class="builder-event-action" data-action="file">${escHtml(t("builder.event.action.file"))}</button>
<button type="button" class="builder-event-action" data-action="skip">${escHtml(t("builder.event.action.skip"))}</button>
`;
}
return `<button type="button" class="builder-event-action" data-action="reset">${escHtml(t("builder.event.action.reset"))}</button>`;
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View File

@@ -0,0 +1,981 @@
// 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 { mountAddProceedingPicker, type ProceedingTypeMeta } from "./builder-picker";
import { overlayEventStates, renderTriplet, type ScenarioFlagCatalogEntry } from "./builder-triplet";
// 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.
// ────────────────────────────────────────────────────────────────────────────
interface State {
active: BuilderScenarioDeep | null;
list: BuilderScenario[];
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 };
}
const state: State = {
active: null,
list: [],
procTypes: [],
procTypesById: new Map(),
procTypesByCode: new Map(),
flagCatalog: [],
saveTimer: null,
pending: {},
};
// ────────────────────────────────────────────────────────────────────────────
// 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[]> {
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios?status=active");
return Array.isArray(out) ? out : [];
}
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> {
state.list = await fetchScenarios();
renderScenarioList();
renderScenarioPicker();
}
function renderScenarioList(): void {
const ul = document.getElementById("builder-scenario-list-active");
if (!ul) return;
if (state.list.length === 0) {
ul.innerHTML = `<li class="builder-scenario-list-empty" data-i18n="builder.panel.empty">Noch keine Szenarien.</li>`;
return;
}
const activeId = state.active?.id;
ul.innerHTML = state.list.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 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>`];
for (const sc of state.list) {
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();
const data: DeadlineResponse | null = await calculateDeadlines({
proceedingType: meta.code,
triggerDate: stichtag,
flags: scenarioFlagsToArray(proc.scenario_flags),
});
const side: Side = (proc.primary_party as Side) || null;
const eventsByRule = buildEventsByRule(proc.id);
const columnsHtml = data
? renderColumnsBody(data, { 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);
},
});
}
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);
}
setSaveState("saved");
renderCanvas();
}
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;
}
// ────────────────────────────────────────────────────────────────────────────
// 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 = {};
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);
const rename = document.getElementById("builder-rename-btn") as HTMLButtonElement | null;
if (rename) rename.disabled = false;
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();
});
}
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
// ────────────────────────────────────────────────────────────────────────────
function wirePageHeader(): void {
document.getElementById("builder-rename-btn")?.addEventListener("click", () => {
void onRenameClick();
});
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;
writeScenarioToUrl(null);
renderCanvas();
}
});
const stichtag = document.getElementById("builder-stichtag-input") as HTMLInputElement | null;
stichtag?.addEventListener("change", () => {
onStichtagChange(stichtag.value);
});
}
export async function mountBuilder(): Promise<void> {
wirePageHeader();
// 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();
const requested = readScenarioFromUrl();
if (requested && state.list.some((s) => s.id === requested)) {
await loadScenario(requested);
} 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 };

View File

@@ -214,6 +214,69 @@ const translations: Record<Lang, Record<string, string>> = {
"procedures.panel.akte.placeholder": "Akten-Einstieg folgt in einem sp\u00e4teren Slice.",
"nav.procedures": "Verfahren & Fristen",
// Litigation Builder (m/paliad#153 B1+B2)
"builder.subtitle": "Litigation Builder \u2014 Szenarien bauen, Verfahren stapeln, Fristen behalten.",
"builder.header.scenario": "Szenario:",
"builder.header.akte": "Akte:",
"builder.header.stichtag": "Stichtag:",
"builder.header.search": "Suche:",
"builder.akte.none": "\u2014 ohne \u2014",
"builder.search.placeholder": "Ereignis, Szenario, Akte \u2026",
"builder.action.rename": "Benennen",
"builder.action.rename.prompt": "Name f\u00fcr dieses Szenario:",
"builder.action.share": "Teilen",
"builder.action.promote": "Als Projekt anlegen",
"builder.mode.cold": "\u00dcbersicht",
"builder.mode.event": "Ereignis",
"builder.mode.akte": "Aus Akte",
"builder.panel.title": "Meine Szenarien",
"builder.panel.new": "+ Neues Szenario",
"builder.panel.empty": "Noch keine Szenarien.",
"builder.bucket.active": "Aktiv",
"builder.empty.headline": "Noch kein Szenario ge\u00f6ffnet.",
"builder.empty.hint": "Starte ein neues Szenario, w\u00e4hle aus deiner Liste oder \u00fcbernimm eine Akte (B4).",
"builder.empty.cta": "Neues Szenario starten",
"builder.empty.recent": "Zuletzt bearbeitet",
"builder.picker.placeholder": "\u2014 Szenario w\u00e4hlen \u2014",
"builder.picker.title": "Verfahren hinzuf\u00fcgen",
"builder.picker.close": "Schlie\u00dfen",
"builder.picker.aria": "Verfahren ausw\u00e4hlen",
"builder.picker.axis.forum": "Forum:",
"builder.picker.axis.proc": "Verfahren:",
"builder.picker.empty": "Keine Verfahren verf\u00fcgbar.",
"builder.picker.future_jurisdiction": "Andere Foren folgen sp\u00e4ter.",
"builder.canvas.add_proceeding": "+ Verfahren hinzuf\u00fcgen",
"builder.triplet.loading": "Berechne Fristen \u2026",
"builder.triplet.unknown_proceeding": "Unbekannter Verfahrenstyp.",
"builder.triplet.side.claimant": "Kl\u00e4ger-Sicht",
"builder.triplet.side.defendant": "Beklagten-Sicht",
"builder.triplet.flags.label": "Optionen:",
"builder.triplet.perspective.label": "Perspektive:",
"builder.triplet.perspective.none": "keine",
"builder.triplet.perspective.claimant": "Kl\u00e4ger",
"builder.triplet.perspective.defendant": "Beklagter",
"builder.triplet.detailgrad.label": "Detailgrad:",
"builder.triplet.detailgrad.selected": "Gew\u00e4hlt",
"builder.triplet.detailgrad.all_options": "Alle Optionen",
"builder.triplet.remove": "Entfernen",
"builder.triplet.collapse": "Einklappen",
"builder.triplet.expand": "Ausklappen",
"builder.triplet.no_flags": "(keine Flags f\u00fcr diesen Verfahrenstyp)",
"builder.event.state.planned": "geplant",
"builder.event.state.filed": "eingereicht",
"builder.event.state.skipped": "ausgelassen",
"builder.event.action.file": "Einreichen",
"builder.event.action.skip": "Auslassen",
"builder.event.action.reset": "Zur\u00fcck zu geplant",
"builder.event.actual_date.prompt": "Datum der Einreichung:",
"builder.event.skip_reason.prompt": "Grund (optional):",
"builder.event.horizon.label": "+{n} Optionen \u25be",
"builder.event.horizon.hide": "Optionen ausblenden",
"builder.save.idle": "\u00a0",
"builder.save.saving": "Speichert \u2026",
"builder.save.saved": "Gespeichert \u2713",
"builder.save.error": "Speichern fehlgeschlagen",
"deadlines.step1": "Verfahrensart w\u00e4hlen",
"deadlines.step2": "Ausgangsdatum eingeben",
"deadlines.step2.perspective": "Perspektive und Datum",
@@ -3418,6 +3481,69 @@ const translations: Record<Lang, Record<string, string>> = {
"procedures.panel.akte.placeholder": "Matter entry ships in a later slice.",
"nav.procedures": "Procedures & Deadlines",
// Litigation Builder (m/paliad#153 B1+B2)
"builder.subtitle": "Litigation Builder — build scenarios, stack proceedings, track deadlines.",
"builder.header.scenario": "Scenario:",
"builder.header.akte": "Matter:",
"builder.header.stichtag": "Anchor:",
"builder.header.search": "Search:",
"builder.akte.none": "— none —",
"builder.search.placeholder": "Event, scenario, matter …",
"builder.action.rename": "Name it",
"builder.action.rename.prompt": "Name for this scenario:",
"builder.action.share": "Share",
"builder.action.promote": "Create as project",
"builder.mode.cold": "Overview",
"builder.mode.event": "Event",
"builder.mode.akte": "From matter",
"builder.panel.title": "My scenarios",
"builder.panel.new": "+ New scenario",
"builder.panel.empty": "No scenarios yet.",
"builder.bucket.active": "Active",
"builder.empty.headline": "No scenario open.",
"builder.empty.hint": "Start a new scenario, pick one from your list, or load a matter (B4).",
"builder.empty.cta": "Start a new scenario",
"builder.empty.recent": "Recent",
"builder.picker.placeholder": "— pick a scenario —",
"builder.picker.title": "Add proceeding",
"builder.picker.close": "Close",
"builder.picker.aria": "Pick a proceeding",
"builder.picker.axis.forum": "Forum:",
"builder.picker.axis.proc": "Proceeding:",
"builder.picker.empty": "No proceedings available.",
"builder.picker.future_jurisdiction": "Other forums coming later.",
"builder.canvas.add_proceeding": "+ Add proceeding",
"builder.triplet.loading": "Calculating deadlines …",
"builder.triplet.unknown_proceeding": "Unknown proceeding type.",
"builder.triplet.side.claimant": "Claimant view",
"builder.triplet.side.defendant": "Defendant view",
"builder.triplet.flags.label": "Options:",
"builder.triplet.perspective.label": "Perspective:",
"builder.triplet.perspective.none": "none",
"builder.triplet.perspective.claimant": "Claimant",
"builder.triplet.perspective.defendant": "Defendant",
"builder.triplet.detailgrad.label": "Detail:",
"builder.triplet.detailgrad.selected": "Selected",
"builder.triplet.detailgrad.all_options": "All options",
"builder.triplet.remove": "Remove",
"builder.triplet.collapse": "Collapse",
"builder.triplet.expand": "Expand",
"builder.triplet.no_flags": "(no flags for this proceeding type)",
"builder.event.state.planned": "planned",
"builder.event.state.filed": "filed",
"builder.event.state.skipped": "skipped",
"builder.event.action.file": "File",
"builder.event.action.skip": "Skip",
"builder.event.action.reset": "Reset to planned",
"builder.event.actual_date.prompt": "Date of filing:",
"builder.event.skip_reason.prompt": "Reason (optional):",
"builder.event.horizon.label": "+{n} optional ▾",
"builder.event.horizon.hide": "Hide optional",
"builder.save.idle": " ",
"builder.save.saving": "Saving …",
"builder.save.saved": "Saved ✓",
"builder.save.error": "Save failed",
"deadlines.step1": "Select Proceeding Type",
"deadlines.step2": "Enter Trigger Date",
"deadlines.step2.perspective": "Perspective and Date",

View File

@@ -1,150 +1,15 @@
// /tools/procedures client (m/paliad#151,
// docs/design-unified-procedural-events-tool-2026-05-27.md).
// /tools/procedures bundle entry — Litigation Builder (m/paliad#153 B1).
//
// 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.
//
// 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.
//
// 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.
// Replaces cronus's U0-U4 catalog bootstrap. The page chrome is
// emitted by procedures.tsx; this file boots the i18n + sidebar
// runtime and hands off to builder.ts.
import { initI18n } 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";
type ProceduresTab = "proceeding" | "search" | "wizard" | "akte";
const TABS: ProceduresTab[] = ["proceeding", "search", "wizard", "akte"];
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";
}
function writeTabToUrl(tab: ProceduresTab): 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);
}
// installOverhaulHost moves the (legacy) #fristen-overhaul-root /
// #fristen-overhaul-mode-host scaffold under `panelId`. Always clears
// any existing host first, so the IDs stay unique across the page even
// when the user toggles between Direkt-suchen and Geführt — both Mode
// A and the wizard read these IDs from document.getElementById which
// returns the first match in DOM order, so two parallel hosts would
// cross-wire.
function installOverhaulHost(panelId: string): HTMLElement | null {
document.querySelectorAll("#fristen-overhaul-root").forEach((el) => el.remove());
const panel = document.getElementById(panelId);
if (!panel) return null;
panel.innerHTML = `
<div class="procedures-overhaul-host">
<div class="fristen-overhaul-root" id="fristen-overhaul-root">
<div id="fristen-overhaul-mode-host"></div>
</div>
</div>
`;
return panel;
}
function 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;
}
}
// Verfahrensablauf wiring is idempotent-unfriendly (module-local
// selectedType + lastResponse + listeners that re-bind on every
// proceeding click). Wire it exactly once per page load; on subsequent
// activations the existing DOM + listeners are reused so picked
// proceeding / dates / flags persist across tab switches.
let verfahrensablaufWired = false;
async function activateTab(tab: ProceduresTab): Promise<void> {
setActiveTabUI(tab);
if (tab === "search") {
installOverhaulHost("procedures-panel-search");
await mountModeA();
return;
}
if (tab === "wizard") {
installOverhaulHost("procedures-panel-wizard");
await mountWizard();
return;
}
if (tab === "proceeding") {
if (!verfahrensablaufWired) {
initVerfahrensablauf();
verfahrensablaufWired = true;
}
}
}
function wireTabs(): void {
for (const t of TABS) {
const btn = document.getElementById(`procedures-tab-${t}`);
if (!btn) continue;
btn.addEventListener("click", () => {
void activateTab(t);
writeTabToUrl(t);
});
}
}
// boot dispatches on the URL: a deep link with `?event=` jumps straight
// to the linear result view (the Direkt-suchen tab stays as the visible
// context). Otherwise the requested tab — defaulting to "proceeding" —
// activates per readTabFromUrl().
async function boot(): Promise<void> {
const params = new URLSearchParams(window.location.search);
const eventRef = params.get("event") || "";
if (eventRef) {
setActiveTabUI("search");
installOverhaulHost("procedures-panel-search");
await mountResultView({
eventRef,
triggerDate: params.get("trigger_date") || undefined,
party: params.get("party") || undefined,
courtId: params.get("court_id") || undefined,
});
return;
}
await activateTab(readTabFromUrl());
}
import { mountBuilder } from "./builder";
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
wireTabs();
void boot();
void mountBuilder();
});

View File

@@ -1042,7 +1042,15 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
// timeline-item — dotted border + faded styling.
dl.isConditional ? "fr-col-item--conditional" : "",
].filter(Boolean).join(" ");
return `<div class="${itemClasses}">
// data-rule-id on the card root lets the Litigation Builder
// overlay per-card state (planned/filed/skipped) + action
// affordances onto cards rendered through this shared body
// without re-implementing the columns renderer. Empty on
// synthetic rows (appeal trigger marker etc.); the Builder
// skips state lookup when missing.
const ruleIdAttr = dl.ruleId ? ` data-rule-id="${escAttr(dl.ruleId)}"` : "";
const submissionCodeAttr = dl.code ? ` data-submission-code="${escAttr(dl.code)}"` : "";
return `<div class="${itemClasses}"${ruleIdAttr}${submissionCodeAttr}>
${deadlineCardHtml(dl, cardOpts)}
${mirrorTag}
</div>`;

View File

@@ -728,6 +728,67 @@ export type I18nKey =
| "bottomnav.add.title"
| "bottomnav.badge.deadlines"
| "bottomnav.menu"
| "builder.action.promote"
| "builder.action.rename"
| "builder.action.rename.prompt"
| "builder.action.share"
| "builder.akte.none"
| "builder.bucket.active"
| "builder.canvas.add_proceeding"
| "builder.empty.cta"
| "builder.empty.headline"
| "builder.empty.hint"
| "builder.empty.recent"
| "builder.event.action.file"
| "builder.event.action.reset"
| "builder.event.action.skip"
| "builder.event.actual_date.prompt"
| "builder.event.horizon.hide"
| "builder.event.horizon.label"
| "builder.event.skip_reason.prompt"
| "builder.event.state.filed"
| "builder.event.state.planned"
| "builder.event.state.skipped"
| "builder.header.akte"
| "builder.header.scenario"
| "builder.header.search"
| "builder.header.stichtag"
| "builder.mode.akte"
| "builder.mode.cold"
| "builder.mode.event"
| "builder.panel.empty"
| "builder.panel.new"
| "builder.panel.title"
| "builder.picker.aria"
| "builder.picker.axis.forum"
| "builder.picker.axis.proc"
| "builder.picker.close"
| "builder.picker.empty"
| "builder.picker.future_jurisdiction"
| "builder.picker.placeholder"
| "builder.picker.title"
| "builder.save.error"
| "builder.save.idle"
| "builder.save.saved"
| "builder.save.saving"
| "builder.search.placeholder"
| "builder.subtitle"
| "builder.triplet.collapse"
| "builder.triplet.detailgrad.all_options"
| "builder.triplet.detailgrad.label"
| "builder.triplet.detailgrad.selected"
| "builder.triplet.expand"
| "builder.triplet.flags.label"
| "builder.triplet.loading"
| "builder.triplet.no_flags"
| "builder.triplet.perspective.claimant"
| "builder.triplet.perspective.defendant"
| "builder.triplet.perspective.label"
| "builder.triplet.perspective.none"
| "builder.triplet.remove"
| "builder.triplet.side.claimant"
| "builder.triplet.side.defendant"
| "builder.triplet.unknown_proceeding"
| "cal.day.back_to_month"
| "cal.day.fri"
| "cal.day.mon"

View File

@@ -4,23 +4,19 @@ 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 — Litigation Builder (m/paliad#153 PRD §3).
//
// 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:
// Replaces cronus's 4-tab catalog (U0-U4) with a persistence-backed
// builder shell. Server-rendered chrome is minimal — the page-header
// scenario picker, side panel, and canvas are all hydrated by
// `builder.ts` at boot. The builder loads scenarios from
// /api/builder/scenarios (B0 surface, t-paliad-340) and renders the
// per-proceeding triplets with the existing verfahrensablauf-core calc.
//
// 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)
//
// 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.
// B1 — Builder shell + cold-open mode + single triplet end-to-end.
// B2 — Multi-triplet stack + spawn nesting + per-event state machine.
// B3+ — event-triggered + Akte modes, sharing, promotion (head-gated).
export function renderProcedures(): string {
const today = new Date().toISOString().split("T")[0];
@@ -36,151 +32,142 @@ export function renderProcedures(): string {
<title data-i18n="procedures.title">Verfahren &amp; Fristen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar page-procedures">
<body className="has-sidebar page-procedures page-builder">
<Sidebar currentPath="/tools/procedures" />
<BottomNav currentPath="/tools/procedures" />
<main>
<section className="tool-page">
<section className="tool-page builder-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="procedures.heading">Verfahren &amp; Fristen</h1>
<p className="tool-subtitle" data-i18n="procedures.subtitle">
Verfahrensablauf, Fristenrechner und ger&uuml;hrte Suche in einem Tool.
<p className="tool-subtitle" data-i18n="builder.subtitle">
Litigation Builder &mdash; Szenarien bauen, Verfahren stapeln, Fristen behalten.
</p>
</div>
{/* Shared filter strip — search box + four chip groups
(forum / proceeding / event_kind / party). Lives at the
top of the page so every entry tab and output mode reads
the same active filter set (design §4 + m's Q3
divergence: search composes with chip filters). U0
ships the markup only; chip hydration + search wiring
arrive with U1-U3. */}
<section className="procedures-filter-strip" aria-label="Filter">
<div className="procedures-filter-search">
<svg className="procedures-filter-search-icon" width="18" height="18" viewBox="0 0 24 24"
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input
type="search"
id="procedures-search-input"
className="procedures-filter-search-input"
autocomplete="off"
spellcheck="false"
data-i18n-placeholder="procedures.filter.search.placeholder"
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing&hellip;"
/>
{/* Page header (PRD §3.1): scenario picker · save state · name · share · promote
· Akte picker · Stichtag input. B1 wires the scenario picker
+ name action + Stichtag + save indicator. Akte / share /
promote land at B4 / B5; the affordances render disabled in
B1 so the layout is stable across slices. */}
<section className="builder-pageheader" aria-label="Builder-Steuerung">
<div className="builder-pageheader-row">
<label className="builder-pageheader-field">
<span className="builder-pageheader-label" data-i18n="builder.header.scenario">Szenario:</span>
<select id="builder-scenario-picker" className="builder-scenario-picker" aria-label="Szenario w&auml;hlen"></select>
</label>
<span id="builder-save-status" className="builder-save-status" aria-live="polite" data-state="idle">
<span data-i18n="builder.save.idle">&nbsp;</span>
</span>
<span className="builder-pageheader-spacer"></span>
<button type="button" id="builder-rename-btn"
className="builder-action-btn builder-action-btn--secondary"
disabled
data-i18n="builder.action.rename">Benennen</button>
<button type="button" id="builder-share-btn"
className="builder-action-btn builder-action-btn--secondary"
disabled
title="In B5 verf&uuml;gbar"
data-i18n="builder.action.share">Teilen</button>
<button type="button" id="builder-promote-btn"
className="builder-action-btn builder-action-btn--primary"
disabled
title="In B5 verf&uuml;gbar"
data-i18n="builder.action.promote">Als Projekt anlegen</button>
</div>
<div className="procedures-filter-chips" id="procedures-filter-chips">
<div className="procedures-filter-chip-row" data-axis="forum">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.forum">Forum:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-forum"></div>
</div>
<div className="procedures-filter-chip-row" data-axis="proc">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.proc">Verfahren:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-proc"></div>
</div>
<div className="procedures-filter-chip-row" data-axis="kind">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.kind">Ereignisart:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-kind"></div>
</div>
<div className="procedures-filter-chip-row" data-axis="party">
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.party">Partei:</span>
<div className="procedures-filter-chip-host" id="procedures-filter-chips-party"></div>
</div>
<div className="builder-pageheader-row">
<label className="builder-pageheader-field">
<span className="builder-pageheader-label" data-i18n="builder.header.akte">Akte:</span>
<select id="builder-akte-picker" className="builder-akte-picker" disabled aria-label="Akte w&auml;hlen">
<option value="" data-i18n="builder.akte.none">&mdash; ohne &mdash;</option>
</select>
</label>
<label className="builder-pageheader-field">
<span className="builder-pageheader-label" data-i18n="builder.header.stichtag">Stichtag:</span>
<input type="date" id="builder-stichtag-input" className="builder-stichtag-input"
defaultValue={today} aria-label="Stichtag" />
</label>
<label className="builder-pageheader-field builder-pageheader-field--grow">
<span className="builder-pageheader-label" data-i18n="builder.header.search">Suche:</span>
<input type="search" id="builder-search-input" className="builder-search-input"
data-i18n-placeholder="builder.search.placeholder"
placeholder="Ereignis, Szenario, Akte &hellip;"
autocomplete="off" spellcheck="false" disabled
title="Universelle Suche kommt in B3" />
</label>
</div>
</section>
{/* Entry-mode tab strip — all four tabs visible from boot
(m's Q3 divergence). The active tab is URL-driven
(?mode=proceeding|search|wizard|akte); cold open lands
on "proceeding" per design §11.5.Q3. */}
<nav className="procedures-tabs" role="tablist" aria-label="Einstieg">
{/* Entry-mode radio (PRD §0.2, §2). B1 ships cold-open active;
event-triggered + akte ship at B3 / B4 and are disabled
here so the layout stays stable across slices. */}
<nav className="builder-modebar" role="tablist" aria-label="Einstieg">
<button type="button"
className="procedures-tab is-active"
className="builder-mode is-active"
role="tab"
aria-selected="true"
data-tab="proceeding"
id="procedures-tab-proceeding">
<span className="procedures-tab-icon" aria-hidden="true">&#128218;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.proceeding">Verfahren w&auml;hlen</span>
data-mode="cold"
id="builder-mode-cold">
<span className="builder-mode-label" data-i18n="builder.mode.cold">&Uuml;bersicht</span>
</button>
<button type="button"
className="procedures-tab"
className="builder-mode"
role="tab"
aria-selected="false"
data-tab="search"
id="procedures-tab-search">
<span className="procedures-tab-icon" aria-hidden="true">&#9889;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.search">Direkt suchen</span>
data-mode="event"
id="builder-mode-event"
disabled
title="In B3 verf&uuml;gbar">
<span className="builder-mode-label" data-i18n="builder.mode.event">Ereignis</span>
</button>
<button type="button"
className="procedures-tab"
className="builder-mode"
role="tab"
aria-selected="false"
data-tab="wizard"
id="procedures-tab-wizard">
<span className="procedures-tab-icon" aria-hidden="true">&#129517;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.wizard">Gef&uuml;hrt</span>
</button>
<button type="button"
className="procedures-tab"
role="tab"
aria-selected="false"
data-tab="akte"
id="procedures-tab-akte">
<span className="procedures-tab-icon" aria-hidden="true">&#128193;</span>
<span className="procedures-tab-label" data-i18n="procedures.tab.akte">Aus Akte</span>
data-mode="akte"
id="builder-mode-akte"
disabled
title="In B4 verf&uuml;gbar">
<span className="builder-mode-label" data-i18n="builder.mode.akte">Aus Akte</span>
</button>
</nav>
{/* Per-tab content hosts. Only one is visible at a time —
procedures.ts toggles `hidden` on the inactive ones.
Each later slice fills the corresponding host. */}
<section className="procedures-panel" id="procedures-panel-proceeding" role="tabpanel"
aria-labelledby="procedures-tab-proceeding">
{/* Verfahrensablauf wizard body — shared TSX component
used by /tools/verfahrensablauf (legacy) and the
unified /tools/procedures page. procedures.ts calls
initVerfahrensablauf() on the first activation of
this tab, which wires the .proceeding-btn clicks,
timeline-container, detail-mode toggle, etc. against
the markup. The legacy page's auto-boot is guarded
against the procedures-only #procedures-panel-proceeding
element so it doesn't fire twice. */}
<VerfahrensablaufBody todayIso={today} />
</section>
{/* Two-column body: side panel (left, scenarios list) + canvas (right). */}
<div className="builder-body">
<aside className="builder-sidepanel" aria-label="Meine Szenarien">
<header className="builder-sidepanel-header">
<h2 className="builder-sidepanel-title" data-i18n="builder.panel.title">Meine Szenarien</h2>
<button type="button" id="builder-new-scenario-btn"
className="builder-sidepanel-newbtn"
data-i18n="builder.panel.new">+ Neues Szenario</button>
</header>
<div className="builder-sidepanel-bucket" data-bucket="active">
<h3 className="builder-bucket-label" data-i18n="builder.bucket.active">Aktiv</h3>
<ul className="builder-scenario-list" id="builder-scenario-list-active" aria-label="Aktive Szenarien"></ul>
</div>
{/* Geteilt / Promoted / Archiviert buckets land in B5+. */}
</aside>
<section className="procedures-panel" id="procedures-panel-search" role="tabpanel"
aria-labelledby="procedures-tab-search" hidden></section>
<section className="procedures-panel" id="procedures-panel-wizard" role="tabpanel"
aria-labelledby="procedures-tab-wizard" hidden></section>
<section className="procedures-panel" id="procedures-panel-akte" role="tabpanel"
aria-labelledby="procedures-tab-akte" hidden>
<div className="procedures-panel-placeholder" data-i18n="procedures.panel.akte.placeholder">
Akten-Einstieg folgt in einem sp&auml;teren Slice.
</div>
</section>
{/* Tree output host. Slice U3 mounts the Verfahrensablauf
tree here; U0 leaves it empty + hidden so the
tab placeholders are the only thing visible. */}
<section className="procedures-output procedures-output-tree" id="procedures-output-tree"
aria-label="Tree output" hidden></section>
{/* Linear-drawer host. Inline drawer expanding beneath a
tree card (design §8 — desktop) AND the standalone
linear follow-up view that Mode A / Mode B land on
after locking a trigger event (design §3.2). U1
switches it on. */}
<section className="procedures-output procedures-output-linear" id="procedures-output-linear"
aria-label="Linear output" hidden></section>
<section className="builder-canvas-wrap" aria-label="Builder-Canvas">
<div id="builder-canvas" className="builder-canvas">
{/* Cold-open placeholder — replaced by triplet stack once a
scenario is loaded. */}
<div className="builder-empty" id="builder-empty">
<p className="builder-empty-headline" data-i18n="builder.empty.headline">
Noch kein Szenario ge&ouml;ffnet.
</p>
<p className="builder-empty-hint" data-i18n="builder.empty.hint">
Starte ein neues Szenario, w&auml;hle aus deiner Liste oder &uuml;bernimm eine Akte (B4).
</p>
<button type="button" id="builder-cta-new" className="builder-cta-new"
data-i18n="builder.empty.cta">
Neues Szenario starten
</button>
</div>
</div>
</section>
</div>
</div>
</section>
</main>

View File

@@ -19811,3 +19811,694 @@ a.fristen-overhaul-rule-source {
width: 100%;
}
}
/* --- Litigation Builder (m/paliad#153 B1+B2) --- */
.builder-page .tool-header {
margin-bottom: 0.75rem;
}
.builder-pageheader {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.75rem 1rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
margin-bottom: 0.6rem;
}
.builder-pageheader-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.builder-pageheader-field {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.9rem;
}
.builder-pageheader-field--grow {
flex: 1 1 220px;
}
.builder-pageheader-label {
color: var(--color-text-subtle);
font-weight: 500;
white-space: nowrap;
}
.builder-pageheader-spacer {
flex: 1 1 auto;
}
.builder-scenario-picker,
.builder-akte-picker,
.builder-stichtag-input,
.builder-search-input {
font: inherit;
padding: 0.3rem 0.55rem;
border: 1px solid var(--color-border);
border-radius: 0.3rem;
background: var(--color-surface-2);
color: var(--color-text);
min-width: 200px;
}
.builder-search-input {
min-width: 260px;
}
.builder-scenario-picker:disabled,
.builder-akte-picker:disabled,
.builder-search-input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.builder-save-status {
font-size: 0.85rem;
color: var(--color-text-subtle);
min-width: 8rem;
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.builder-save-status[data-state="saving"] { color: var(--color-text-subtle); }
.builder-save-status[data-state="saved"] { color: var(--color-accent-strong-fg); }
.builder-save-status[data-state="error"] { color: var(--status-red-fg, #c5503a); }
.builder-action-btn {
font: inherit;
padding: 0.35rem 0.85rem;
border-radius: 0.3rem;
cursor: pointer;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
color: var(--color-text);
}
.builder-action-btn:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.builder-action-btn--primary {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-dark);
font-weight: 500;
}
.builder-action-btn--primary:hover:not(:disabled) {
background: var(--color-accent-light);
}
.builder-action-btn--secondary:hover:not(:disabled) {
background: var(--color-surface-muted);
}
.builder-modebar {
display: inline-flex;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface);
padding: 0.15rem;
margin-bottom: 0.75rem;
}
.builder-mode {
font: inherit;
background: transparent;
border: 0;
padding: 0.3rem 0.9rem;
border-radius: 999px;
cursor: pointer;
color: var(--color-text-subtle);
}
.builder-mode.is-active {
background: var(--color-segment-active-bg);
color: var(--color-segment-active-fg);
}
.builder-mode:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.builder-body {
display: grid;
grid-template-columns: 240px 1fr;
gap: 1rem;
align-items: start;
}
.builder-sidepanel {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 0.75rem;
position: sticky;
top: 1rem;
max-height: calc(100vh - 2rem);
overflow: auto;
}
.builder-sidepanel-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.builder-sidepanel-title {
font-size: 0.95rem;
margin: 0;
}
.builder-sidepanel-newbtn {
font: inherit;
font-size: 0.8rem;
background: var(--color-accent);
border: 1px solid var(--color-accent);
border-radius: 0.3rem;
padding: 0.2rem 0.5rem;
cursor: pointer;
color: var(--color-accent-dark);
}
.builder-sidepanel-newbtn:hover {
background: var(--color-accent-light);
}
.builder-bucket-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-subtle);
margin: 0.5rem 0 0.3rem;
}
.builder-scenario-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.builder-scenario-list-empty {
font-size: 0.85rem;
color: var(--color-text-subtle);
font-style: italic;
}
.builder-scenario-list-item {
cursor: pointer;
border-radius: 0.3rem;
}
.builder-scenario-list-item.is-active {
background: var(--color-accent-soft-bg);
}
.builder-scenario-list-link {
display: block;
width: 100%;
text-align: left;
background: transparent;
border: 0;
padding: 0.4rem 0.5rem;
font: inherit;
color: inherit;
cursor: pointer;
}
.builder-scenario-list-item:hover {
background: var(--color-surface-muted);
}
.builder-canvas-wrap {
min-height: 320px;
}
.builder-canvas {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.builder-empty {
background: var(--color-surface);
border: 1px dashed var(--color-border);
border-radius: 0.5rem;
padding: 2rem;
text-align: center;
}
.builder-empty-headline {
font-size: 1.05rem;
margin: 0 0 0.4rem;
}
.builder-empty-hint {
color: var(--color-text-subtle);
margin: 0 0 1rem;
}
.builder-cta-new {
font: inherit;
background: var(--color-accent);
border: 1px solid var(--color-accent);
border-radius: 0.3rem;
padding: 0.55rem 1.2rem;
cursor: pointer;
color: var(--color-accent-dark);
font-weight: 500;
}
.builder-cta-new:hover {
background: var(--color-accent-light);
}
.builder-recent {
margin-top: 1.5rem;
text-align: left;
}
.builder-recent-title {
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-subtle);
margin: 0 0 0.5rem;
}
.builder-recent-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.builder-recent-item {
padding: 0.4rem 0.6rem;
background: var(--color-surface-2);
border-radius: 0.3rem;
cursor: pointer;
}
.builder-recent-item:hover {
background: var(--color-surface-muted);
}
.builder-triplet-host {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
overflow: hidden;
}
.builder-triplet-host[data-child="true"] {
margin-left: 1.5rem;
border-left: 3px solid var(--color-accent);
}
.builder-triplet-header {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.85rem;
background: var(--color-surface-2);
border-bottom: 1px solid var(--color-border);
font-size: 0.9rem;
flex-wrap: wrap;
}
.builder-triplet-jurisdiction {
background: var(--color-accent);
color: var(--color-accent-dark);
font-weight: 600;
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
border-radius: 0.25rem;
letter-spacing: 0.05em;
}
.builder-triplet-code {
font-family: ui-monospace, Menlo, monospace;
font-size: 0.78rem;
color: var(--color-text-subtle);
}
.builder-triplet-name {
font-weight: 500;
margin-right: auto;
}
.builder-triplet-side {
background: var(--color-accent-soft-bg);
color: var(--color-accent-soft-fg);
border: 1px solid var(--color-accent-soft-border);
padding: 0.1rem 0.45rem;
border-radius: 0.25rem;
font-size: 0.75rem;
}
.builder-triplet-flags {
font-size: 0.78rem;
color: var(--color-text-subtle);
display: inline-flex;
align-items: center;
gap: 0.3rem;
}
.builder-triplet-flag-chip {
background: var(--color-accent-soft-bg);
border: 1px solid var(--color-accent-soft-border);
color: var(--color-accent-soft-fg);
padding: 0.05rem 0.4rem;
border-radius: 0.25rem;
font-family: ui-monospace, Menlo, monospace;
}
.builder-triplet-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.55rem;
padding: 0.45rem 0.85rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
font-size: 0.85rem;
}
.builder-triplet-controls-label {
color: var(--color-text-subtle);
}
.builder-triplet-perspective,
.builder-triplet-detailgrad {
display: inline-flex;
align-items: center;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface-2);
padding: 0.1rem;
}
.builder-triplet-perspective button,
.builder-triplet-detailgrad button {
font: inherit;
font-size: 0.78rem;
border: 0;
background: transparent;
padding: 0.2rem 0.6rem;
border-radius: 999px;
cursor: pointer;
color: var(--color-text-subtle);
}
.builder-triplet-perspective button.is-active,
.builder-triplet-detailgrad button.is-active {
background: var(--color-segment-active-bg);
color: var(--color-segment-active-fg);
}
.builder-triplet-remove {
margin-left: auto;
font: inherit;
font-size: 0.78rem;
background: transparent;
border: 1px solid var(--color-border);
border-radius: 0.25rem;
padding: 0.15rem 0.55rem;
cursor: pointer;
color: var(--color-text-subtle);
}
.builder-triplet-remove:hover {
border-color: var(--status-red-border, #d08070);
color: var(--status-red-fg, #c5503a);
}
.builder-triplet-flagstrip {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.45rem 0.85rem;
border-bottom: 1px solid var(--color-border);
background: var(--color-surface);
font-size: 0.85rem;
}
.builder-triplet-flag-toggle {
display: inline-flex;
align-items: center;
gap: 0.3rem;
cursor: pointer;
}
.builder-triplet-flag-empty {
font-style: italic;
color: var(--color-text-subtle);
}
.builder-triplet-body {
padding: 0.85rem;
}
.builder-triplet-loading,
.builder-triplet-error {
padding: 1rem;
text-align: center;
color: var(--color-text-subtle);
font-style: italic;
}
.builder-add-proceeding-btn {
font: inherit;
background: var(--color-surface-2);
border: 1px dashed var(--color-border);
border-radius: 0.5rem;
padding: 0.7rem;
cursor: pointer;
color: var(--color-text-subtle);
}
.builder-add-proceeding-btn:hover {
background: var(--color-accent-soft-bg);
border-color: var(--color-accent-soft-border);
color: var(--color-accent-soft-fg);
}
/* Add-proceeding popover */
.builder-picker-popover {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
box-shadow: 0 6px 20px rgba(0,0,0,0.12);
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.6rem;
min-width: 380px;
}
.builder-picker-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.builder-picker-title {
font-size: 0.95rem;
}
.builder-picker-close {
font: inherit;
font-size: 1.2rem;
background: transparent;
border: 0;
cursor: pointer;
color: var(--color-text-subtle);
}
.builder-picker-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
}
.builder-picker-axis-label {
flex: 0 0 6rem;
font-size: 0.85rem;
color: var(--color-text-subtle);
padding-top: 0.25rem;
}
.builder-picker-chips {
display: inline-flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.builder-picker-chips--wrap {
flex: 1;
}
.builder-picker-chip {
font: inherit;
font-size: 0.85rem;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
padding: 0.25rem 0.55rem;
border-radius: 999px;
cursor: pointer;
color: var(--color-text);
}
.builder-picker-chip.is-active {
background: var(--color-segment-active-bg);
color: var(--color-segment-active-fg);
border-color: var(--color-segment-active-border);
}
.builder-picker-chip:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.builder-picker-chip:hover:not(:disabled) {
background: var(--color-accent-soft-bg);
}
.builder-picker-chip--proc {
display: inline-flex;
align-items: baseline;
gap: 0.4rem;
text-align: left;
}
.builder-picker-chip-code {
font-family: ui-monospace, Menlo, monospace;
font-size: 0.72rem;
color: var(--color-text-subtle);
}
.builder-picker-empty {
font-size: 0.85rem;
color: var(--color-text-subtle);
font-style: italic;
}
/* Event-card state overrides (B2). The 3-state machine sits on top of
the existing .fr-col-item card. The Builder render passes editable=false
to renderColumnsBody and overlays its own per-card state attributes
on top of the card root via data-builder-state. */
.fr-col-item[data-builder-state="filed"] {
background: var(--color-accent-soft-bg);
border-left: 3px solid var(--color-accent);
}
.fr-col-item[data-builder-state="filed"] .timeline-name::before {
content: "✓ ";
color: var(--color-accent-soft-fg);
font-weight: 600;
}
.fr-col-item[data-builder-state="skipped"] {
opacity: 0.55;
}
.fr-col-item[data-builder-state="skipped"] .timeline-name {
text-decoration: line-through;
}
.builder-event-actions {
display: flex;
gap: 0.3rem;
margin-top: 0.4rem;
}
.builder-event-action {
font: inherit;
font-size: 0.72rem;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: 0.25rem;
padding: 0.15rem 0.45rem;
cursor: pointer;
color: var(--color-text);
}
.builder-event-action:hover {
background: var(--color-accent-soft-bg);
}
.builder-event-action[data-action="file"] {
background: var(--color-accent);
border-color: var(--color-accent);
color: var(--color-accent-dark);
}
.builder-event-action[data-action="file"]:hover {
background: var(--color-accent-light);
}
.builder-event-horizon-chip {
display: inline-block;
font-size: 0.72rem;
color: var(--color-accent-soft-fg);
background: var(--color-accent-soft-bg);
border: 1px solid var(--color-accent-soft-border);
border-radius: 0.25rem;
padding: 0.1rem 0.45rem;
margin-top: 0.3rem;
cursor: pointer;
}
.builder-event-horizon-chip:hover {
background: var(--color-accent-strong-bg);
}
/* Responsive: collapse side panel into stacked block on narrow viewports. */
@media (max-width: 900px) {
.builder-body {
grid-template-columns: 1fr;
}
.builder-sidepanel {
position: static;
max-height: none;
}
}
@media (max-width: 640px) {
.builder-pageheader-row {
flex-direction: column;
align-items: stretch;
gap: 0.4rem;
}
.builder-pageheader-field {
flex-wrap: wrap;
}
.builder-scenario-picker,
.builder-akte-picker,
.builder-search-input {
min-width: 0;
width: 100%;
}
}

View File

@@ -537,6 +537,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventDelete)
protected.HandleFunc("POST /api/builder/scenarios/{id}/shares", handleBuilderShareCreate)
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/shares/{sid}", handleBuilderShareDelete)
// m/paliad#153 B2 — read-only passthrough so the builder can render
// per-triplet flag toggles without a per-project round-trip.
protected.HandleFunc("GET /api/builder/scenario-flag-catalog", handleBuilderFlagCatalog)
// Dev-only test route — gated to PaliadinOwnerEmail (m).
protected.HandleFunc("GET /dev/scenario-builder", handleBuilderDevTestPage)

View File

@@ -388,6 +388,44 @@ func handleBuilderShareDelete(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Scenario flag catalog passthrough (m/paliad#153 B2)
// ---------------------------------------------------------------------------
// handleBuilderFlagCatalog — GET /api/builder/scenario-flag-catalog
//
// Returns every row of paliad.scenario_flag_catalog so the Litigation
// Builder can render per-triplet flag toggles without a per-project
// round-trip. The catalog itself is global (no jurisdiction or
// proceeding scope baked into the table); which flags actually apply
// to a given proceeding type is decided by the calc engine via
// condition_expr at calculation time. The client renders every catalog
// flag and lets the user toggle them — flags with no effect on the
// active proceeding's rules simply have no condition_expr referencing
// them, so toggling is a no-op.
//
// 503 when ScenarioFlagsService is nil (DATABASE_URL unset); per-row
// visibility checks aren't needed because the catalog is global.
func handleBuilderFlagCatalog(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.scenarioFlags == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Flag-Katalog vorübergehend nicht verfügbar (keine Datenbank).",
})
return
}
if _, ok := requireUser(w, r); !ok {
return
}
out, err := dbSvc.scenarioFlags.ListCatalog(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "Flag-Katalog konnte nicht geladen werden",
})
return
}
writeJSON(w, http.StatusOK, out)
}
// ---------------------------------------------------------------------------
// Dev-only test route
// ---------------------------------------------------------------------------

View File

@@ -265,7 +265,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
query := `
SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date,
f.warning_date, f.source, f.rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
f.warning_date, f.source, f.sequencing_rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
f.created_at, f.updated_at,
f.approval_status, f.pending_request_id, f.approved_by, f.approved_at,

View File

@@ -145,7 +145,7 @@ func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts Procee
AND pe.event_kind = $%d
)`, opts.EventKind)
}
query := `SELECT code, name, name_en, jurisdiction
query := `SELECT id, code, name, name_en, jurisdiction
FROM paliad.proceeding_types
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY sort_order`
@@ -160,7 +160,7 @@ func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts Procee
for rows.Next() {
var t lp.FristenrechnerType
var juris sql.NullString
if err := rows.Scan(&t.Code, &t.Name, &t.NameEN, &juris); err != nil {
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.NameEN, &juris); err != nil {
return nil, err
}
if juris.Valid {

View File

@@ -1247,7 +1247,7 @@ func (s *ProjectionService) collectActualsForOverrides(
}
var dRows []drow
scopeFilter := scopeProjectIDFilter("d", "project_id", projectID, directOnly)
q := `SELECT d.rule_id, d.rule_code, d.due_date, d.completed_at, d.status
q := `SELECT d.sequencing_rule_id AS rule_id, d.rule_code, d.due_date, d.completed_at, d.status
FROM paliad.deadlines d
WHERE ` + scopeFilter
if err := s.db.SelectContext(ctx, &dRows, q, projectID); err != nil {

View File

@@ -204,7 +204,15 @@ func (s *ScenarioBuilderService) GetScenarioDeep(ctx context.Context, userID, sc
return nil, ErrScenarioBuilderNotVisible
}
deep := &BuilderScenarioDeep{BuilderScenario: *sc}
deep := &BuilderScenarioDeep{
BuilderScenario: *sc,
// Initialise to empty so the JSON response always carries arrays,
// not null — the builder frontend's renderCanvas calls .filter on
// proceedings/events unconditionally once state.active is set.
Proceedings: []BuilderProceeding{},
Events: []BuilderEvent{},
Shares: []BuilderShare{},
}
if err := s.db.SelectContext(ctx, &deep.Proceedings, `
SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,

View File

@@ -405,6 +405,23 @@ func parseInlineSpans(text string) []inlineSpan {
i := 0
n := len(text)
for i < n {
// Preserve {{...}} placeholders verbatim. Underscores and
// other Markdown-significant chars inside a placeholder key
// (e.g. {{project.case_number}}) must not be interpreted as
// bold/italic delimiters — otherwise the key gets stripped of
// its underscores and the v1 placeholder pass looks up the
// wrong key, surfacing [KEIN WERT: project.casenumber] in the
// preview.
if i+1 < n && text[i] == '{' && text[i+1] == '{' {
rel := strings.Index(text[i+2:], "}}")
if rel >= 0 {
end := i + 2 + rel + 2
cur.WriteString(text[i:end])
i = end
continue
}
// Unmatched {{ — fall through to plain character handling.
}
// Bold delimiters first (longer match wins over italic).
if i+1 < n && (text[i:i+2] == "**" || text[i:i+2] == "__") {
flush()

View File

@@ -86,6 +86,90 @@ func TestRenderMarkdownToOOXML_PlaceholdersPassThrough(t *testing.T) {
}
}
func TestRenderMarkdownToOOXML_PlaceholderUnderscoresPreserved(t *testing.T) {
// Regression: a placeholder key containing underscores (project.case_number,
// user.display_name, project.patent_number_upc) used to get its underscores
// consumed by the italic/bold inline scanner — the OOXML stored
// {{project.casenumber}} and the preview surfaced
// [KEIN WERT: project.casenumber] instead of the real value.
cases := []string{
"{{project.case_number}}",
"{{user.display_name}}",
"{{project.patent_number_upc}}",
"prefix {{project.case_number}} suffix",
"two: {{a.b_c}} and {{d.e_f}}",
"mixed: _italic_ then {{project.case_number}} then __bold__",
}
for _, in := range cases {
out := RenderMarkdownToOOXML(in, "Normal")
// Every placeholder substring in the input must appear verbatim
// in the output (XML escaping is irrelevant for {} and _).
for _, ph := range extractPlaceholders(in) {
if !strings.Contains(out, ph) {
t.Errorf("input %q: placeholder %q lost; got %q", in, ph, out)
}
}
}
}
func TestParseInlineSpans_PlaceholderWithUnderscoresIsLiteral(t *testing.T) {
// Direct guard on the inline scanner. {{project.case_number}} must
// emit as a single non-italic span containing the full placeholder.
spans := parseInlineSpans("{{project.case_number}}")
if len(spans) != 1 {
t.Fatalf("expected 1 span; got %d (%+v)", len(spans), spans)
}
if spans[0].Italic || spans[0].Bold {
t.Errorf("placeholder must not be italic/bold; got %+v", spans[0])
}
if spans[0].Text != "{{project.case_number}}" {
t.Errorf("placeholder text corrupted: got %q", spans[0].Text)
}
}
func TestParseInlineSpans_ItalicAroundPlaceholder(t *testing.T) {
// Italic delimiters outside a placeholder still work; the placeholder
// itself stays literal even when it sits between italics.
spans := parseInlineSpans("_before_ {{x.y_z}} _after_")
var saw struct {
italicBefore bool
placeholder bool
italicAfter bool
}
for _, s := range spans {
if s.Italic && s.Text == "before" {
saw.italicBefore = true
}
if !s.Italic && !s.Bold && strings.Contains(s.Text, "{{x.y_z}}") {
saw.placeholder = true
}
if s.Italic && s.Text == "after" {
saw.italicAfter = true
}
}
if !saw.italicBefore || !saw.placeholder || !saw.italicAfter {
t.Errorf("expected italic/placeholder/italic structure; got %+v", spans)
}
}
// extractPlaceholders pulls every {{...}} occurrence out of a Markdown
// source. Tiny helper, only used by the regression test above.
func extractPlaceholders(s string) []string {
var out []string
for {
start := strings.Index(s, "{{")
if start < 0 {
return out
}
end := strings.Index(s[start+2:], "}}")
if end < 0 {
return out
}
out = append(out, s[start:start+2+end+2])
s = s[start+2+end+2:]
}
}
func TestRenderMarkdownToOOXML_XMLEscape(t *testing.T) {
out := RenderMarkdownToOOXML("a & b < c > d", "")
if strings.Contains(out, " & ") {

View File

@@ -205,7 +205,11 @@ func TestCalculate_BeforeChildOfCourtSetParent_OutOfOrderSequence(t *testing.T)
cat := &stubCatalog{pt: pt, rules: rules}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
// IncludeOptional=true because translation_request carries
// priority='optional'; the test exercises the before-child-of-
// court-set-parent flow, which is orthogonal to the optional-rule
// suppression added in t-paliad-342.
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{IncludeOptional: true}, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
@@ -301,8 +305,10 @@ func TestCalculate_BeforeChildOfCourtSetParent_WithOverride(t *testing.T) {
cat := &stubCatalog{pt: pt, rules: rules}
// User pins the oral hearing to 2026-10-15.
// User pins the oral hearing to 2026-10-15. IncludeOptional=true
// because translation_request is priority='optional' (t-paliad-342).
opts := CalcOptions{
IncludeOptional: true,
AnchorOverrides: map[string]string{
oralCode: "2026-10-15",
},

View File

@@ -80,6 +80,21 @@ func Calculate(
overrideDates[code] = od
}
// Trigger-event anchors keyed by paliad.trigger_events.code
// (t-paliad-342). Parsed up-front so malformed dates error before
// the rule walk. When a rule has trigger_event_id set, the engine
// looks up triggerAnchorByCode[trigger_event.code] for the
// semantic anchor instead of falling back to the proceeding's
// trigger date.
triggerAnchorByCode := make(map[string]time.Time, len(opts.TriggerEventAnchors))
for code, dateStr := range opts.TriggerEventAnchors {
td, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return nil, fmt.Errorf("invalid trigger event anchor for %q (%q): %w", code, dateStr, err)
}
triggerAnchorByCode[code] = td
}
// Look up proceeding type metadata.
pickedProceeding, rules, err := catalog.LoadProceeding(ctx, proceedingCode, opts.ProjectHint)
if err != nil {
@@ -213,6 +228,7 @@ func Calculate(
perCardAppellant := opts.PerCardAppellant
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
hiddenCount := 0
rulesAwaitingAnchor := 0
appellantContext := make(map[uuid.UUID]string, len(rules))
for _, r := range walkRules {
@@ -227,6 +243,17 @@ func Calculate(
continue
}
// Optional-rule suppression (t-paliad-342 / youpcorg#2570).
// Rules tagged priority='optional' don't auto-fire in the
// default timeline; the caller opts in via
// CalcOptions.IncludeOptional. Cascade through skippedIDs so
// children chaining off the suppressed rule also drop — they
// can't compute a date against a missing parent.
if r.Priority == "optional" && !opts.IncludeOptional {
skippedIDs[r.ID] = struct{}{}
continue
}
// SkipRules suppression (t-paliad-265).
// t-paliad-290 (m/paliad#122): when opts.IncludeHidden is set,
// we re-surface the directly-skipped row (faded via IsHidden)
@@ -327,15 +354,43 @@ func Calculate(
// (m/paliad#126 / t-paliad-294). When a rule has a real
// trigger_event_id, that catalog event is the actual semantic
// anchor — not the parent_id node, which is only the calc-time
// arithmetic anchor. Only the user-facing wire fields shift;
// parentRule (and the parent_id chain feeding parentIsCourtSet
// and the calc-time arithmetic below) stays anchored on the
// rule tree.
// arithmetic anchor. Only the user-facing wire fields shift
// here; the calc-time anchor logic for trigger_event_id rules
// lives just below.
var triggerEventAnchor time.Time
var hasTriggerEventAnchor bool
if r.TriggerEventID != nil {
if te, ok := triggerEventByID[*r.TriggerEventID]; ok {
d.ParentRuleCode = te.Code
d.ParentRuleName = te.NameDE
d.ParentRuleNameEN = te.Name
if td, ok := triggerAnchorByCode[te.Code]; ok {
triggerEventAnchor = td
hasTriggerEventAnchor = true
}
}
// Trigger-event semantic-anchor suppression (t-paliad-342 /
// youpcorg#2568). When a rule has an explicit trigger_event_id
// but the caller hasn't supplied a date for that event via
// CalcOptions.TriggerEventAnchors, the engine refuses to
// fabricate a date off the proceeding's trigger date — the
// rule's semantic anchor is the event itself, not the SoC.
// Render IsConditional with empty dates and propagate via
// courtSet so descendants chaining off this rule also surface
// as conditional rather than projecting fictional dates.
if !hasTriggerEventAnchor {
d.IsConditional = true
d.IsCourtSet = true
d.DueDate = ""
d.OriginalDate = ""
courtSet[r.ID] = true
rulesAwaitingAnchor++
if r.SubmissionCode != nil {
skippedIDs[r.ID] = struct{}{}
}
deadlines = append(deadlines, d)
continue
}
}
@@ -379,6 +434,20 @@ func Calculate(
}
}
// Trigger-event anchor wins over the bucket logic below: a
// zero-duration rule with trigger_event_id is "occurs on the
// trigger event's date". Anchor missing was already caught
// above (suppression branch).
if hasTriggerEventAnchor {
d.DueDate = triggerEventAnchor.Format("2006-01-02")
d.OriginalDate = d.DueDate
if r.SubmissionCode != nil {
computed[*r.SubmissionCode] = triggerEventAnchor
}
deadlines = append(deadlines, d)
continue
}
if r.ParentID == nil && !r.IsCourtSet {
// Bucket 1: timeline anchor.
d.IsRootEvent = true
@@ -457,11 +526,19 @@ func Calculate(
continue
}
// Anchor: prefer alt-anchor (e.g. priority_date for
// epa.grant.exa publish) when supplied, then parent's computed
// date (or user override), then trigger date.
// Anchor priority:
// 1. trigger_event_id semantic anchor (t-paliad-342) — when
// the rule has trigger_event_id and the caller supplied a
// date in TriggerEventAnchors, that date wins over the
// parent chain AND the priority_date alt-anchor. The
// missing-anchor case was already short-circuited above.
// 2. priority_date alt-anchor (epa.grant.exa publish).
// 3. parent's computed date (or user override).
// 4. proceeding trigger date (default fallback).
baseDate := triggerDate
if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
if hasTriggerEventAnchor {
baseDate = triggerEventAnchor
} else if r.AnchorAlt != nil && *r.AnchorAlt == "priority_date" && priorityDate != nil {
baseDate = *priorityDate
} else if r.ParentID != nil {
for _, prev := range rules {
@@ -635,12 +712,13 @@ func Calculate(
}
resp := &Timeline{
ProceedingType: pickedProceeding.Code,
ProceedingName: pickedProceeding.Name,
ProceedingNameEN: pickedProceeding.NameEN,
TriggerDate: triggerDateStr,
Deadlines: deadlines,
HiddenCount: hiddenCount,
ProceedingType: pickedProceeding.Code,
ProceedingName: pickedProceeding.Name,
ProceedingNameEN: pickedProceeding.NameEN,
TriggerDate: triggerDateStr,
Deadlines: deadlines,
HiddenCount: hiddenCount,
RulesAwaitingAnchor: rulesAwaitingAnchor,
}
// Sub-track routing keeps the user-picked proceeding's identity,
// so the trigger-event label rides on `pickedProceeding`.

View File

@@ -0,0 +1,379 @@
package litigationplanner
import (
"context"
"testing"
"github.com/google/uuid"
)
// Tests for t-paliad-342 / youpcorg#2568 + #2570.
//
// Two paired engine semantics:
//
// - Optional rules (priority='optional') don't auto-fire in the
// default timeline; the caller opts in via
// CalcOptions.IncludeOptional.
// - Rules with explicit trigger_event_id anchor on the trigger
// event's date (CalcOptions.TriggerEventAnchors keyed by
// trigger_events.code). Missing anchor = render conditional
// instead of fabricating a date off the proceeding's trigger date.
// stubCatalogWithTriggers extends stubCatalog with a trigger-events
// map so the engine can resolve TriggerEventID → code for the
// trigger-anchor branch. stubCatalog from before_court_set_anchor_test.go
// returns an empty map, which suffices for tests that don't exercise
// trigger_event_id; here we need real entries.
type stubCatalogWithTriggers struct {
stubCatalog
triggerEvents map[int64]TriggerEvent
}
func (s *stubCatalogWithTriggers) LoadTriggerEventsByIDs(_ context.Context, ids []int64) (map[int64]TriggerEvent, error) {
out := make(map[int64]TriggerEvent, len(ids))
for _, id := range ids {
if te, ok := s.triggerEvents[id]; ok {
out[id] = te
}
}
return out, nil
}
// mandatory_socRule builds a minimal SoC root rule + the proceeding
// type wrapper that nearly every test below needs.
func mandatorySocFixture(t *testing.T) (ProceedingType, Rule, uuid.UUID) {
t.Helper()
jurisdiction := "UPC"
procID := 1
pt := ProceedingType{
ID: procID,
Code: "upc.inf.cfi",
Name: "Verletzungsverfahren",
Jurisdiction: &jurisdiction,
IsActive: true,
}
socID, _ := uuid.NewRandom()
socCode := "upc.inf.cfi.soc"
procIDPtr := &procID
str := func(s string) *string { return &s }
soc := Rule{
ID: socID,
ProceedingTypeID: procIDPtr,
ParentID: nil,
SubmissionCode: &socCode,
Name: "Klageerhebung",
NameEN: "SoC",
PrimaryParty: str("claimant"),
DurationValue: 0,
DurationUnit: "months",
Timing: str("after"),
SequenceOrder: 0,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
}
return pt, soc, socID
}
// TestCalculate_TriggerEventIDWithoutAnchor_Suppressed verifies the
// RoP.109.5 bug from youpcorg#2568: a rule with trigger_event_id and
// no parent_id must NOT fall back to the proceeding's trigger date.
// The buggy behaviour rendered the rule with a fabricated date 2 weeks
// before the user's SoC date.
func TestCalculate_TriggerEventIDWithoutAnchor_Suppressed(t *testing.T) {
ctx := context.Background()
pt, soc, _ := mandatorySocFixture(t)
str := func(s string) *string { return &s }
procIDPtr := &pt.ID
ruleID, _ := uuid.NewRandom()
ruleCode := "upc.inf.cfi.rop_109_5"
rop109_5Trigger := int64(49)
rop109_5 := Rule{
ID: ruleID,
ProceedingTypeID: procIDPtr,
ParentID: nil,
SubmissionCode: &ruleCode,
Name: "Vorbereitung mündliche Verhandlung",
NameEN: "Oral hearing preparation",
PrimaryParty: str("both"),
DurationValue: 2,
DurationUnit: "weeks",
Timing: str("before"),
SequenceOrder: 100,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
TriggerEventID: &rop109_5Trigger,
}
cat := &stubCatalogWithTriggers{
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, rop109_5}},
triggerEvents: map[int64]TriggerEvent{
49: {ID: 49, Code: "oral_hearing", Name: "Oral hearing", NameDE: "Mündliche Verhandlung"},
},
}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
rop, ok := byCode[ruleCode]
if !ok {
t.Fatalf("RoP.109.5 missing from timeline; want present with conditional-no-date")
}
if rop.DueDate != "" {
t.Errorf("RoP.109.5: DueDate=%q, want empty (no oral_hearing anchor supplied)", rop.DueDate)
}
if !rop.IsConditional {
t.Errorf("RoP.109.5: IsConditional=%v, want true", rop.IsConditional)
}
if timeline.RulesAwaitingAnchor != 1 {
t.Errorf("RulesAwaitingAnchor=%d, want 1", timeline.RulesAwaitingAnchor)
}
}
// TestCalculate_TriggerEventIDWithAnchor_Renders verifies the
// caller-supplied trigger-event anchor produces correct arithmetic.
// 2 weeks before 2026-10-15 = 2026-10-01.
func TestCalculate_TriggerEventIDWithAnchor_Renders(t *testing.T) {
ctx := context.Background()
pt, soc, _ := mandatorySocFixture(t)
str := func(s string) *string { return &s }
procIDPtr := &pt.ID
ruleID, _ := uuid.NewRandom()
ruleCode := "upc.inf.cfi.rop_109_5"
rop109_5Trigger := int64(49)
rop109_5 := Rule{
ID: ruleID,
ProceedingTypeID: procIDPtr,
ParentID: nil,
SubmissionCode: &ruleCode,
Name: "Vorbereitung mündliche Verhandlung",
NameEN: "Oral hearing preparation",
PrimaryParty: str("both"),
DurationValue: 2,
DurationUnit: "weeks",
Timing: str("before"),
SequenceOrder: 100,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
TriggerEventID: &rop109_5Trigger,
}
cat := &stubCatalogWithTriggers{
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, rop109_5}},
triggerEvents: map[int64]TriggerEvent{
49: {ID: 49, Code: "oral_hearing", Name: "Oral hearing", NameDE: "Mündliche Verhandlung"},
},
}
opts := CalcOptions{
TriggerEventAnchors: map[string]string{
"oral_hearing": "2026-10-15",
},
}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
rop := byCode[ruleCode]
if rop.DueDate != "2026-10-01" {
t.Errorf("RoP.109.5: DueDate=%q, want 2026-10-01 (2 weeks before oral_hearing 2026-10-15)", rop.DueDate)
}
if rop.IsConditional {
t.Errorf("RoP.109.5: IsConditional=%v, want false (anchor supplied)", rop.IsConditional)
}
if timeline.RulesAwaitingAnchor != 0 {
t.Errorf("RulesAwaitingAnchor=%d, want 0", timeline.RulesAwaitingAnchor)
}
}
// TestCalculate_MandatoryRule_RendersByDefault is the control case for
// the optional-suppression fix: mandatory rules render with their
// computed dates by default. Prevents regression where the optional
// filter accidentally drops mandatory rules too.
func TestCalculate_MandatoryRule_RendersByDefault(t *testing.T) {
ctx := context.Background()
pt, soc, socID := mandatorySocFixture(t)
str := func(s string) *string { return &s }
procIDPtr := &pt.ID
replyID, _ := uuid.NewRandom()
replyCode := "upc.inf.cfi.reply"
reply := Rule{
ID: replyID,
ProceedingTypeID: procIDPtr,
ParentID: &socID,
SubmissionCode: &replyCode,
Name: "Klageerwiderung",
NameEN: "Reply to SoC",
PrimaryParty: str("defendant"),
DurationValue: 3,
DurationUnit: "months",
Timing: str("after"),
SequenceOrder: 10,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
}
cat := &stubCatalogWithTriggers{
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, reply}},
}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
got, ok := byCode[replyCode]
if !ok {
t.Fatalf("mandatory reply rule missing from default timeline")
}
if got.DueDate != "2026-08-26" {
t.Errorf("reply: DueDate=%q, want 2026-08-26 (3 months after SoC)", got.DueDate)
}
}
// TestCalculate_OptionalRule_SuppressedByDefault pins the
// youpcorg#2570 fix: priority='optional' rules don't render in the
// default timeline. The caller opts in via IncludeOptional=true.
func TestCalculate_OptionalRule_SuppressedByDefault(t *testing.T) {
ctx := context.Background()
pt, soc, socID := mandatorySocFixture(t)
str := func(s string) *string { return &s }
procIDPtr := &pt.ID
confID, _ := uuid.NewRandom()
confCode := "upc.inf.cfi.rop_262_2"
conf := Rule{
ID: confID,
ProceedingTypeID: procIDPtr,
ParentID: &socID,
SubmissionCode: &confCode,
Name: "Erwiderung Vertraulichkeitsantrag",
NameEN: "Reply to confidentiality motion",
PrimaryParty: str("both"),
DurationValue: 14,
DurationUnit: "days",
Timing: str("after"),
SequenceOrder: 20,
IsActive: true,
LifecycleState: "published",
Priority: "optional",
}
cat := &stubCatalogWithTriggers{
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, conf}},
}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{}, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
for _, d := range timeline.Deadlines {
if d.Code == confCode {
t.Errorf("optional R.262(2) rule rendered in default timeline (DueDate=%q); want suppressed", d.DueDate)
}
}
}
// TestCalculate_OptionalRule_RendersWithIncludeOptional verifies the
// opt-in path: when the caller passes IncludeOptional=true, optional
// rules show up in the timeline with their computed dates.
func TestCalculate_OptionalRule_RendersWithIncludeOptional(t *testing.T) {
ctx := context.Background()
pt, soc, socID := mandatorySocFixture(t)
str := func(s string) *string { return &s }
procIDPtr := &pt.ID
confID, _ := uuid.NewRandom()
confCode := "upc.inf.cfi.rop_262_2"
conf := Rule{
ID: confID,
ProceedingTypeID: procIDPtr,
ParentID: &socID,
SubmissionCode: &confCode,
Name: "Erwiderung Vertraulichkeitsantrag",
NameEN: "Reply to confidentiality motion",
PrimaryParty: str("both"),
DurationValue: 14,
DurationUnit: "days",
Timing: str("after"),
SequenceOrder: 20,
IsActive: true,
LifecycleState: "published",
Priority: "optional",
}
cat := &stubCatalogWithTriggers{
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc, conf}},
}
timeline, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", CalcOptions{IncludeOptional: true}, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
byCode := make(map[string]TimelineEntry, len(timeline.Deadlines))
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
got, ok := byCode[confCode]
if !ok {
t.Fatalf("optional R.262(2) missing from timeline despite IncludeOptional=true")
}
// R.262(2) is the "optional opposing-side" pattern (priority=optional,
// primary_party=both, parent=SoC root) — the engine renders this as
// IsConditional (no concrete date) per the t-paliad-289 logic
// preserved in the walk. The point of this test is that the rule
// is no longer suppressed wholesale by the t-paliad-342 default —
// it surfaces, just with the conditional-render UX.
if !got.IsConditional {
t.Errorf("R.262(2): IsConditional=%v, want true (optional opposing-side, no real trigger recorded)", got.IsConditional)
}
}
// TestCalculate_TriggerEventID_MalformedAnchor_Errors ensures
// malformed dates in TriggerEventAnchors fail fast at the top of the
// engine, before any rule walking — same protocol as AnchorOverrides.
func TestCalculate_TriggerEventID_MalformedAnchor_Errors(t *testing.T) {
ctx := context.Background()
pt, soc, _ := mandatorySocFixture(t)
cat := &stubCatalogWithTriggers{
stubCatalog: stubCatalog{pt: pt, rules: []Rule{soc}},
}
opts := CalcOptions{
TriggerEventAnchors: map[string]string{
"oral_hearing": "15-10-2026", // wrong format
},
}
_, err := Calculate(ctx, "upc.inf.cfi", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
if err == nil {
t.Fatalf("Calculate: want error on malformed TriggerEventAnchors date, got nil")
}
}

View File

@@ -334,6 +334,25 @@ type CalcOptions struct {
// filter applied) so a stale frontend chip doesn't break the
// timeline render — see IsValidAppealTarget.
AppealTarget string
// IncludeOptional surfaces rules with priority='optional' in the
// default timeline (t-paliad-342 / youpcorg#2570). Default false:
// optional rules don't auto-fire alongside mandatory ones. The
// caller (paliad /tools/procedures, youpc.org/deadlines) wires this
// to a user-facing "show optional applications" toggle.
IncludeOptional bool
// TriggerEventAnchors supplies concrete dates for procedural events
// referenced by rules' trigger_event_id (t-paliad-342 / youpcorg#2568).
// Key = paliad.trigger_events.code (e.g. "oral_hearing"), value =
// YYYY-MM-DD. When a rule carries an explicit trigger_event_id, that
// catalog event is the authoritative semantic anchor: arithmetic
// resolves against TriggerEventAnchors[code] if set, otherwise the
// rule is suppressed as IsConditional (no fabricated date off the
// user's trigger date). Empty map = engine never anchors on a
// trigger event, so every rule with trigger_event_id surfaces as
// conditional.
TriggerEventAnchors map[string]string
}
// ProjectHint scopes a Catalog call to a specific project. Paliad's
@@ -375,6 +394,13 @@ type Timeline struct {
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
HiddenCount int `json:"hiddenCount"`
// RulesAwaitingAnchor counts rules suppressed because their
// trigger_event_id anchor date wasn't supplied via
// CalcOptions.TriggerEventAnchors (t-paliad-342). Such rules still
// render in the timeline as IsConditional (no date) — the field
// gives the caller a single integer for "N rules waiting on an
// anchor" UI affordances + telemetry.
RulesAwaitingAnchor int `json:"rulesAwaitingAnchor,omitempty"`
}
// TimelineEntry matches the frontend's CalculatedDeadline TypeScript
@@ -505,7 +531,17 @@ type RuleCalculationProceeding struct {
// FristenrechnerType mirrors the /api/tools/proceeding-types response
// metadata.
//
// ID is the paliad.proceeding_types primary key. Surfaces so frontend
// pickers (Litigation Builder add-proceeding, fristenrechner-wizard
// project prefill) can POST the FK directly without a code→id round
// trip. Historically code-keyed; the Litigation Builder POSTing
// proceeding_type_id (int) to /api/builder/scenarios/{id}/proceedings
// forced surfacing the id (t-paliad-345 — the missing id meant the
// POST silently sent body={} and the "+ Verfahren hinzufügen" button
// did nothing).
type FristenrechnerType struct {
ID int `json:"id"`
Code string `json:"code"`
Name string `json:"name"`
NameEN string `json:"nameEN"`

View File

@@ -0,0 +1,50 @@
package litigationplanner
import (
"encoding/json"
"strings"
"testing"
)
// TestFristenrechnerType_WireShapeIncludesID is the regression test for
// t-paliad-345: the /api/tools/proceeding-types JSON response must
// include `id` so frontend pickers (Litigation Builder add-proceeding,
// fristenrechner-wizard project prefill) can POST proceeding_type_id
// directly without a code→id round trip. When the id was missing the
// Litigation Builder "+ Verfahren hinzufügen" button silently dropped
// the proceeding_type_id from the POST body (JSON.stringify omits
// undefined keys), the server rejected with 400, and the client
// swallowed the error — user-visible symptom was "nothing happens".
func TestFristenrechnerType_WireShapeIncludesID(t *testing.T) {
in := FristenrechnerType{
ID: 42,
Code: "upc.inf.cfi",
Name: "UPC Verletzungsverfahren",
NameEN: "UPC Infringement Action",
Group: "UPC",
}
b, err := json.Marshal(in)
if err != nil {
t.Fatalf("marshal: %v", err)
}
got := string(b)
if !strings.Contains(got, `"id":42`) {
t.Errorf("missing id in wire shape: %s", got)
}
for _, want := range []string{`"code":"upc.inf.cfi"`, `"nameEN":"UPC Infringement Action"`} {
if !strings.Contains(got, want) {
t.Errorf("missing %q in wire shape: %s", want, got)
}
}
// Round-trip — a client that posts the id back to /api/builder/
// scenarios/{id}/proceedings should see it preserved as an integer
// (paliad.scenario_proceedings.proceeding_type_id is INT, not UUID).
var out FristenrechnerType
if err := json.Unmarshal(b, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if out.ID != 42 {
t.Errorf("id lost on round-trip: got %d want 42", out.ID)
}
}