feat(determinator/slice-3b): scope B1 cascade by project's proceeding type

m's 2026-05-08 18:09 spec: "if we have the project type defined, we
should only have events available that match the type of project /
type of case." Slice 3b wires the project's proceeding_type into the
cascade narrowing alongside the inbox chip and ad-hoc context.

Three inputs feed the cascade now, in priority order:

  1. Inbox chip            (cms / bea / posteingang) — user override.
  2. Ad-hoc Step 1 chip    (upc / de / epa / dpma).
  3. Project's proceeding  (Step 1 picked Akte → proceeding_type_id →
     proceeding_types.code → forum prefix).

activeForumOnPage() returns the first non-null value. The B1
cascade's inboxFilterAllowsForums consults this so a user landing on
/tools/fristenrechner?project=<uuid>&path=b&mode=tree gets the
narrowed cascade automatically — no chip clicks required. The chip
can still override at the top of the panel.

Pieces:

  - ProjectOption gains optional proceeding_type_id (already on the
    JSON; just declared so TypeScript can read it).
  - cachedProceedingTypes Map<int, string> is populated once on init
    via /api/proceeding-types-db and cached for the page lifetime.
  - forumFromProceedingCode() maps "UPC_INF" / "DE_NULL" / "EPA_OPP"
    / "EP_GRANT" / "DPMA_OPP" → upc / de / epa / dpma. EP_ and EPA_
    both hit the EPA branch since EP_GRANT belongs to the EPA forum.
  - triggerCascadeRefresh() is called from selectProject /
    selectAdhoc / clearStep1Context + after the async load completes
    so the cascade re-renders when the context changes.

The role variants (Klägerseite vs Beklagtenseite, Berufungskläger vs
-beklagte) are Slice 3c — they require fetching the user's
project_teams.responsibility for the selected project. Project's
forum lands first; role layers on after.

Refs t-paliad-157 / m/paliad#15. Folds in part of #18 (Item A
rule-vs-event collapse) — when the project context narrows the cascade
to one jurisdiction, the rule-vs-event mismatch surface shrinks.
This commit is contained in:
m
2026-05-08 20:15:50 +02:00
parent 6224898f9e
commit 2f27620a5b

View File

