feat(fristenrechner/inbox-chip): persisted forum pre-filter on /tools/fristenrechner

Adds a slim chip strip above the pathway fork on /tools/fristenrechner
so the user can pick the inbox channel they typically work in (CMS for
UPC, beA / Posteingang for national-DE). Three behaviours stack:

  - URL ?inbox=cms|bea|posteingang  per-visit override; lets a colleague
                                    share a CMS-narrowed link without
                                    flipping the recipient's saved pref.
  - /api/me forum_pref              the user's persisted default,
                                    fetched on hydrate when no URL.
  - unset                            picker shows all four groups.

Click behaviour: write URL → apply filter (hide non-matching
.proceeding-group via the new data-forum attributes) → PATCH /api/me.
The "Alle" chip clears both URL and the saved pref. EPA / DPMA fall
out under cms / bea / posteingang; users still reach those via B2
search or by clearing the chip.

Frontend pieces:

  - frontend/src/fristenrechner.tsx — new .fristen-inbox-bar markup
    above the pathway fork; data-forum attributes on each
    .proceeding-group so the filter knows which to hide.
  - frontend/src/client/fristenrechner.ts — initInboxFilter() hydrates
    from URL → /api/me, wires chip clicks (write URL, apply filter,
    PATCH /api/me opportunistically), restores on popstate.
  - frontend/src/client/i18n.ts — 6 new keys (deadlines.inbox.*) DE+EN.
  - frontend/src/i18n-keys.ts — codegen picked up the new keys.
  - frontend/src/styles/global.css — .fristen-inbox-bar /
    .fristen-inbox-chip / --active / --clear styles, all bound to the
    existing --color-* / --color-accent token palette.

The chip writes "" to forum_pref to clear (matching the
EscalationContactID convention from the previous slice). The B2 forum
filter (the 10-bucket finer-grained chip set further down the page)
stays untouched and orthogonal — this slice is the page-top coarse
pre-filter only.

Refs m/paliad#15.
This commit is contained in:
m
2026-05-08 16:31:31 +02:00
parent 06bd276a9c
commit 1df1bc7e40
5 changed files with 231 additions and 4 deletions

View File

@@ -2784,3 +2784,120 @@ function initForumFilter() {
document.addEventListener("DOMContentLoaded", initForumFilter);
// ============================================================================
// m/paliad#15 inbox-channel pre-filter
// ============================================================================
// Coarse forum chip on the page header above the pathway fork. Three
// values: cms (UPC), bea (national-DE), posteingang (national-DE, slower
// channel — same forums as beA). State priority on hydrate:
// 1. URL ?inbox=cms|bea|posteingang (per-visit override; lets a
// colleague share a CMS-narrowed link without flipping anyone's
// saved preference)
// 2. /api/me forum_pref (the user's persisted default)
// 3. unset (no filter; picker shows all groups)
//
// On chip click: write URL + PATCH /api/me + filter Pathway A picker.
// Empty string PATCH body clears the saved preference (matches the
// EscalationContactID convention in services/user_service.go).
type InboxChannel = "cms" | "bea" | "posteingang" | null;
const INBOX_CHANNEL_VALUES = new Set<string>(["cms", "bea", "posteingang"]);
function readInboxFromURL(): InboxChannel {
const raw = new URLSearchParams(window.location.search).get("inbox");
return raw && INBOX_CHANNEL_VALUES.has(raw) ? (raw as InboxChannel) : null;
}
function writeInboxToURL(ch: InboxChannel, replace = false) {
const url = new URL(window.location.href);
if (ch === null) url.searchParams.delete("inbox");
else url.searchParams.set("inbox", ch);
if (replace) window.history.replaceState({}, "", url.toString());
else window.history.pushState({}, "", url.toString());
}
// inboxChannelToForum collapses the channel onto the coarse forum
// bucket the proceeding-group filter understands. cms → upc; beA and
// Posteingang both → de (same set of national-DE proceedings, different
// inbox name). Null = no filter.
function inboxChannelToForum(ch: InboxChannel): "upc" | "de" | null {
if (ch === "cms") return "upc";
if (ch === "bea" || ch === "posteingang") return "de";
return null;
}
function applyInboxFilter(ch: InboxChannel) {
const forum = inboxChannelToForum(ch);
document.querySelectorAll<HTMLButtonElement>(".fristen-inbox-chip").forEach((btn) => {
const slug = btn.dataset.inbox || "";
const isClear = btn.hasAttribute("data-inbox-clear");
const active = (ch !== null && slug === ch) || (ch === null && isClear);
btn.classList.toggle("fristen-inbox-chip--active", active);
btn.setAttribute("aria-pressed", active ? "true" : "false");
});
// Hide non-matching proceeding groups in Pathway A. EPA and DPMA fall
// out under cms/bea/posteingang; users still reach them via the B2
// search or by clearing the chip.
document.querySelectorAll<HTMLElement>(".proceeding-group").forEach((g) => {
const groupForum = g.dataset.forum || "";
g.hidden = forum !== null && groupForum !== forum;
});
}
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.
// Persistence is opportunistic; URL state already won the visit.
try {
await fetch("/api/me", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
credentials: "same-origin",
body: JSON.stringify({ forum_pref: ch === null ? "" : ch }),
});
} catch {
// Network blip on persist isn't worth blocking the UI; the user can
// re-pick on the next visit.
}
}
async function initInboxFilter() {
const bar = document.getElementById("fristen-inbox-bar");
if (!bar) return;
let initial: InboxChannel = readInboxFromURL();
if (initial === null) {
try {
const resp = await fetch("/api/me", { credentials: "same-origin" });
if (resp.ok) {
const me = (await resp.json()) as { forum_pref?: string | null };
if (me.forum_pref && INBOX_CHANNEL_VALUES.has(me.forum_pref)) {
initial = me.forum_pref as InboxChannel;
}
}
} catch {
// Anonymous visitor or transient error — leave the chip unset.
}
}
applyInboxFilter(initial);
bar.querySelectorAll<HTMLButtonElement>(".fristen-inbox-chip").forEach((btn) => {
btn.addEventListener("click", () => {
const isClear = btn.hasAttribute("data-inbox-clear");
const next: InboxChannel = isClear ? null : ((btn.dataset.inbox as InboxChannel) ?? null);
writeInboxToURL(next);
applyInboxFilter(next);
void persistInboxPref(next);
});
});
window.addEventListener("popstate", () => {
applyInboxFilter(readInboxFromURL());
});
}
document.addEventListener("DOMContentLoaded", initInboxFilter);

