fix(builder): port engine semantics into Builder triplet calc surface (t-paliad-348)

The Litigation Builder triplet renders /api/tools/fristenrechner output
verbatim and never applied the pre-existing filterByDetailMode pass that
the legacy /tools/verfahrensablauf page uses. With the engine fix
(3c840c0 — pkg/litigationplanner default IncludeOptional=false + trigger
event semantic anchoring) already in main, optional rules are dropped
server-side but rules with an unsatisfied trigger_event_id surface as
IsConditional. Without filterByDetailMode those still rendered as
"abhängig von ..." cards on the triplet, polluting m's "naked
proceeding with options but not always displayed" mental model.

upc.inf.cfi went from 7 mandatory backbone events to 29 visible cards
(22 conditional noise — Lodging of translations, Mängelbeseitigung,
Antrag auf Verweisung, Wiedereinsetzung, ...). Live BEFORE/AFTER
captured in exports/screenshots/.

Fix layers:

- Go handler (internal/handlers/fristenrechner.go): accept
  includeOptional + triggerEventAnchors from request body and
  forward to services.CalcOptions. Default zero values match the
  engine defaults (suppress optionals + no fabricated dates for
  trigger_event_id rules), so the wire is unchanged when callers
  don't set them.

- TS calc surface (frontend/src/client/views/verfahrensablauf-core.ts):
  add the same two fields to CalcParams + forward in the fetch body;
  surface rulesAwaitingAnchor on DeadlineResponse mirroring
  Timeline.RulesAwaitingAnchor.

- Builder triplet (frontend/src/client/builder.ts hydrateTriplet):
  apply filterByDetailMode(detailgrad) before renderColumnsBody, with
  detailgrad sourced from the proceeding row. "selected" (default)
  drops conditional + optional rules; "all_options" passes
  includeOptional=true so the engine returns the optional rules the
  user can opt into.

