Merge: t-paliad-348 — port engine semantics to TS calc + manuscript regen (m/paliad#153)
This commit is contained in:
@@ -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
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user