feat(fristenrechner/inbox-chip): wire inbox into B1 cascade narrowing

Completes the #15 vision: the inbox chip now narrows the B1 decision
tree alongside Pathway A's picker and B2's fine-bucket forum filter.
Picking CMS hides DE / EPA / DPMA cascade entries; picking beA /
Posteingang hides UPC / EPA / DPMA entries. Neutral nodes (top-level
branches, Mündliche Verhandlung sub-states, court-generic events like
Ladung / Kostenfestsetzung) stay visible from every inbox setting so
the user can always reach the cross-jurisdictional middle of the tree.

Migration 065 adds paliad.event_categories.forums (text[]) with a
CHECK on {upc, de, epa, dpma}, a partial GIN index, and a two-step
backfill:

  1. Regex on slug for nodes that carry the forum token explicitly.
     Token-bounded by ^/./- so .dpma doesn't trip the de pattern.
  2. Explicit slug list for stragglers (BGH / BPatG / Versäumnisurteil /
     Hinweisbeschluss are DE-only; r116-eingaben is EPA-only).

NULL stays neutral. Migration applied to live Supabase; tracker at v65.

Backend: EventCategoryNode JSON gains an optional `forums` array;
EventCategoryService.Tree SELECT includes the column and threads it
through to the response.

Frontend: new module-level currentInboxChannel mirrors the chip state
so renderB1Cascade can ask "which forum is active?" without re-deriving
from the URL on every step. inboxFilterAllowsForums(forums) gates each
child node — neutral arrays (undefined / empty) always pass; tagged
arrays must include the active forum. applyInboxFilter re-renders the
cascade so chip clicks reflow B1 in place. Pathway A picker filter
and B2 fine-bucket sync remain orthogonal — same chip, three filters.

Refs m/paliad#15 (B1 follow-up).
This commit is contained in:
m
2026-05-08 16:54:34 +02:00
parent e87929885d
commit 6ef14ddc39
4 changed files with 123 additions and 2 deletions

View File

