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:
BIN
exports/screenshots/paliad-348-after-upc-inf-cfi.png
Normal file
BIN
exports/screenshots/paliad-348-after-upc-inf-cfi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
BIN
exports/screenshots/paliad-348-before-upc-inf-cfi.png
Normal file
BIN
exports/screenshots/paliad-348-before-upc-inf-cfi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 490 KiB |
@@ -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 {
|
||||
@@ -534,15 +535,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,
|
||||
@@ -877,6 +895,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) {
|
||||
|
||||
@@ -91,6 +91,19 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
// slugs are silently dropped (no filter) so a stale frontend
|
||||
// chip doesn't 400 the request.
|
||||
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 {
|
||||
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,
|
||||
IncludeHidden: req.IncludeHidden,
|
||||
AppealTarget: req.AppealTarget,
|
||||
IncludeOptional: req.IncludeOptional,
|
||||
TriggerEventAnchors: req.TriggerEventAnchors,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnknownProceedingType) {
|
||||
|
||||
Reference in New Issue
Block a user