Merge: t-paliad-197 — Determinator row-cascade Slice 2 (project-driven narrowing + auto-walk)

This commit is contained in:
mAi
2026-05-16 00:50:59 +02:00
6 changed files with 508 additions and 50 deletions

View File

@@ -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: <reference>" 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<string, string> = {
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: <reference>" 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: <reference>" 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
? `<span class="fristen-row-answer-icon" aria-hidden="true">${escHtml(row.pickedIcon)}</span>`
: "";
// 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"
? `<span class="fristen-row-prefilled-tag" data-i18n="deadlines.row.prefilled.from_akte">aus Akte</span>`
? (row.prefilledReference
? `<span class="fristen-row-prefilled-tag">
<span data-i18n="deadlines.row.prefilled.from_akte">aus Akte</span>:
<span class="fristen-row-prefilled-ref">${escHtml(row.prefilledReference)}</span>
</span>`
: `<span class="fristen-row-prefilled-tag" data-i18n="deadlines.row.prefilled.from_akte">aus Akte</span>`)
: "";
return `<div class="fristen-row ${stateClass}"${dataKindAttr}${dataIdAttr}>
<div class="fristen-row-head">
@@ -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<HTMLButtonElement>(".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 = `
<span class="fristen-row-autowalk-tip-icon" aria-hidden="true">&#9432;</span>
<span class="fristen-row-autowalk-tip-text" data-i18n="deadlines.row.autowalk.tooltip">
${escHtml(t("deadlines.row.autowalk.tooltip"))}
</span>
<button type="button" class="fristen-row-autowalk-tip-dismiss"
aria-label="${escAttr(t("deadlines.row.autowalk.dismiss"))}">
&times;
</button>`;
firstPrefilled.parentElement?.insertBefore(tip, firstPrefilled);
tip.querySelector<HTMLButtonElement>(".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();
}
});

View File

@@ -377,6 +377,8 @@ const translations: Record<Lang, Record<string, string>> = {
"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<Lang, Record<string, string>> = {
"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",

View File

@@ -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"

View File

@@ -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;

View File

@@ -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
}

View File

@@ -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)
}
}
}