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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 — über CMS">
|
||||
CMS
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip" data-inbox="bea"
|
||||
data-i18n-title="deadlines.inbox.bea.title" title="Nationale Verfahren — über beA">
|
||||
beA
|
||||
</button>
|
||||
<button type="button" className="fristen-inbox-chip" data-inbox="posteingang"
|
||||
data-i18n-title="deadlines.inbox.posteingang.title" title="Nationale Verfahren — 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ä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))}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user