View File

@@ -320,6 +320,12 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.pathway.b.tree.empty": "Keine Treffer für diesen Pfad.",
"deadlines.pathway.b.tree.reset": "Neu starten",
"deadlines.pathway.b.tree.start_question": "Was ist passiert?",
"deadlines.inbox.label": "Wo kam es an?",
"deadlines.inbox.cms.title": "UPC — über CMS",
"deadlines.inbox.bea.title": "Nationale Verfahren — über beA",
"deadlines.inbox.posteingang.title": "Nationale Verfahren — Postzustellung",
"deadlines.inbox.posteingang": "Posteingang",
"deadlines.inbox.all": "Alle",
"deadlines.filter.forum.label": "Gericht / System:",
"deadlines.filter.forum.upc_cfi": "UPC CFI",
"deadlines.filter.forum.upc_coa": "UPC CoA",
@@ -2385,6 +2391,12 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.pathway.b.tree.empty": "No matches for this path.",
"deadlines.pathway.b.tree.reset": "Restart",
"deadlines.pathway.b.tree.start_question": "What happened?",
"deadlines.inbox.label": "Where did it arrive?",
"deadlines.inbox.cms.title": "UPC — via CMS",
"deadlines.inbox.bea.title": "National-DE — via beA",
"deadlines.inbox.posteingang.title": "National-DE — postal mail",
"deadlines.inbox.posteingang": "Postal",
"deadlines.inbox.all": "All",
"deadlines.filter.forum.label": "Forum / System:",
"deadlines.filter.forum.upc_cfi": "UPC CFI",
"deadlines.filter.forum.upc_coa": "UPC CoA",

View File

