From a33060e600be7f8618de3ca8555acf2f45df32ec Mon Sep 17 00:00:00 2001 From: mAi Date: Sat, 16 May 2026 00:50:27 +0200 Subject: [PATCH] =?UTF-8?q?feat(t-paliad-197):=20Slice=202=20=E2=80=94=20p?= =?UTF-8?q?roject-driven=20narrowing=20+=20cascade=20auto-walk?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the project context into the Determinator row stack so a UPC INF matter doesn't need to be hand-walked through five obvious cascade picks. Auto-walk descends single-option chains as `is-prefilled` rows, the inbox row vanishes for UPC matters (CMS implied), and the first prefilled row carries the project reference inline ("aus Akte: HL-2024-001"). Backend: `internal/services/proceeding_mapping.go` adds MapLitigationToFristenrechner — single source of truth for bridging the litigation conceptual codes (INF / REV / APP / CCR / AMD / APM / OPP) onto fristenrechner codes (UPC_INF / DE_INF / EPA_OPP / …). Ambiguous combos (APP+DE, ZPO_CIVIL, AMD+DE) return ok=false; callers degrade to "no narrowing" instead of guessing. Table-driven test covers every documented mapping plus the ambiguous-degrade cases. Frontend: `buildRowStack` filters cascade children by project context along the proceeding axis (kebab segment lookup against the project's fristenrechner code); auto-walks while filtered scope narrows to one; caps depth via `cascadeAutoWalkStopAfter` after an "ändern" on a prefilled row so the user lands at an active chip set without the auto-walk re-engaging. Result panel narrows on the post-auto-walk effective slug, not the URL slug. A one-time inline tooltip ("Diese Schritte ergeben sich aus Ihrer Akte") surfaces when ≥2 rows render prefilled — dismissal flag persists in localStorage. Narrowing is purely additive: an Akte without a fristenrechner code (11/11 live projects pre-Slice-5 were NULL) degrades to today's forum-only behaviour. Slice 3 (mobile polish + search relocation) follows. Refs: docs/design-determinator-row-cascade-2026-05-13.md §10 Slice 2 + §4 + §5. --- frontend/src/client/fristenrechner.ts | 345 ++++++++++++++++--- frontend/src/client/i18n.ts | 4 + frontend/src/i18n-keys.ts | 2 + frontend/src/styles/global.css | 66 ++++ internal/services/proceeding_mapping.go | 87 +++++ internal/services/proceeding_mapping_test.go | 54 +++ 6 files changed, 508 insertions(+), 50 deletions(-) create mode 100644 internal/services/proceeding_mapping.go create mode 100644 internal/services/proceeding_mapping_test.go diff --git a/frontend/src/client/fristenrechner.ts b/frontend/src/client/fristenrechner.ts index 5703f54..3cb87df 100644 --- a/frontend/src/client/fristenrechner.ts +++ b/frontend/src/client/fristenrechner.ts @@ -2390,6 +2390,9 @@ function initPathwayFork() { // project.our_side ↔ currentPerspective inside the renderer, so // no separate DOM hint flip is needed on popstate. currentActiveRow = null; + // t-paliad-197: popstate is a fresh navigation — auto-walk should + // re-evaluate from scratch, no stale "ändern-just-clicked" cap. + cascadeAutoWalkStopAfter = null; const path = readPathwayFromURL(); const mode = readBModeFromURL(); showPathway(path, mode); @@ -2541,6 +2544,10 @@ interface RowSpec { pickedValue?: string; pickedLabel?: string; pickedIcon?: string; + // is-prefilled rows can carry an "aus Akte: " annotation. + // Per design §11.2, only the first prefilled row in a stack shows the + // reference; subsequent ones show the plain tag. + prefilledReference?: string; // Cascade-only: the slug of the ancestor node whose children populate // this row's options. Used by the click handler to navigate to a // sibling without losing prefix state. Empty string = cascade root. @@ -2549,6 +2556,10 @@ interface RowSpec { // on this answered row. Always the row's parent slug (ancestor at // depth K-1), which drops descendants and re-actives this depth. cascadeRevertSlug?: string; + // Cascade-only: the depth this row occupies in the post-auto-walk + // trail (0 = root bucket). Used by handleRowEdit to compute the + // auto-walk suppression key. + cascadeDepth?: number; } // currentActiveRow tracks which non-cascade row the user has clicked @@ -2556,6 +2567,20 @@ interface RowSpec { // natural active row. Cleared on every successful pick. let currentActiveRow: string | null = null; +// cascadeAutoWalkStopAfter caps the auto-walk depth for the next +// render. Set when the user clicks "ändern" on an auto-walked cascade +// row at depth K — auto-walk would otherwise re-fire on render and +// undo the edit. The cap stays in place until the user picks a chip +// (which clears it via handleRowPick → renderRowStack → cleared by +// the picker), at which point auto-walk re-engages from the new +// position. null = no cap (auto-walk runs to first branching point). +let cascadeAutoWalkStopAfter: number | null = null; + +// cascadeTooltipDismissedKey backs the "first auto-walk tooltip" flag +// in localStorage. Once the user dismisses the tooltip, the key stays +// for future sessions; we only re-prompt if the key gets cleared. +const cascadeTooltipDismissedKey = "paliad.fristen.cascade.autoWalkTooltipSeen"; + function perspectiveOptionLabel(value: string): string { if (value === "claimant") return t("deadlines.perspective.claimant.short"); if (value === "defendant") return t("deadlines.perspective.defendant.short"); @@ -2575,8 +2600,93 @@ function modeOptionLabel(value: string): string { : t("deadlines.pathway.b.mode.tree"); } +// Slice 2: cascade-segment ↔ fristenrechner-code bridge. The event_categories +// taxonomy uses kebab-case segments under the `cms-eingang.*` buckets to +// represent proceedings (`upc-inf`, `de-bgh-null`, …); paliad.projects +// stores the fristenrechner code in UPPER_SNAKE form (`UPC_INF`, …). +// Most pairs follow a direct kebab↔snake mapping; a few — particularly +// the DE BGH variants and the DPMA BGH Rechtsbeschwerde — were given +// different segment orderings and need an explicit override. Any code +// not in the map degrades to "no proceeding-axis narrowing" — better +// silent than wrong (design §11.6). +const fristenrechnerCodeToCascadeSegment: Record = { + UPC_INF: "upc-inf", + UPC_REV: "upc-rev", + UPC_APP: "upc-app", + UPC_PI: "upc-pi", + DE_INF: "de-inf", + DE_NULL: "de-null", + DE_INF_BGH: "de-bgh-inf", + DE_NULL_BGH: "de-bgh-null", + DPMA_OPP: "dpma-opp", + DPMA_BGH_RB: "dpma-bgh", + EPA_OPP: "epa-opp", + EPA_APP: "epa-app", +}; + +// Set of kebab segments known to be proceeding-axis values. Used to +// distinguish "this child is a proceeding pick" from "this child is a +// sender / bucket / Schriftsatz" — proceeding-axis siblings get filtered +// by project context, others always pass. +const proceedingCascadeSegments = new Set(Object.values(fristenrechnerCodeToCascadeSegment)); + +function fristenrechnerCodeFromProject(p?: ProjectOption | null): string | null { + if (!p || p.proceeding_type_id == null) return null; + return cachedProceedingTypes.get(p.proceeding_type_id) ?? null; +} + +function projectCascadeSegment(p?: ProjectOption | null): string | null { + const code = fristenrechnerCodeFromProject(p); + if (!code) return null; + return fristenrechnerCodeToCascadeSegment[code] ?? null; +} + +// cascadeChildAllowsProject narrows cascade children to those matching +// the project's proceeding code along the proceeding axis. Children +// whose terminal slug segment isn't a known proceeding segment always +// pass (they're orthogonal axes — sender / bucket / Schriftsatz / …). +// Without a project context this is a no-op. +function cascadeChildAllowsProject(child: EventCategoryNode, projectSegment: string | null): boolean { + if (!projectSegment) return true; + const terminal = child.slug.split(".").pop() || ""; + if (!proceedingCascadeSegments.has(terminal)) return true; + return terminal === projectSegment; +} + +function filterCascadeChildren(scope: EventCategoryNode[], projectSegment: string | null): EventCategoryNode[] { + return scope.filter((c) => + inboxFilterAllowsForums(c.forums) + && perspectiveAllowsParty(c.party) + && cascadeChildAllowsProject(c, projectSegment)); +} + +// projectReferenceLabel returns the short identifier used in the "aus +// Akte: " tag. Falls back to a short slice of the UUID when +// the project has no human-readable reference yet. +function projectReferenceLabel(p?: ProjectOption | null): string { + if (!p) return ""; + if (p.reference && p.reference.trim() !== "") return p.reference.trim(); + return p.id ? p.id.slice(0, 8) : ""; +} + function buildRowStack(currentSlug: string): RowSpec[] { const rows: RowSpec[] = []; + const proj = currentStep1Context.kind === "project" ? currentStep1Context.project : undefined; + const hasProject = !!proj; + const projectForum = hasProject ? forumFromProject(proj) : null; + const projectSegment = projectCascadeSegment(proj); + const projectRef = projectReferenceLabel(proj); + + // Once a single "aus Akte: " tag has been rendered, subsequent + // prefilled rows omit the reference (design §11.2) — repeating it on + // every row is just noise. + let referenceTagUsed = false; + const consumeReferenceTag = (): string => { + if (!projectRef) return ""; + if (referenceTagUsed) return ""; + referenceTagUsed = true; + return projectRef; + }; // R0 — Mode. Always present, defaults to "tree" so a fresh visit // renders as answered. ändern flips to active and lets the user @@ -2598,10 +2708,9 @@ function buildRowStack(currentSlug: string): RowSpec[] { // R1 — Perspective. Default null ("Beide") = no filter; the row // renders as answered with that label. t-paliad-164 carry-over: a - // project-bound perspective shows as `is-prefilled` with a "aus Akte" + // project-bound perspective shows as `is-prefilled` with the "aus Akte" // tag in the picked-answer area. const perspectiveValue = currentPerspective ?? ""; - const proj = currentStep1Context.kind === "project" ? currentStep1Context.project : undefined; const expectedFromAkte = ourSideToPerspective(proj?.our_side); const perspectivePrefilled = !!(proj && proj.our_side && expectedFromAkte === currentPerspective && currentPerspective !== null); @@ -2619,33 +2728,46 @@ function buildRowStack(currentSlug: string): RowSpec[] { ], pickedValue: perspectiveValue, pickedLabel: perspectiveOptionLabel(perspectiveValue), + prefilledReference: perspectivePrefilled ? consumeReferenceTag() : undefined, }); - // R2 — Inbox channel. Default null ("Alle") = no filter. Slice 2 will - // hide this row entirely for UPC projects (forum implies CMS); for - // Slice 1 the row is always active/answered like any other. - const inboxValue = currentInboxChannel ?? ""; - rows.push({ - kind: "inbox", - rowId: "inbox", - state: currentActiveRow === "inbox" ? "active" : "answered", - question: t("deadlines.inbox.label"), - options: [ - { value: "cms", label: "CMS", title: t("deadlines.inbox.cms.title") }, - { value: "bea", label: "beA", title: t("deadlines.inbox.bea.title") }, - { value: "posteingang", label: t("deadlines.inbox.posteingang"), title: t("deadlines.inbox.posteingang.title") }, - { value: "", label: t("deadlines.inbox.all") }, - ], - pickedValue: inboxValue, - pickedLabel: inboxOptionLabel(inboxValue), - }); + // R2 — Inbox channel. Hidden entirely when the project's forum is UPC + // (CMS is the only valid inbox for UPC matters, so the question is + // moot — design §5 matrix). For non-UPC projects, ad-hoc explore mode, + // and the no-context case, the row renders as answered with whatever + // "Wo kam es an?" channel was last picked (default: "Alle"). + const r2Hidden = projectForum === "upc" && currentActiveRow !== "inbox"; + if (!r2Hidden) { + const inboxValue = currentInboxChannel ?? ""; + rows.push({ + kind: "inbox", + rowId: "inbox", + state: currentActiveRow === "inbox" ? "active" : "answered", + question: t("deadlines.inbox.label"), + options: [ + { value: "cms", label: "CMS", title: t("deadlines.inbox.cms.title") }, + { value: "bea", label: "beA", title: t("deadlines.inbox.bea.title") }, + { value: "posteingang", label: t("deadlines.inbox.posteingang"), title: t("deadlines.inbox.posteingang.title") }, + { value: "", label: t("deadlines.inbox.all") }, + ], + pickedValue: inboxValue, + pickedLabel: inboxOptionLabel(inboxValue), + }); + } - // R3..Rn — Cascade. The trail is the chain of picked nodes from the - // root down to the deepest pick. Each picked node renders as an - // answered row whose question is the question asked at its parent - // scope. When the deepest pick is non-leaf with surviving children - // (after forum + perspective filters), an active row materialises - // below to demand the next pick. + // R3..Rn — Cascade. URL slug = user-picked deepest position. The + // trail walks the URL slug; each trail node renders as `answered` + // unless it was the sole option at its parent's filtered scope under + // a project context — in which case it's `prefilled` (the user + // effectively had no choice; project context implied this pick). + // + // After the trail, the auto-walk extends the stack with `prefilled` + // rows whenever the remaining scope narrows to a single option under + // a project context; the walk stops at the first branching point, + // empty scope, or leaf — which is where the active row goes (if any). + // Auto-walk does NOT extend the URL — the user-picked slug stays + // explicit. "Ändern" on an auto-walked row sets `cascadeAutoWalkStopAfter` + // so the next render shows the active row at that depth. const trail = eventCategoryTree ? buildBreadcrumb(eventCategoryTree, currentSlug) : []; @@ -2655,47 +2777,105 @@ function buildRowStack(currentSlug: string): RowSpec[] { for (let i = 0; i < trail.length; i++) { const node = trail[i]; - const scope = parentScope.filter((c) => - inboxFilterAllowsForums(c.forums) && perspectiveAllowsParty(c.party)); + const filteredSiblings = filterCascadeChildren(parentScope, projectSegment); + // "Sole option" detection: at this depth, the project-filtered scope + // contains only this node — the pick was implied by context. + const wasSoleOption = hasProject && filteredSiblings.length === 1 + && filteredSiblings[0].slug === node.slug; + const isPrefilled = wasSoleOption; rows.push({ kind: "cascade", rowId: `cascade:${i}`, - state: "answered", + state: isPrefilled ? "prefilled" : "answered", question: parentQuestion, - options: scope.map(nodeToRowOption), + options: filteredSiblings.map(nodeToRowOption), pickedValue: node.slug, pickedLabel: nodeLabel(node), pickedIcon: node.icon, cascadeParentSlug: parentSlug, cascadeRevertSlug: parentSlug, + cascadeDepth: i, + prefilledReference: isPrefilled ? consumeReferenceTag() : undefined, }); parentScope = node.children || []; parentSlug = node.slug; parentQuestion = nodeStepQuestion(node) || parentQuestion; } - // Active cascade row below the trail. Skip if the deepest node is a - // leaf (no further pick) or if all children get filtered out. - const deepest = trail.length > 0 ? trail[trail.length - 1] : null; - if (!deepest || !deepest.is_leaf) { - const activeScope = parentScope.filter((c) => - inboxFilterAllowsForums(c.forums) && perspectiveAllowsParty(c.party)); - if (activeScope.length > 0) { + // Auto-walk extension + active row. Walk down the cascade as long as + // the filtered scope narrows to a single option (with a project + // context that justifies the inference); stop at branching, empty + // scope, or a leaf. The first branching point becomes the active row; + // an empty scope or leaf finalises the stack with no active row. + let walkDepth = trail.length; + const deepestTrail = trail.length > 0 ? trail[trail.length - 1] : null; + let walked: EventCategoryNode | null = null; + + if (!deepestTrail || !deepestTrail.is_leaf) { + // eslint-disable-next-line no-constant-condition + while (true) { + const filtered = filterCascadeChildren(parentScope, projectSegment); + if (filtered.length === 0) break; + const canAutoWalk = filtered.length === 1 + && hasProject + && (cascadeAutoWalkStopAfter === null || walkDepth < cascadeAutoWalkStopAfter); + if (canAutoWalk) { + const node = filtered[0]; + rows.push({ + kind: "cascade", + rowId: `cascade:${walkDepth}`, + state: "prefilled", + question: parentQuestion, + options: filtered.map(nodeToRowOption), + pickedValue: node.slug, + pickedLabel: nodeLabel(node), + pickedIcon: node.icon, + cascadeParentSlug: parentSlug, + // Revert slug for an auto-walked row is the URL-explicit slug + // (= the trail tail). ändern on this row should NOT change the + // URL — it suppresses auto-walk past this depth instead. See + // handleRowEdit's cascade branch. + cascadeRevertSlug: parentSlug, + cascadeDepth: walkDepth, + prefilledReference: consumeReferenceTag(), + }); + walked = node; + parentScope = node.children || []; + parentSlug = node.slug; + parentQuestion = nodeStepQuestion(node) || parentQuestion; + walkDepth += 1; + if (node.is_leaf) break; + continue; + } + // Branching or no-project — render active row at this depth. rows.push({ kind: "cascade", - rowId: `cascade:${trail.length}`, + rowId: `cascade:${walkDepth}`, state: "active", question: parentQuestion, - options: activeScope.map(nodeToRowOption), + options: filtered.map(nodeToRowOption), cascadeParentSlug: parentSlug, cascadeRevertSlug: parentSlug, + cascadeDepth: walkDepth, }); + break; } } + // Stash the effective deepest slug so renderRowStack can pass it to + // runB1Search — the result panel narrows on the auto-walked leaf, not + // the URL slug. + cascadeEffectiveSlug = walked ? walked.slug : (deepestTrail ? deepestTrail.slug : ""); + return rows; } +// cascadeEffectiveSlug is the deepest slug after auto-walk extension — +// used by runB1Search to narrow the concept-card results to the +// effective cascade position, not just the URL-explicit slug. Updated +// by buildRowStack on every render. +let cascadeEffectiveSlug = ""; + function nodeToRowOption(c: EventCategoryNode): RowOption { return { value: c.slug, @@ -2743,8 +2923,17 @@ function rowHtml(row: RowSpec, rowNumber: number): string { const iconHtml = row.pickedIcon ? `` : ""; + // Prefilled tag carries the project reference on the first prefilled + // row only (design §11.2 — subsequent rows show the plain "aus Akte" + // tag). prefilledReference !== "" signals "this row owns the + // reference"; "" / undefined signals plain tag. const prefilledTag = row.state === "prefilled" - ? `aus Akte` + ? (row.prefilledReference + ? ` + aus Akte: + ${escHtml(row.prefilledReference)} + ` + : `aus Akte`) : ""; return `
@@ -2768,6 +2957,7 @@ function renderRowStack(currentSlug: string) { const rows = buildRowStack(currentSlug); stack.innerHTML = rows.map((r, i) => rowHtml(r, i + 1)).join(""); + maybeShowAutoWalkTooltip(stack, rows); // Wire chip picks (active rows). stack.querySelectorAll(".fristen-row-chip").forEach((btn) => { @@ -2799,7 +2989,7 @@ function renderRowStack(currentSlug: string) { }); }); - runB1Search(currentSlug); + runB1Search(cascadeEffectiveSlug); } // handleRowPick routes a chip selection in an active row to the right @@ -2812,6 +3002,7 @@ function handleRowPick(rowId: string, value: string) { const next: BMode = value === "filter" ? "filter" : "tree"; setPathwayURL("b", next); currentActiveRow = null; + cascadeAutoWalkStopAfter = null; showBMode(next); return; } @@ -2819,6 +3010,7 @@ function handleRowPick(rowId: string, value: string) { const next: Perspective = value === "claimant" || value === "defendant" ? value : null; writePerspectiveToURL(next); currentActiveRow = null; + cascadeAutoWalkStopAfter = null; applyPerspective(next); return; } @@ -2827,6 +3019,7 @@ function handleRowPick(rowId: string, value: string) { ? value : null; writeInboxToURL(next); currentActiveRow = null; + cascadeAutoWalkStopAfter = null; applyInboxFilter(next); applyFineForumsFromInbox(next); writeForumsToURL(true); @@ -2835,6 +3028,9 @@ function handleRowPick(rowId: string, value: string) { } if (rowId.startsWith("cascade:")) { currentActiveRow = null; + // A new cascade pick clears any auto-walk cap — the user has + // committed to this branch, so auto-walk can re-engage from here. + cascadeAutoWalkStopAfter = null; navigateB1(value); return; } @@ -2842,9 +3038,11 @@ function handleRowPick(rowId: string, value: string) { // handleRowEdit re-activates an answered row. For non-cascade rows that // flips the row to active in-place (toggle via currentActiveRow) so the -// user can re-pick; the cascade below stays valid. For cascade rows, it -// drops descendants by navigating to the row's revert slug — matching -// today's breadcrumb-click semantic. +// user can re-pick; the cascade below stays valid. For cascade rows it +// either drops descendants by navigating to the row's revert slug — +// matching today's breadcrumb-click semantic — or, when the row was +// auto-walked, caps the auto-walk depth so the row turns active +// in-place without changing the URL. function handleRowEdit(rowId: string) { if (rowId === "mode" || rowId === "perspective" || rowId === "inbox") { currentActiveRow = rowId; @@ -2852,18 +3050,64 @@ function handleRowEdit(rowId: string) { return; } if (rowId.startsWith("cascade:")) { - // Derive the revert slug from the trail: ändern on cascade depth K - // reverts to depth K-1's slug (or "" at root), which drops - // descendants and makes depth K the new active row. const idx = parseInt(rowId.slice("cascade:".length), 10); if (!Number.isFinite(idx)) return; - const trail = buildBreadcrumb(eventCategoryTree || [], readB1PathFromURL()); - const revertSlug = idx > 0 ? (trail[idx - 1]?.slug || "") : ""; + const urlSlug = readB1PathFromURL(); + const trail = buildBreadcrumb(eventCategoryTree || [], urlSlug); + if (idx < trail.length) { + // Row K is within the user-explicit trail — ändern drops to + // the parent slug, which drops descendants AND clears any + // auto-walk suppression so the next pick can extend again. + const revertSlug = idx > 0 ? (trail[idx - 1]?.slug || "") : ""; + currentActiveRow = null; + cascadeAutoWalkStopAfter = null; + navigateB1(revertSlug); + return; + } + // Row K is auto-walked (beyond the URL trail). Suppress auto-walk + // past depth K so the row materialises as active in the next + // render — URL stays at the trail end. + cascadeAutoWalkStopAfter = idx; currentActiveRow = null; - navigateB1(revertSlug); + renderRowStack(urlSlug); } } +// maybeShowAutoWalkTooltip surfaces a one-time hint when ≥ 2 cascade +// rows render in the prefilled (auto-walked) state. Per design §11.3 +// and Q11 (deferred to v2 nice-to-have), the tooltip only appears the +// first time the user lands in a multi-row auto-walk — once dismissed, +// localStorage suppresses it forever. Tooltip is inline (no portal / +// modal): it injects a small banner above the first prefilled row. +function maybeShowAutoWalkTooltip(stack: HTMLElement, rows: RowSpec[]) { + const prefilledCount = rows.filter((r) => r.state === "prefilled" && r.kind === "cascade").length; + if (prefilledCount < 2) return; + let dismissed = false; + try { dismissed = localStorage.getItem(cascadeTooltipDismissedKey) === "1"; } catch { /* private mode */ } + if (dismissed) return; + // Inject the tooltip element. It's a sibling of the row stack, slotted + // just above the first prefilled row via DOM insertion. + const firstPrefilled = stack.querySelector(".fristen-row.is-prefilled"); + if (!firstPrefilled) return; + const tip = document.createElement("div"); + tip.className = "fristen-row-autowalk-tip"; + tip.setAttribute("role", "status"); + tip.innerHTML = ` + + + ${escHtml(t("deadlines.row.autowalk.tooltip"))} + + `; + firstPrefilled.parentElement?.insertBefore(tip, firstPrefilled); + tip.querySelector(".fristen-row-autowalk-tip-dismiss")?.addEventListener("click", () => { + tip.remove(); + try { localStorage.setItem(cascadeTooltipDismissedKey, "1"); } catch { /* private mode */ } + }); +} + // b1SearchSeq guards against out-of-order responses when the user // click-cascades faster than the network. Only the latest invocation @@ -2975,6 +3219,7 @@ async function initB1Cascade() { if (params.get("path") === "b" && params.get("mode") === "tree") { // Always re-render — tree may not have loaded yet on first popstate. currentActiveRow = null; + cascadeAutoWalkStopAfter = null; loadAndRenderB1(); } }); diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index e405a8c..690e901 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -377,6 +377,8 @@ const translations: Record> = { "deadlines.row.reset.title": "Pfad zurücksetzen — alle Cascade-Antworten verwerfen", "deadlines.row.search.link": "Direkt suchen", "deadlines.row.search.link.title": "Direkt nach einer Frist suchen — überspringt den Entscheidungsbaum", + "deadlines.row.autowalk.tooltip": "Diese Schritte ergeben sich aus Ihrer Akte. Klicken Sie „ändern\", um eine Antwort manuell anzupassen.", + "deadlines.row.autowalk.dismiss": "Hinweis schließen", "deadlines.inbox.label": "Wo kam es an?", "deadlines.inbox.cms.title": "UPC — über CMS", "deadlines.inbox.bea.title": "Nationale Verfahren — über beA", @@ -2939,6 +2941,8 @@ const translations: Record> = { "deadlines.row.reset.title": "Reset path — discard all cascade answers", "deadlines.row.search.link": "Search directly", "deadlines.row.search.link.title": "Search directly for a deadline — skips the decision tree", + "deadlines.row.autowalk.tooltip": "These steps were derived from your matter. Click \"edit\" to override any answer manually.", + "deadlines.row.autowalk.dismiss": "Dismiss hint", "deadlines.inbox.label": "Where did it arrive?", "deadlines.inbox.cms.title": "UPC — via CMS", "deadlines.inbox.bea.title": "National-DE — via beA", diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 06dcdbd..37de346 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -1090,6 +1090,8 @@ export type I18nKey = | "deadlines.proceeding.reselect" | "deadlines.proceeding.selected" | "deadlines.reset" + | "deadlines.row.autowalk.dismiss" + | "deadlines.row.autowalk.tooltip" | "deadlines.row.edit" | "deadlines.row.mode.question" | "deadlines.row.prefilled.from_akte" diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 0dad9fd..1cb7315 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -1837,6 +1837,9 @@ input[type="range"]::-moz-range-thumb { } .fristen-row-prefilled-tag { + display: inline-flex; + align-items: center; + gap: 0.25rem; margin-left: 0.4rem; padding: 0.05rem 0.45rem; border-radius: 999px; @@ -1849,6 +1852,69 @@ input[type="range"]::-moz-range-thumb { letter-spacing: 0.02em; } +/* Reference identifier (project.reference / "HL-2024-001") inside the + * prefilled tag. Rendered upper-case and not italic so the project ID + * stands out from the surrounding "aus Akte" italic text. */ +.fristen-row-prefilled-ref { + font-style: normal; + text-transform: none; + font-weight: 600; + color: var(--color-text); +} + +/* t-paliad-197 Slice 2 — auto-walk inline tooltip. Surfaces once per + * user per browser when ≥ 2 cascade rows render in is-prefilled state. + * Slots above the first prefilled row inside the stack so it reads as + * a contextual hint rather than a global banner. The dismiss button + * removes the element and writes a localStorage flag so the tooltip + * never re-surfaces in this browser. */ +.fristen-row-autowalk-tip { + display: flex; + align-items: flex-start; + gap: 0.5rem; + margin: 0.25rem 0 0.4rem; + padding: 0.5rem 0.75rem; + border: 1px solid color-mix(in srgb, var(--color-accent) 35%, var(--color-border)); + border-left-width: 4px; + border-radius: 6px; + background: color-mix(in srgb, var(--color-accent) 8%, transparent); + font-size: 0.85rem; + color: var(--color-text); +} + +.fristen-row-autowalk-tip-icon { + flex-shrink: 0; + font-size: 1.05rem; + line-height: 1.2; + color: var(--color-accent); +} + +.fristen-row-autowalk-tip-text { + flex: 1 1 auto; +} + +.fristen-row-autowalk-tip-dismiss { + flex-shrink: 0; + background: none; + border: none; + color: var(--color-text-muted); + font-size: 1.1rem; + line-height: 1; + cursor: pointer; + padding: 0 0.25rem; + border-radius: 4px; +} + +.fristen-row-autowalk-tip-dismiss:hover { + color: var(--color-text); + background: var(--color-bg-muted); +} + +.fristen-row-autowalk-tip-dismiss:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 1px; +} + .fristen-row-edit { background: none; border: none; diff --git a/internal/services/proceeding_mapping.go b/internal/services/proceeding_mapping.go new file mode 100644 index 0000000..a952425 --- /dev/null +++ b/internal/services/proceeding_mapping.go @@ -0,0 +1,87 @@ +package services + +// proceeding_mapping bridges the two proceeding-type vocabularies in the +// codebase: the **litigation** conceptual category (INF / REV / APP / +// CCR / AMD / APM / ZPO_CIVIL) used by the historical project-binding +// + Pipeline-A rules, and the **fristenrechner** code category (UPC_INF +// / DE_INF / EPA_OPP / …) used by the Determinator cascade + rule +// engine. Post-Phase-3-Slice-5 (t-paliad-186) projects bind to +// fristenrechner codes directly, but the litigation→fristenrechner +// mapping is still needed for the ~40 Pipeline-A rules that remain on +// litigation proceedings and for any other surface that thinks in +// litigation terms. +// +// The mapping table here is the single source of truth — see +// docs/design-determinator-row-cascade-2026-05-13.md §4.2 for the +// design rationale + ambiguity notes. **Never silent FK promotion**: +// every ambiguous case returns ok=false so callers can degrade +// gracefully ("no narrowing") instead of guessing. + +// MapLitigationToFristenrechner returns the fristenrechner code + +// condition flags implied by a (litigationCode, jurisdiction) pair. +// +// Inputs are case-sensitive — pass the canonical upper-snake form +// (e.g. "INF", "UPC"). Unrecognised codes or genuinely ambiguous +// combinations (APP+DE, ZPO_CIVIL+DE) return ok=false with a zero +// fristenrechner code; callers should treat that as "no narrowing" +// and leave the cascade wide-open rather than auto-pick. +// +// Condition flags are returned as a slice so callers can apply them +// alongside the fristenrechner code (CCR+UPC → UPC_INF + with_ccr, +// AMD+UPC → UPC_INF + with_amend). An empty slice means no flag +// context applies. +func MapLitigationToFristenrechner(litigationCode, jurisdiction string) (fristenrechnerCode string, conditionFlags []string, ok bool) { + switch litigationCode { + case "INF": + switch jurisdiction { + case "UPC": + return "UPC_INF", nil, true + case "DE": + return "DE_INF", nil, true + } + case "REV": + switch jurisdiction { + case "UPC": + return "UPC_REV", nil, true + case "DE": + return "DE_NULL", nil, true + } + case "CCR": + // Counterclaim revocation — UPC fold-in is structural (the + // counterclaim lives inside an UPC_INF proceeding with the + // with_ccr flag). DE Nichtigkeit is conceptually the same + // adversarial-validity test, no separate flag. + switch jurisdiction { + case "UPC": + return "UPC_INF", []string{"with_ccr"}, true + case "DE": + return "DE_NULL", nil, true + } + case "AMD": + // Amendment-application bundled into UPC_INF via with_amend. + // No DE / EPA / DPMA analogue today. + if jurisdiction == "UPC" { + return "UPC_INF", []string{"with_amend"}, true + } + case "APP": + // Appeal is ambiguous in DE (OLG vs BGH) and the project + // model doesn't carry the instance hint we'd need to + // disambiguate. UPC is unambiguous. + if jurisdiction == "UPC" { + return "UPC_APP", nil, true + } + case "APM": + // Preliminary injunction / urgency procedure — UPC-only + // concept in the fristenrechner taxonomy. + if jurisdiction == "UPC" { + return "UPC_PI", nil, true + } + case "OPP": + // Opposition — primarily EPA. DPMA has DPMA_OPP but it + // doesn't surface from the litigation vocabulary today. + if jurisdiction == "EPA" { + return "EPA_OPP", nil, true + } + } + return "", nil, false +} diff --git a/internal/services/proceeding_mapping_test.go b/internal/services/proceeding_mapping_test.go new file mode 100644 index 0000000..a3dd466 --- /dev/null +++ b/internal/services/proceeding_mapping_test.go @@ -0,0 +1,54 @@ +package services + +import ( + "reflect" + "testing" +) + +func TestMapLitigationToFristenrechner(t *testing.T) { + type tc struct { + litigation, jurisdiction string + wantCode string + wantFlags []string + wantOK bool + } + cases := []tc{ + // Unambiguous UPC fold-ins. + {"INF", "UPC", "UPC_INF", nil, true}, + {"REV", "UPC", "UPC_REV", nil, true}, + {"APP", "UPC", "UPC_APP", nil, true}, + {"APM", "UPC", "UPC_PI", nil, true}, + // CCR + UPC = UPC_INF with the with_ccr flag. + {"CCR", "UPC", "UPC_INF", []string{"with_ccr"}, true}, + // AMD + UPC = UPC_INF with the with_amend flag. + {"AMD", "UPC", "UPC_INF", []string{"with_amend"}, true}, + // DE first-instance / Nichtigkeit mappings. + {"INF", "DE", "DE_INF", nil, true}, + {"REV", "DE", "DE_NULL", nil, true}, + {"CCR", "DE", "DE_NULL", nil, true}, + // EPA opposition. + {"OPP", "EPA", "EPA_OPP", nil, true}, + // Ambiguous: APP+DE has both OLG and BGH analogues; project + // model can't disambiguate, so degrade. + {"APP", "DE", "", nil, false}, + // No analogue: ZPO_CIVIL → nothing in fristenrechner. + {"ZPO_CIVIL", "DE", "", nil, false}, + // AMD only fires on UPC; DE has no analogue. + {"AMD", "DE", "", nil, false}, + // APM only fires on UPC. + {"APM", "EPA", "", nil, false}, + // Unknown codes / jurisdictions → ok=false. + {"XXX", "UPC", "", nil, false}, + {"INF", "ZZZ", "", nil, false}, + {"", "", "", nil, false}, + } + for _, c := range cases { + gotCode, gotFlags, gotOK := MapLitigationToFristenrechner(c.litigation, c.jurisdiction) + if gotCode != c.wantCode || gotOK != c.wantOK || !reflect.DeepEqual(gotFlags, c.wantFlags) { + t.Errorf("MapLitigationToFristenrechner(%q, %q) = (%q, %v, %v); want (%q, %v, %v)", + c.litigation, c.jurisdiction, + gotCode, gotFlags, gotOK, + c.wantCode, c.wantFlags, c.wantOK) + } + } +}