Merge: t-paliad-348 — port engine semantics to TS calc + manuscript regen (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

This commit is contained in:
mAi
2026-05-28 11:03:58 +02:00
9 changed files with 843 additions and 12 deletions

View File

@@ -24,6 +24,7 @@ import {
type DeadlineResponse,
type Side,
} from "./views/verfahrensablauf-core";
import { filterByDetailMode, type DetailMode } from "./verfahrensablauf-detail-mode";
import { mountAddProceedingPicker, type ProceedingTypeMeta } from "./builder-picker";
import { overlayEventStates, renderTriplet, type ScenarioFlagCatalogEntry } from "./builder-triplet";
import {
@@ -550,15 +551,32 @@ async function hydrateTriplet(
return;
}
const stichtag = proc.stichtag || state.active?.stichtag || todayISO();
// t-paliad-348 — port engine semantics to the Builder triplet calc:
// - `includeOptional` follows the proceeding's detailgrad. The
// "selected" default suppresses optional rules server-side
// (engine drops them); "all_options" opts in so the dimmed
// optional cards can be rendered for the lawyer to opt into.
// - `filterByDetailMode` then runs client-side over what the
// engine emitted, dropping isConditional rows (rules whose
// `trigger_event_id` anchor wasn't supplied) when the lawyer
// is on "selected"/"mandatory_only" — those rules belong to the
// "naked proceeding with options but not always displayed"
// mental model and shouldn't pollute the backbone view.
const detailgrad: DetailMode = (proc.detailgrad as DetailMode) || "selected";
const data: DeadlineResponse | null = await calculateDeadlines({
proceedingType: meta.code,
triggerDate: stichtag,
flags: scenarioFlagsToArray(proc.scenario_flags),
includeOptional: detailgrad === "all_options",
});
const side: Side = (proc.primary_party as Side) || null;
const eventsByRule = buildEventsByRule(proc.id);
const columnsHtml = data
? renderColumnsBody(data, { editable: false, side, showDurations: false })
const scenarioFlagsBool = scenarioFlagsToBoolMap(proc.scenario_flags);
const filteredData: DeadlineResponse | null = data
? { ...data, deadlines: filterByDetailMode(data.deadlines, detailgrad, scenarioFlagsBool) }
: null;
const columnsHtml = filteredData
? renderColumnsBody(filteredData, { editable: false, side, showDurations: false })
: "";
host.innerHTML = renderTriplet({
proceeding: proc,
@@ -927,6 +945,19 @@ function scenarioFlagsToArray(flags: Record<string, unknown>): string[] {
return out;
}
// scenarioFlagsToBoolMap narrows the jsonb-shape scenario_flags blob
// (`{key: true|false|null|other}`) to the strict `Record<string, boolean>`
// shape filterByDetailMode consumes. The rule:<uuid>=true|false per-rule
// deviation keys flow through verbatim (their truthiness IS the override
// signal isRuleSelected reads).
function scenarioFlagsToBoolMap(flags: Record<string, unknown>): Record<string, boolean> {
const out: Record<string, boolean> = {};
for (const [k, v] of Object.entries(flags)) {
if (typeof v === "boolean") out[k] = v;
}
return out;
}
// ────────────────────────────────────────────────────────────────────────────
// Actions
// ────────────────────────────────────────────────────────────────────────────

View File

@@ -176,6 +176,20 @@ const APPEAL_TARGET_PROCEEDINGS = new Set([
"upc.apl.unified",
]);
// hasOptionalOptIn — true when any `rule:<uuid>=true` override exists in
// the scenarioFlags map (the per-rule "Aufnehmen" deviation written by
// the detail-mode selection-chip in onRuleSelectionToggle). When set we
// flip the engine's IncludeOptional on so the chosen optional rules
// actually reach the response; the engine has no rule:<uuid> awareness
// of its own so without this layer the pick would silently no-op.
// (t-paliad-348)
function hasOptionalOptIn(flags: Record<string, boolean>): boolean {
for (const [k, v] of Object.entries(flags)) {
if (v === true && k.startsWith("rule:")) return true;
}
return false;
}
function hasAppealTarget(proceedingType: string): boolean {
return APPEAL_TARGET_PROCEEDINGS.has(proceedingType);
}
@@ -408,6 +422,16 @@ async function doCalc() {
perCardChoices,
includeHidden: showHidden,
appealTarget,
// t-paliad-348 — match the page-level detail mode to the engine's
// optional-rule suppression. The engine drops optional rules by
// default (IncludeOptional=false); "all_options" mode needs them
// back in the response so filterByDetailMode can dim them. We
// also opt-in whenever any per-rule `rule:<uuid>=true` deviation
// is set on scenarioFlags so an "Aufnehmen"-ed optional in
// "selected" mode still surfaces — the engine doesn't read
// rule:<uuid> overrides, so without this the user's pick would
// silently no-op server-side.
includeOptional: detailMode === "all_options" || hasOptionalOptIn(scenarioFlags),
});
if (seq !== calcSeq) return;
if (!data) return;

View File

@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
type CalculatedDeadline,
type DeadlineResponse,
bucketDeadlinesIntoColumns,
calculateDeadlines,
deadlineCardHtml,
formatDurationLabel,
renderColumnsBody,
@@ -773,3 +774,81 @@ describe("stripLeadingDurationFromNotes — render-side dedup (t-paliad-307)", (
.toBe("Time limit set by the court");
});
});
// Pin the engine-options plumbing surface (t-paliad-348 / yoUPC#178).
// calculateDeadlines must forward `includeOptional` and
// `triggerEventAnchors` straight into the POST body so the Go handler
// (handleFristenrechnerAPI) can pass them into lp.CalcOptions. If a
// future refactor drops the fields, the Builder triplet silently
// reverts to "engine emits optional rules" and the unified
// /tools/procedures page loses its naked-proceeding default.
describe("calculateDeadlines — forwards engine options into request body", () => {
type CapturedRequest = { url: string; body: Record<string, unknown> };
let captured: CapturedRequest | null;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
captured = null;
originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
captured = { url: String(input), body };
return new Response(JSON.stringify({
proceedingType: "x", proceedingName: "x", triggerDate: "2026-01-01", deadlines: [],
}), { status: 200, headers: { "Content-Type": "application/json" } });
}) as typeof globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
test("default call omits includeOptional and triggerEventAnchors", async () => {
await calculateDeadlines({ proceedingType: "upc.inf.cfi", triggerDate: "2026-05-26" });
expect(captured).not.toBeNull();
expect(captured!.body.includeOptional).toBeUndefined();
expect(captured!.body.triggerEventAnchors).toBeUndefined();
});
test("includeOptional=true sends includeOptional: true", async () => {
await calculateDeadlines({
proceedingType: "upc.inf.cfi",
triggerDate: "2026-05-26",
includeOptional: true,
});
expect(captured!.body.includeOptional).toBe(true);
});
test("includeOptional=false is omitted (matches engine default)", async () => {
await calculateDeadlines({
proceedingType: "upc.inf.cfi",
triggerDate: "2026-05-26",
includeOptional: false,
});
expect(captured!.body.includeOptional).toBeUndefined();
});
test("triggerEventAnchors forwarded as object", async () => {
await calculateDeadlines({
proceedingType: "upc.inf.cfi",
triggerDate: "2026-05-26",
triggerEventAnchors: {
"upc.inf.cfi.oral": "2026-09-01",
"upc.inf.cfi.decision": "2026-12-15",
},
});
expect(captured!.body.triggerEventAnchors).toEqual({
"upc.inf.cfi.oral": "2026-09-01",
"upc.inf.cfi.decision": "2026-12-15",
});
});
test("empty triggerEventAnchors is omitted", async () => {
await calculateDeadlines({
proceedingType: "upc.inf.cfi",
triggerDate: "2026-05-26",
triggerEventAnchors: {},
});
expect(captured!.body.triggerEventAnchors).toBeUndefined();
});
});