@@ -283,6 +283,11 @@ interface ProjectOption {
reference?: string | null;
title: string;
path: string;
// proceeding_type_id is on every paliad.projects row; the JSON
// already includes it. We just declare the field so the Determinator
// (Slice 3b) can scope the cascade by the project's jurisdiction
// without an extra fetch.
proceeding_type_id?: number | null;
}
function escAttr(s: string): string {
@@ -2525,6 +2530,11 @@ function selectProject(project: ProjectOption) {
writeStep1ContextToURL(currentStep1Context);
renderStep1Summary();
showStep2Card();
// Slice 3b: project's proceeding type narrows the B1 cascade if the
// user reaches it via Step 2 → Etwas ist passiert. Refresh here so
// a cascade already on screen (rare but possible via popstate) picks
// up the new narrowing.
triggerCascadeRefresh();
}
function selectAdhoc(forum: AdhocForum) {
@@ -2532,6 +2542,7 @@ function selectAdhoc(forum: AdhocForum) {
writeStep1ContextToURL(currentStep1Context);
renderStep1Summary();
showStep2Card();
triggerCascadeRefresh();
}
function clearStep1Context() {
@@ -2539,6 +2550,7 @@ function clearStep1Context() {
writeStep1ContextToURL(currentStep1Context);
renderStep1Summary();
hideStep2Card();
triggerCascadeRefresh();
}
function renderStep1Summary() {
@@ -2601,15 +2613,24 @@ function initPathwayFork() {
try { localStorage.setItem(PATHWAY_STORAGE_KEY, initial); } catch { /* */ }
}
// Step 1 — fetch projects + render filtered list. Search filters the
// list in-place; click on a row drops the user into Step 2.
// Step 1 — fetch projects + proceeding-types in parallel. Both are
// small + cacheable; both are needed before the cascade narrowing
// can fire correctly. Render the list as soon as projects come in,
// refresh cascade once the proceeding-types map is also populated.
void (async () => {
cachedAkten = await fetchProjects();
const [projects] = await Promise.all([
fetchProjects(),
loadProceedingTypes(),
]);
cachedAkten = projects;
if (currentStep1Context.kind === "project" && currentStep1Context.projectId) {
currentStep1Context.project = cachedAkten.find((p) => p.id === currentStep1Context.projectId);
renderStep1Summary();
}
renderAkteList("");
// Cascade may already be on screen if the user landed with
// ?path=b&project=<uuid>; re-render to apply the now-known forum.
triggerCascadeRefresh();
})();
const akteSearch = document.getElementById("fristen-akte-search") as HTMLInputElement | null;
@@ -3223,17 +3244,84 @@ function applyInboxFilter(ch: InboxChannel) {
}
}
// Slice 3b: cascade narrowing now flows from THREE inputs, in priority
// order. Whichever is set first wins.
//
// 1. Inbox chip (cms / bea / posteingang) — user-clicked override.
// Maps to upc / de / de.
// 2. Ad-hoc chip (Step 1's explore-mode upc / de / epa / dpma).
// 3. Project context (Step 1's selected Akte → proceeding_type_id →
// proceeding_types.code → forum prefix).
//
// activeForumOnPage() returns the first non-null value or null when
// nothing is set. inboxFilterAllowsForums consults this so the B1
// cascade narrows automatically when the user enters Pathway B with a
// project context — no extra clicks needed. The chip can still
// override at the top of the B1 panel.
let cachedProceedingTypes: Map<number, string> = new Map();
async function loadProceedingTypes(): Promise<void> {
try {
const resp = await fetch("/api/proceeding-types-db");
if (!resp.ok) return;
const list = (await resp.json()) as Array<{ id: number; code: string }>;
for (const pt of list) {
cachedProceedingTypes.set(pt.id, pt.code);
}
} catch {
// Anonymous visitor / network blip — leave the map empty; project
// narrowing falls back to "no opinion" and the cascade stays
// wide-open.
}
}
function forumFromProceedingCode(code: string): "upc" | "de" | "epa" | "dpma" | null {
if (code.startsWith("UPC_")) return "upc";
if (code.startsWith("DE_")) return "de";
if (code.startsWith("EPA_") || code.startsWith("EP_")) return "epa";
if (code.startsWith("DPMA_")) return "dpma";
return null;
}
function forumFromProject(p?: ProjectOption | null): "upc" | "de" | "epa" | "dpma" | null {
if (!p || p.proceeding_type_id == null) return null;
const code = cachedProceedingTypes.get(p.proceeding_type_id);
return code ? forumFromProceedingCode(code) : null;
}
function activeForumOnPage(): "upc" | "de" | "epa" | "dpma" | null {
const chipForum = inboxChannelToForum(currentInboxChannel);
if (chipForum !== null) return chipForum;
if (currentStep1Context.kind === "adhoc" && currentStep1Context.adhocForum) {
return currentStep1Context.adhocForum;
}
if (currentStep1Context.kind === "project") {
return forumFromProject(currentStep1Context.project);
}
return null;
}
// inboxFilterAllowsForums returns true when a node with the given
// forums tags should be visible under the current inbox chip. Neutral
// nodes (forums undefined / empty) are always visible. When no inbox
// is active, every node is visible.
// forums tags should be visible. Neutral nodes (forums undefined /
// empty) are always visible. When no forum is active anywhere on the
// page, every node is visible.
function inboxFilterAllowsForums(forums: string[] | undefined): boolean {
if (!forums || forums.length === 0) return true;
const active = inboxChannelToForum(currentInboxChannel);
const active = activeForumOnPage();
if (active === null) return true;
return forums.includes(active);
}
// triggerCascadeRefresh re-renders the B1 cascade if the panel is
// mounted. Call after any change that affects activeForumOnPage()
// (chip click, project selection, ad-hoc selection, clear).
function triggerCascadeRefresh() {
if (eventCategoryTree && document.getElementById("fristen-b1-cascade")) {
renderB1Cascade(readB1PathFromURL());
}
}
async function persistInboxPref(ch: InboxChannel) {
// forum_pref="" clears on the server side (NULL in the DB) — matches
// the EscalationContactID convention in services/user_service.go.