- Legacy /tools/verfahrensablauf (frontend/src/client/verfahrensablauf.ts):
  pass includeOptional based on detailMode + a small hasOptionalOptIn
  helper so per-rule rule:<uuid>=true deviations still surface their
  optional rule even in "selected" mode (the engine has no rule:<uuid>
  awareness; without the opt-in the user's pick would silently no-op).

Tests:
- frontend/src/client/views/verfahrensablauf-core.test.ts: pin the
  fetch body shape - includeOptional=true and triggerEventAnchors={...}
  round-trip through the request; empty/default values are omitted so
  the wire stays minimal.

bun build + bun test (269 pass) + go vet + go test
./internal/handlers/... ./pkg/litigationplanner/... all clean.

(m/paliad#153)
This commit is contained in:
mAi
2026-05-28 11:01:49 +02:00
parent fcdfba209d
commit a81581878e
7 changed files with 185 additions and 12 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

View File

@@ -24,6 +24,7 @@ import {
type DeadlineResponse, type DeadlineResponse,
type Side, type Side,
} from "./views/verfahrensablauf-core"; } from "./views/verfahrensablauf-core";
import { filterByDetailMode, type DetailMode } from "./verfahrensablauf-detail-mode";
import { mountAddProceedingPicker, type ProceedingTypeMeta } from "./builder-picker"; import { mountAddProceedingPicker, type ProceedingTypeMeta } from "./builder-picker";
import { overlayEventStates, renderTriplet, type ScenarioFlagCatalogEntry } from "./builder-triplet"; import { overlayEventStates, renderTriplet, type ScenarioFlagCatalogEntry } from "./builder-triplet";
import { import {
@@ -534,15 +535,32 @@ async function hydrateTriplet(
return; return;
} }
const stichtag = proc.stichtag || state.active?.stichtag || todayISO(); 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({ const data: DeadlineResponse | null = await calculateDeadlines({
proceedingType: meta.code, proceedingType: meta.code,
triggerDate: stichtag, triggerDate: stichtag,
flags: scenarioFlagsToArray(proc.scenario_flags), flags: scenarioFlagsToArray(proc.scenario_flags),
includeOptional: detailgrad === "all_options",
}); });
const side: Side = (proc.primary_party as Side) || null; const side: Side = (proc.primary_party as Side) || null;
const eventsByRule = buildEventsByRule(proc.id); const eventsByRule = buildEventsByRule(proc.id);
const columnsHtml = data const scenarioFlagsBool = scenarioFlagsToBoolMap(proc.scenario_flags);
? renderColumnsBody(data, { editable: false, side, showDurations: false }) 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({ host.innerHTML = renderTriplet({
proceeding: proc, proceeding: proc,
@@ -877,6 +895,19 @@ function scenarioFlagsToArray(flags: Record<string, unknown>): string[] {
return out; 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 // Actions
// ──────────────────────────────────────────────────────────────────────────── // ────────────────────────────────────────────────────────────────────────────

View File

@@ -176,6 +176,20 @@ const APPEAL_TARGET_PROCEEDINGS = new Set([
"upc.apl.unified", "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 { function hasAppealTarget(proceedingType: string): boolean {
return APPEAL_TARGET_PROCEEDINGS.has(proceedingType); return APPEAL_TARGET_PROCEEDINGS.has(proceedingType);
} }
@@ -408,6 +422,16 @@ async function doCalc() {
perCardChoices, perCardChoices,
includeHidden: showHidden, includeHidden: showHidden,
appealTarget, 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 (seq !== calcSeq) return;
if (!data) 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 { import {
type CalculatedDeadline, type CalculatedDeadline,
type DeadlineResponse, type DeadlineResponse,
bucketDeadlinesIntoColumns, bucketDeadlinesIntoColumns,
calculateDeadlines,
deadlineCardHtml, deadlineCardHtml,
formatDurationLabel, formatDurationLabel,
renderColumnsBody, renderColumnsBody,
@@ -773,3 +774,81 @@ describe("stripLeadingDurationFromNotes — render-side dedup (t-paliad-307)", (
.toBe("Time limit set by the court"); .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 // when the toggle is OFF — so users know there's something to
// re-surface. // re-surface.
hiddenCount?: number; 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 { export interface CourtRow {
@@ -311,6 +317,20 @@ export interface CalcParams {
// endentscheidung | kostenentscheidung | anordnung | // endentscheidung | kostenentscheidung | anordnung |
// schadensbemessung | bucheinsicht. // schadensbemessung | bucheinsicht.
appealTarget?: string; 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> = { const PARTY_CLASS: Record<string, string> = {
@@ -1118,6 +1138,10 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
: undefined, : undefined,
includeHidden: params.includeHidden ? true : undefined, includeHidden: params.includeHidden ? true : undefined,
appealTarget: params.appealTarget || 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) { if (!resp.ok) {

View File

@@ -91,6 +91,19 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
// slugs are silently dropped (no filter) so a stale frontend // slugs are silently dropped (no filter) so a stale frontend
// chip doesn't 400 the request. // chip doesn't 400 the request.
AppealTarget string `json:"appealTarget,omitempty"` AppealTarget string `json:"appealTarget,omitempty"`
// t-paliad-348 / yoUPC#178 — surface the engine's two new
// CalcOptions axes to the HTTP boundary:
//
// IncludeOptional: when true, priority='optional' rules
// surface on the timeline. Default false matches the
// engine's default (mandatory backbone only).
// TriggerEventAnchors: per-event-code anchor dates the
// engine consults for rules carrying trigger_event_id.
// When a rule's anchor is absent the engine renders the
// rule as IsConditional rather than fabricating a date
// off the proceeding's trigger date.
IncludeOptional bool `json:"includeOptional,omitempty"`
TriggerEventAnchors map[string]string `json:"triggerEventAnchors,omitempty"`
} }
if err := json.NewDecoder(r.Body).Decode(&req); err != nil { if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"}) writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
@@ -139,6 +152,8 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
IncludeCCRFor: addendum.IncludeCCRFor, IncludeCCRFor: addendum.IncludeCCRFor,
IncludeHidden: req.IncludeHidden, IncludeHidden: req.IncludeHidden,
AppealTarget: req.AppealTarget, AppealTarget: req.AppealTarget,
IncludeOptional: req.IncludeOptional,
TriggerEventAnchors: req.TriggerEventAnchors,
}) })
if err != nil { if err != nil {
if errors.Is(err, services.ErrUnknownProceedingType) { if errors.Is(err, services.ErrUnknownProceedingType) {