View File

@@ -271,6 +271,12 @@ export interface DeadlineResponse {
// when the toggle is OFF — so users know there's something to
// re-surface.
hiddenCount?: number;
// rulesAwaitingAnchor (t-paliad-348 / yoUPC#178): number of rules the
// engine suppressed because their `trigger_event_id` anchor wasn't
// supplied via CalcParams.triggerEventAnchors. Mirrors the Go
// Timeline.RulesAwaitingAnchor counter — a single integer surface for
// "N rules waiting on an anchor" UI affordances.
rulesAwaitingAnchor?: number;
}
export interface CourtRow {
@@ -311,6 +317,20 @@ export interface CalcParams {
// endentscheidung | kostenentscheidung | anordnung |
// schadensbemessung | bucheinsicht.
appealTarget?: string;
// t-paliad-348 / yoUPC#178 — surface the engine's two new CalcOptions
// axes to the HTTP boundary:
//
// includeOptional: when true, the engine returns priority='optional'
// rules in the timeline. Default false matches the engine default
// (mandatory backbone only). The /tools/procedures detailgrad
// toggle ("all_options" mode) drives this to true so the dimmed
// optional cards can be rendered for the lawyer to opt into.
// triggerEventAnchors: per-event-code anchor dates the engine
// consults for rules carrying trigger_event_id. Empty/omitted =
// no anchors → such rules render as IsConditional (the engine
// refuses to fabricate a date off the proceeding's trigger date).
includeOptional?: boolean;
triggerEventAnchors?: Record<string, string>;
}
const PARTY_CLASS: Record<string, string> = {
@@ -1118,6 +1138,10 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
: undefined,
includeHidden: params.includeHidden ? true : undefined,
appealTarget: params.appealTarget || undefined,
includeOptional: params.includeOptional ? true : undefined,
triggerEventAnchors: params.triggerEventAnchors && Object.keys(params.triggerEventAnchors).length > 0
? params.triggerEventAnchors
: undefined,
}),
});
if (!resp.ok) {