@@ -112,6 +112,33 @@ export function renderFristenrechner(): string {
</p>
</div>
{/* m/paliad#15 inbox-channel chip: persists the user's typical
forum (CMS = UPC; beA / Posteingang = national-DE) via
PATCH /api/me. URL ?inbox= overrides per-visit so a
colleague can share a CMS-narrowed link without flipping
anyone's saved preference. Hidden EPA / DPMA shortcuts
stay reachable via the picker when no chip is active. */}
<div className="fristen-inbox-bar" id="fristen-inbox-bar" role="group" aria-label="Inbox channel">
<span className="fristen-inbox-bar-label" data-i18n="deadlines.inbox.label">Wo kam es an?</span>
<div className="fristen-inbox-chips">
<button type="button" className="fristen-inbox-chip" data-inbox="cms"
data-i18n-title="deadlines.inbox.cms.title" title="UPC &mdash; &uuml;ber CMS">
CMS
</button>
<button type="button" className="fristen-inbox-chip" data-inbox="bea"
data-i18n-title="deadlines.inbox.bea.title" title="Nationale Verfahren &mdash; &uuml;ber beA">
beA
</button>
<button type="button" className="fristen-inbox-chip" data-inbox="posteingang"
data-i18n-title="deadlines.inbox.posteingang.title" title="Nationale Verfahren &mdash; Postzustellung">
<span data-i18n="deadlines.inbox.posteingang">Posteingang</span>
</button>
<button type="button" className="fristen-inbox-chip fristen-inbox-chip--clear" data-inbox-clear>
<span data-i18n="deadlines.inbox.all">Alle</span>
</button>
</div>
</div>
{/* v3 landing fork (t-paliad-133) — visible by default, hidden once
the user picks a pathway. URL ?path= drives visibility. */}
<div className="fristen-pathway-fork" id="fristen-pathway-fork" role="group" aria-label="Pathway selector">
@@ -238,28 +265,28 @@ export function renderFristenrechner(): string {
<span data-i18n="deadlines.step1">Verfahrensart w&auml;hlen</span>
</h3>
<div className="proceeding-group">
<div className="proceeding-group" data-forum="upc">
<h4 data-i18n="deadlines.upc">UPC</h4>
<div className="proceeding-btns">
{UPC_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group">
<div className="proceeding-group" data-forum="de">
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
<div className="proceeding-btns">
{DE_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group">
<div className="proceeding-group" data-forum="epa">
<h4 data-i18n="deadlines.epa">EPA</h4>
<div className="proceeding-btns">
{EPA_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group">
<div className="proceeding-group" data-forum="dpma">
<h4 data-i18n="deadlines.dpma">DPMA</h4>
<div className="proceeding-btns">
{DPMA_TYPES.map((p) => proceedingBtn(p))}

View File

@@ -833,6 +833,12 @@ export type I18nKey =
| "deadlines.flag.rev_cci"
| "deadlines.form.approval_hint"
| "deadlines.heading"
| "deadlines.inbox.all"
| "deadlines.inbox.bea.title"
| "deadlines.inbox.cms.title"
| "deadlines.inbox.label"
| "deadlines.inbox.posteingang"
| "deadlines.inbox.posteingang.title"
| "deadlines.kalender.empty"
| "deadlines.kalender.heading"
| "deadlines.kalender.list"

View File

@@ -1958,6 +1958,71 @@ input[type="range"]::-moz-range-thumb {
outline-offset: 1px;
}
/* m/paliad#15 inbox-channel pre-filter — slim chip strip above the
pathway fork. Lives between tool-header and pathway-fork; styling
echoes .fristen-search-chip but with a clearer "active" treatment so
the user sees their persisted pref at a glance. */
.fristen-inbox-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem;
margin: 0.25rem 0 1rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--color-border, #e5e5e5);
border-radius: 0.5rem;
background: var(--color-bg-subtle, #fafafa);
}
.fristen-inbox-bar-label {
font-size: 0.875rem;
color: var(--color-muted, #666);
margin-right: 0.25rem;
}
.fristen-inbox-chips {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
}
.fristen-inbox-chip {
padding: 0.3rem 0.75rem;
border: 1px solid var(--color-border, #ddd);
border-radius: 999px;
background: var(--color-bg, #fff);
color: var(--color-text, #222);
font-size: 0.85rem;
cursor: pointer;
transition: background 120ms, border-color 120ms, color 120ms;
}
.fristen-inbox-chip:hover {
background: var(--color-bg-subtle, #f4f4f4);
border-color: var(--color-text-muted, #aaa);
}
.fristen-inbox-chip:focus-visible {
outline: 2px solid var(--color-accent, #c6f41c);
outline-offset: 1px;
}
.fristen-inbox-chip--active {
background: var(--color-accent, #c6f41c);
border-color: var(--color-accent, #c6f41c);
color: var(--color-text, #111);
font-weight: 600;
}
.fristen-inbox-chip--clear {
color: var(--color-muted, #666);
}
.fristen-inbox-chip--clear.fristen-inbox-chip--active {
color: var(--color-text, #111);
}
.fristen-search-results,
.fristen-b1-results {
margin-top: 1rem;