@@ -2409,6 +2409,9 @@ interface EventCategoryNode {
icon?: string;
sort_order: number;
is_leaf: boolean;
// #15 follow-up: coarse forum tags ('upc' | 'de' | 'epa' | 'dpma').
// Empty / undefined = neutral, node visible from every inbox setting.
forums?: string[];
children?: EventCategoryNode[];
}
@@ -2494,7 +2497,12 @@ function renderB1Cascade(currentSlug: string) {
const trail = buildBreadcrumb(eventCategoryTree, currentSlug);
const node = trail.length > 0 ? trail[trail.length - 1] : null;
const childScope = node ? (node.children || []) : eventCategoryTree;
const rawChildScope = node ? (node.children || []) : eventCategoryTree;
// #15 follow-up: drop children whose forums tag doesn't match the
// active inbox channel. Nodes with no forums (neutral) stay visible
// so the user always reaches court-event / Schriftsatz parents that
// span jurisdictions.
const childScope = rawChildScope.filter((c) => inboxFilterAllowsForums(c.forums));
const breadcrumbHtml = trail.length === 0
? ""
@@ -2804,6 +2812,13 @@ type InboxChannel = "cms" | "bea" | "posteingang" | null;
const INBOX_CHANNEL_VALUES = new Set<string>(["cms", "bea", "posteingang"]);
// currentInboxChannel mirrors the chip's active state so the B1 cascade
// renderer (which lives in a different section of this file) can ask
// "which forum is active right now?" without re-deriving from URL on
// every render. Updated by applyInboxFilter on hydrate / click /
// popstate.
let currentInboxChannel: InboxChannel = null;
function readInboxFromURL(): InboxChannel {
const raw = new URLSearchParams(window.location.search).get("inbox");
return raw && INBOX_CHANNEL_VALUES.has(raw) ? (raw as InboxChannel) : null;
@@ -2854,6 +2869,7 @@ function applyFineForumsFromInbox(ch: InboxChannel) {
}
function applyInboxFilter(ch: InboxChannel) {
currentInboxChannel = ch;
const forum = inboxChannelToForum(ch);
document.querySelectorAll<HTMLButtonElement>(".fristen-inbox-chip").forEach((btn) => {
@@ -2871,6 +2887,25 @@ function applyInboxFilter(ch: InboxChannel) {
const groupForum = g.dataset.forum || "";
g.hidden = forum !== null && groupForum !== forum;
});
// Re-render the B1 cascade so its button set picks up the new forum
// narrowing. Render is a no-op if the tree hasn't loaded yet or the
// cascade DOM isn't mounted (Pathway B not visible) — both guards
// already inside renderB1Cascade.
if (eventCategoryTree) {
renderB1Cascade(readB1PathFromURL());
}
}
// 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.
function inboxFilterAllowsForums(forums: string[] | undefined): boolean {
if (!forums || forums.length === 0) return true;
const active = inboxChannelToForum(currentInboxChannel);
if (active === null) return true;
return forums.includes(active);
}
async function persistInboxPref(ch: InboxChannel) {

View File

@@ -0,0 +1,9 @@
-- Reverse t-paliad-157 / m/paliad#15 follow-up: drop the forums column.
DROP INDEX IF EXISTS paliad.event_categories_forums_gin;
ALTER TABLE paliad.event_categories
DROP CONSTRAINT IF EXISTS event_categories_forums_check;
ALTER TABLE paliad.event_categories
DROP COLUMN IF EXISTS forums;

View File

@@ -0,0 +1,67 @@
-- t-paliad-157 / m/paliad#15 follow-up: tag event_categories with the
-- coarse forums they belong to so the inbox-channel chip can narrow the
-- B1 cascade entry-points (the chip already narrows Pathway A's picker
-- and B2's fine-bucket forum filter; B1 was the last hold-out).
--
-- Allowed values: upc | de | epa | dpma. NULL = "neutral" — node stays
-- reachable from every inbox setting. Mixed-jurisdiction nodes (top-
-- level branches, generic court actions like Ladung / Kostenfestsetzung,
-- mündliche Verhandlung sub-states) intentionally stay NULL.
--
-- Two-step backfill:
--
-- 1. Regex on slug for nodes that carry the forum token explicitly
-- (`upc-`, `.upc`, `-upc`, `.de-`, `-de-`, `.epa-`, `dpma`, …).
-- Tokens are bounded by `^` / dot / dash / `$` so e.g. `.dpma`
-- doesn't accidentally match the `de` rule.
--
-- 2. Explicit slug list for stragglers whose name doesn't carry the
-- token (BGH / BPatG / Versäumnisurteil / Hinweisbeschluss are
-- DE-only; r116-eingaben is EPA-only).
--
-- Anything still NULL after both passes is intentionally neutral.
ALTER TABLE paliad.event_categories
ADD COLUMN forums text[];
ALTER TABLE paliad.event_categories
ADD CONSTRAINT event_categories_forums_check
CHECK (forums IS NULL OR forums <@ ARRAY['upc','de','epa','dpma']::text[]);
COMMENT ON COLUMN paliad.event_categories.forums IS
'Coarse forum tags driving the #15 inbox-channel chip narrowing. '
'Allowed: upc, de, epa, dpma. NULL = neutral (visible from every '
'inbox setting). Empty array is treated the same as NULL by the '
'frontend filter (CHECK constraint allows it for forward '
'compatibility but the migration writes NULL where neutral).';
CREATE INDEX event_categories_forums_gin
ON paliad.event_categories USING GIN (forums)
WHERE forums IS NOT NULL;
UPDATE paliad.event_categories SET forums = ARRAY['upc']
WHERE forums IS NULL AND slug ~ '(^|[\.-])upc([\.-]|$)';
UPDATE paliad.event_categories SET forums = ARRAY['de']
WHERE forums IS NULL AND slug ~ '(^|[\.-])de([\.-]|$)';
UPDATE paliad.event_categories SET forums = ARRAY['epa']
WHERE forums IS NULL AND slug ~ '(^|[\.-])epa([\.-]|$)';
UPDATE paliad.event_categories SET forums = ARRAY['dpma']
WHERE forums IS NULL AND slug ~ '(^|[\.-])dpma([\.-]|$)';
UPDATE paliad.event_categories SET forums = ARRAY['de']
WHERE forums IS NULL AND slug IN (
'beschluss-entscheidung.beschluss-bpatg-beschwerde',
'beschluss-entscheidung.versaeumnisurteil',
'cms-eingang.gericht.endentscheidung.beschluss-bpatg-beschwerde',
'cms-eingang.gericht.endentscheidung.versaeumnisurteil',
'cms-eingang.gericht.hinweisbeschluss',
'ich-moechte-einreichen.berufung.bgh-rb',
'ich-moechte-einreichen.berufung.bpatg-beschwerde'
);
UPDATE paliad.event_categories SET forums = ARRAY['epa']
WHERE forums IS NULL
AND slug = 'ich-moechte-einreichen.spaetere-schriftsaetze.r116-eingaben';

View File

@@ -33,6 +33,11 @@ func NewEventCategoryService(db *sqlx.DB) *EventCategoryService {
// EventCategoryNode is one row in the taxonomy with its children attached.
// JSON shape is what the frontend consumes from
// GET /api/tools/fristenrechner/event-categories.
//
// Forums carries the optional coarse forum tags (#15) — `upc` / `de` /
// `epa` / `dpma`. Empty / nil = "neutral", node stays reachable from
// every inbox-channel chip setting. The frontend uses this to hide
// non-matching subtrees when an inbox is active.
type EventCategoryNode struct {
ID string `json:"id"`
Slug string `json:"slug"`
@@ -45,6 +50,7 @@ type EventCategoryNode struct {
Icon *string `json:"icon,omitempty"`
SortOrder int `json:"sort_order"`
IsLeaf bool `json:"is_leaf"`
Forums []string `json:"forums,omitempty"`
Children []EventCategoryNode `json:"children,omitempty"`
}
@@ -70,6 +76,7 @@ type categoryRow struct {
Icon sql.NullString `db:"icon"`
SortOrder int `db:"sort_order"`
IsLeaf bool `db:"is_leaf"`
Forums pq.StringArray `db:"forums"`
}
// Tree returns the full taxonomy as a list of root nodes with children
@@ -82,7 +89,7 @@ func (s *EventCategoryService) Tree(ctx context.Context) ([]EventCategoryNode, e
SELECT id, parent_id, slug, label_de, label_en,
description_de, description_en,
step_question_de, step_question_en,
icon, sort_order, is_leaf
icon, sort_order, is_leaf, forums
FROM paliad.event_categories
WHERE is_active = true
ORDER BY sort_order ASC, slug ASC
@@ -118,6 +125,9 @@ SELECT id, parent_id, slug, label_de, label_en,
if r.Icon.Valid {
n.Icon = &r.Icon.String
}
if len(r.Forums) > 0 {
n.Forums = []string(r.Forums)
}
nodes[r.ID] = &n
}