diff --git a/exports/screenshots/paliad-348-after-upc-inf-cfi.png b/exports/screenshots/paliad-348-after-upc-inf-cfi.png new file mode 100644 index 0000000..c09c213 Binary files /dev/null and b/exports/screenshots/paliad-348-after-upc-inf-cfi.png differ diff --git a/exports/screenshots/paliad-348-before-upc-inf-cfi.png b/exports/screenshots/paliad-348-before-upc-inf-cfi.png new file mode 100644 index 0000000..4c09ba6 Binary files /dev/null and b/exports/screenshots/paliad-348-before-upc-inf-cfi.png differ diff --git a/frontend/src/client/builder.ts b/frontend/src/client/builder.ts index 81e4c7e..adc8115 100644 --- a/frontend/src/client/builder.ts +++ b/frontend/src/client/builder.ts @@ -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[] { return out; } +// scenarioFlagsToBoolMap narrows the jsonb-shape scenario_flags blob +// (`{key: true|false|null|other}`) to the strict `Record` +// shape filterByDetailMode consumes. The rule:=true|false per-rule +// deviation keys flow through verbatim (their truthiness IS the override +// signal isRuleSelected reads). +function scenarioFlagsToBoolMap(flags: Record): Record { + const out: Record = {}; + for (const [k, v] of Object.entries(flags)) { + if (typeof v === "boolean") out[k] = v; + } + return out; +} + // ──────────────────────────────────────────────────────────────────────────── // Actions // ──────────────────────────────────────────────────────────────────────────── diff --git a/frontend/src/client/verfahrensablauf.ts b/frontend/src/client/verfahrensablauf.ts index 2801ecf..529685b 100644 --- a/frontend/src/client/verfahrensablauf.ts +++ b/frontend/src/client/verfahrensablauf.ts @@ -176,6 +176,20 @@ const APPEAL_TARGET_PROCEEDINGS = new Set([ "upc.apl.unified", ]); +// hasOptionalOptIn — true when any `rule:=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: awareness +// of its own so without this layer the pick would silently no-op. +// (t-paliad-348) +function hasOptionalOptIn(flags: Record): 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:=true` deviation + // is set on scenarioFlags so an "Aufnehmen"-ed optional in + // "selected" mode still surfaces — the engine doesn't read + // rule: 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; diff --git a/frontend/src/client/views/verfahrensablauf-core.test.ts b/frontend/src/client/views/verfahrensablauf-core.test.ts index 0f49534..c10d40b 100644 --- a/frontend/src/client/views/verfahrensablauf-core.test.ts +++ b/frontend/src/client/views/verfahrensablauf-core.test.ts @@ -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 }; + 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(); + }); +}); diff --git a/frontend/src/client/views/verfahrensablauf-core.ts b/frontend/src/client/views/verfahrensablauf-core.ts index bed1df9..06148a8 100644 --- a/frontend/src/client/views/verfahrensablauf-core.ts +++ b/frontend/src/client/views/verfahrensablauf-core.ts @@ -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; } const PARTY_CLASS: Record = { @@ -1118,6 +1138,10 @@ export async function calculateDeadlines(params: CalcParams): Promise 0 + ? params.triggerEventAnchors + : undefined, }), }); if (!resp.ok) { diff --git a/internal/handlers/fristenrechner.go b/internal/handlers/fristenrechner.go index a123484..2642865 100644 --- a/internal/handlers/fristenrechner.go +++ b/internal/handlers/fristenrechner.go @@ -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"}) @@ -130,15 +143,17 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) { } resp, err := dbSvc.fristenrechner.Calculate(r.Context(), req.ProceedingType, req.TriggerDate, services.CalcOptions{ - PriorityDateStr: req.PriorityDate, - Flags: req.Flags, - AnchorOverrides: req.AnchorOverrides, - CourtID: req.CourtID, - PerCardAppellant: addendum.PerCardAppellant, - SkipRules: addendum.SkipRules, - IncludeCCRFor: addendum.IncludeCCRFor, - IncludeHidden: req.IncludeHidden, - AppealTarget: req.AppealTarget, + PriorityDateStr: req.PriorityDate, + Flags: req.Flags, + AnchorOverrides: req.AnchorOverrides, + CourtID: req.CourtID, + PerCardAppellant: addendum.PerCardAppellant, + SkipRules: addendum.SkipRules, + IncludeCCRFor: addendum.IncludeCCRFor, + IncludeHidden: req.IncludeHidden, + AppealTarget: req.AppealTarget, + IncludeOptional: req.IncludeOptional, + TriggerEventAnchors: req.TriggerEventAnchors, }) if err != nil { if errors.Is(err, services.ErrUnknownProceedingType) {