diff --git a/frontend/src/client/fristenrechner-mode-a.ts b/frontend/src/client/fristenrechner-mode-a.ts deleted file mode 100644 index 4344104..0000000 --- a/frontend/src/client/fristenrechner-mode-a.ts +++ /dev/null @@ -1,507 +0,0 @@ -// Fristenrechner overhaul Mode A — "Direkt suchen" (design §3.1). -// -// Power-user surface: a filter strip (Forum / Verfahren / Was passierte / -// Partei) over a free-text search box over a result list of -// procedural_events. Clicking a row locks the event as the trigger and -// transitions to the shared result view (S2). Inbox channel chip lives -// as a secondary "Erweitert" toggle per design §3.3 — picking CMS / beA -// / Postal auto-sets the Forum chip. -// -// Section-split visual hierarchy per m §11.Q3: filter strip on top -// ("Filter (eingrenzen)") with the four chip groups, search box and -// result list below — clicking a result row IS the qualifier action. - -import { escAttr, escHtml } from "./views/verfahrensablauf-core"; -import { getLang, t, tDyn } from "./i18n"; -import { mountResultView } from "./fristenrechner-result"; - -// Wire shape from GET /api/tools/fristenrechner/search?kind=events. -// Mirrors services.EventSearchResponse server-side. -interface EventSearchHit { - id: string; - code: string; - name_de: string; - name_en: string; - event_kind?: string; - description?: string; - primary_party?: string; - proceeding_type: { - id: number; - code: string; - name_de: string; - name_en: string; - jurisdiction?: string; - }; - anchor_rule_id: string; - follow_up_count: number; - concept_id?: string; - score: number; -} - -interface EventSearchResponse { - query: string; - events: EventSearchHit[]; - total: number; -} - -interface ProceedingChip { - code: string; - name: string; - nameEN: string; - group: string; -} - -// Module-local state — single Mode A surface at a time. -interface ModeAState { - jurisdiction: string; // "" = Alle - proc: string; // proceeding_types.code, "" = Alle - eventKind: string; // "" = Alle - party: string; // "" = Alle (Mode A's filter semantics, §11.Q8) - q: string; // free-text query - inbox: string; // CMS / bea / postal / "" — secondary, design §3.3 - inboxOpen: boolean; -} - -const state: ModeAState = { - jurisdiction: "", - proc: "", - eventKind: "", - party: "", - q: "", - inbox: "", - inboxOpen: false, -}; - -// Debounce token for search input — avoid hammering the server on -// every keystroke. -let searchSeq = 0; -let searchTimer: ReturnType | null = null; - -// Chip data — static. Forum and event-kind are closed-set per design; -// party is closed-set with "Beide" option (Mode A is filter mode, -// §11.Q8). Inbox secondary chip set per §3.3. -const FORUMS = ["UPC", "DE", "EPA", "DPMA"] as const; -const EVENT_KINDS = ["filing", "hearing", "decision", "order"] as const; -const PARTIES = ["claimant", "defendant", "both"] as const; - -// Forum auto-derivation from inbox chip per §3.3: CMS → UPC, beA → DE, -// Postal → no narrowing (postal arrives at every jurisdiction). -const INBOX_TO_FORUM: Record = { - cms: "UPC", - bea: "DE", - postal: "", -}; - -// MODE_A_HOST_ID is the DOM id of the container Mode A renders into. -// The mode shell (fristenrechner-result.mountModeShell) creates this -// element under the overhaul root and hands it to Mode A; Mode A -// otherwise has no opinion about its placement on the page. -const MODE_A_HOST_ID = "fristen-overhaul-mode-host"; - -export function isModeASurfaceMounted(): boolean { - return !!document.getElementById("fristen-mode-a-root"); -} - -// mountModeA renders the Mode A surface into the overhaul root. Reads -// initial state from URL params so deep links restore the previous -// filter / search state. -export async function mountModeA(): Promise { - const root = document.getElementById(MODE_A_HOST_ID); - if (!root) return; - - // Hydrate state from URL. - const params = new URLSearchParams(window.location.search); - state.jurisdiction = (params.get("forum") || "").toUpperCase(); - state.proc = params.get("pt") || ""; - state.eventKind = params.get("kind") || ""; - state.party = params.get("party") || ""; - state.q = params.get("q") || ""; - - renderShell(); - await loadProceedingChips(); - void runSearch(); -} - -// renderShell builds the Mode A markup. Idempotent re-call from the -// boot path; row-level rewrites use renderResults / renderFilterStrip -// for finer-grained updates. -function renderShell(): void { - const root = document.getElementById("fristen-overhaul-root"); - if (!root) return; - root.innerHTML = ` -
-
-
- ${escHtml(t("deadlines.overhaul.modea.filters.heading"))} -
-
- ${escHtml(t("deadlines.overhaul.modea.axis.forum"))} -
-
-
- ${escHtml(t("deadlines.overhaul.modea.axis.proc"))} -
-
-
- ${escHtml(t("deadlines.overhaul.modea.axis.kind"))} -
-
-
- ${escHtml(t("deadlines.overhaul.modea.axis.party"))} -
-
-
- ${escHtml(t("deadlines.overhaul.modea.inbox.summary"))} -
- ${escHtml(t("deadlines.overhaul.modea.axis.inbox"))} -
-
-
-
- - - -
-
- ${escHtml(t("deadlines.overhaul.modea.results.heading"))} - -
-
    -
    -
    - `; - - renderForumChips(); - renderKindChips(); - renderPartyChips(); - renderInboxChips(); - // Proceeding chips render later, after fetch. - - // Wire search input. - const input = document.getElementById("fristen-mode-a-search-input") as HTMLInputElement | null; - if (input) { - input.addEventListener("input", () => { - state.q = input.value; - scheduleSearch(180); - }); - input.addEventListener("keydown", (e) => { - if ((e as KeyboardEvent).key === "Enter") { - e.preventDefault(); - scheduleSearch(0); - } - }); - } -} - -// Filter-strip chip renderers ---------------------------------------- - -function renderForumChips(): void { - const host = document.getElementById("fristen-mode-a-chips-forum"); - if (!host) return; - const chips = [ - chipHtml("forum", "", t("deadlines.overhaul.modea.chip.all"), state.jurisdiction === ""), - ...FORUMS.map((j) => chipHtml("forum", j, j, state.jurisdiction === j)), - ]; - host.innerHTML = chips.join(""); - host.querySelectorAll(".fristen-mode-a-chip").forEach((btn) => { - btn.addEventListener("click", () => { - const v = btn.dataset.value || ""; - state.jurisdiction = v; - // Forum change invalidates the proc pick if it falls outside. - state.proc = ""; - syncUrl(); - renderForumChips(); - void loadProceedingChips(); - scheduleSearch(0); - }); - }); -} - -function renderKindChips(): void { - const host = document.getElementById("fristen-mode-a-chips-kind"); - if (!host) return; - const chips = [ - chipHtml("kind", "", t("deadlines.overhaul.modea.chip.all"), state.eventKind === ""), - ...EVENT_KINDS.map((k) => chipHtml("kind", k, t(`deadlines.overhaul.kind.${k}` as never), state.eventKind === k, eventKindIconForChip(k))), - ]; - host.innerHTML = chips.join(""); - host.querySelectorAll(".fristen-mode-a-chip").forEach((btn) => { - btn.addEventListener("click", () => { - state.eventKind = btn.dataset.value || ""; - syncUrl(); - renderKindChips(); - scheduleSearch(0); - }); - }); -} - -function renderPartyChips(): void { - const host = document.getElementById("fristen-mode-a-chips-party"); - if (!host) return; - const chips = [ - chipHtml("party", "", t("deadlines.overhaul.modea.chip.all"), state.party === ""), - ...PARTIES.map((p) => chipHtml("party", p, t(`deadlines.party.${p}` as never), state.party === p)), - ]; - host.innerHTML = chips.join(""); - host.querySelectorAll(".fristen-mode-a-chip").forEach((btn) => { - btn.addEventListener("click", () => { - state.party = btn.dataset.value || ""; - syncUrl(); - renderPartyChips(); - scheduleSearch(0); - }); - }); -} - -function renderInboxChips(): void { - const host = document.getElementById("fristen-mode-a-chips-inbox"); - if (!host) return; - const opts = [ - { v: "", label: t("deadlines.overhaul.modea.chip.all") }, - { v: "cms", label: "CMS" }, - { v: "bea", label: "beA" }, - { v: "postal", label: t("deadlines.overhaul.modea.inbox.postal") }, - ]; - host.innerHTML = opts.map((o) => chipHtml("inbox", o.v, o.label, state.inbox === o.v)).join(""); - host.querySelectorAll(".fristen-mode-a-chip").forEach((btn) => { - btn.addEventListener("click", () => { - const v = btn.dataset.value || ""; - state.inbox = v; - // Auto-nudge forum from inbox per design §3.3. - const nudge = INBOX_TO_FORUM[v]; - if (nudge !== undefined && nudge !== "") { - state.jurisdiction = nudge; - state.proc = ""; - renderForumChips(); - void loadProceedingChips(); - } - renderInboxChips(); - scheduleSearch(0); - }); - }); -} - -// Proceeding chips — dynamic fetch. - -let lastProcFetchKey = ""; - -async function loadProceedingChips(): Promise { - const host = document.getElementById("fristen-mode-a-chips-proc"); - if (!host) return; - const key = `j=${state.jurisdiction}`; - if (lastProcFetchKey === key) return; // cached for current jurisdiction - lastProcFetchKey = key; - host.innerHTML = `${escHtml(t("deadlines.overhaul.modea.loading"))}`; - - const url = new URL("/api/tools/proceeding-types", window.location.origin); - url.searchParams.set("kind", "proceeding"); - if (state.jurisdiction) url.searchParams.set("jurisdiction", state.jurisdiction); - - let chips: ProceedingChip[] = []; - try { - const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } }); - if (resp.ok) { - const data = (await resp.json()) as ProceedingChip[] | null; - chips = data || []; - } - } catch { - // Soft-fail: chip strip just hides; search still runs without - // proceeding narrowing. - } - - renderProceedingChips(chips); -} - -function renderProceedingChips(chips: ProceedingChip[]): void { - const host = document.getElementById("fristen-mode-a-chips-proc"); - if (!host) return; - const lang = getLang(); - if (chips.length === 0) { - host.innerHTML = `${escHtml(t("deadlines.overhaul.modea.no_proceedings"))}`; - return; - } - const rendered = [ - chipHtml("proc", "", t("deadlines.overhaul.modea.chip.all"), state.proc === ""), - ...chips.map((c) => { - const label = lang === "en" ? c.nameEN || c.name : c.name; - return chipHtml("proc", c.code, label, state.proc === c.code, undefined, c.code); - }), - ]; - host.innerHTML = rendered.join(""); - host.querySelectorAll(".fristen-mode-a-chip").forEach((btn) => { - btn.addEventListener("click", () => { - state.proc = btn.dataset.value || ""; - syncUrl(); - renderProceedingChips(chips); - scheduleSearch(0); - }); - }); -} - -// Search ------------------------------------------------------------ - -function scheduleSearch(delayMs: number): void { - if (searchTimer !== null) clearTimeout(searchTimer); - searchTimer = setTimeout(() => { - searchTimer = null; - void runSearch(); - }, delayMs); -} - -async function runSearch(): Promise { - searchSeq++; - const mySeq = searchSeq; - - const list = document.getElementById("fristen-mode-a-result-list"); - const count = document.getElementById("fristen-mode-a-results-count"); - if (!list || !count) return; - - list.innerHTML = `
  • ${escHtml(t("deadlines.overhaul.modea.loading"))}
  • `; - count.textContent = ""; - - const url = new URL("/api/tools/fristenrechner/search", window.location.origin); - url.searchParams.set("kind", "events"); - if (state.q) url.searchParams.set("q", state.q); - if (state.jurisdiction) url.searchParams.set("jurisdiction", state.jurisdiction); - if (state.proc) url.searchParams.set("proc", state.proc); - if (state.eventKind) url.searchParams.set("event_kind", state.eventKind); - if (state.party) url.searchParams.set("party", state.party); - - let data: EventSearchResponse; - try { - const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } }); - if (!resp.ok) { - if (mySeq === searchSeq) { - list.innerHTML = `
  • ${escHtml(t("deadlines.overhaul.modea.search_error"))}
  • `; - } - return; - } - data = (await resp.json()) as EventSearchResponse; - } catch { - if (mySeq === searchSeq) { - list.innerHTML = `
  • ${escHtml(t("deadlines.overhaul.modea.search_error"))}
  • `; - } - return; - } - - if (mySeq !== searchSeq) return; // stale response - - renderResults(data); -} - -function renderResults(data: EventSearchResponse): void { - const list = document.getElementById("fristen-mode-a-result-list"); - const count = document.getElementById("fristen-mode-a-results-count"); - if (!list || !count) return; - count.textContent = tDyn("deadlines.overhaul.modea.results.count").replace("{n}", String(data.total)); - - if (data.events.length === 0) { - list.innerHTML = `
  • ${escHtml(t("deadlines.overhaul.modea.no_results"))}
  • `; - return; - } - - const lang = getLang(); - list.innerHTML = data.events.map((e) => { - const name = lang === "en" ? e.name_en || e.name_de : e.name_de; - const pt = e.proceeding_type; - const ptName = lang === "en" ? pt.name_en || pt.name_de : pt.name_de; - const icon = eventKindIconForChip(e.event_kind); - const followUps = tDyn("deadlines.overhaul.modea.row.followups").replace("{n}", String(e.follow_up_count)); - const juris = pt.jurisdiction || ""; - return ` -
  • - -
    -
    ${escHtml(name)}
    -
    - ${escHtml(pt.code)} - ${escHtml(ptName)} - ${juris ? `${escHtml(juris)}` : ""} - ${escHtml(followUps)} -
    -
    - -
  • - `; - }).join(""); - - list.querySelectorAll(".fristen-mode-a-result").forEach((li) => { - li.addEventListener("click", () => commitEvent(li.dataset.eventCode || "")); - li.addEventListener("keydown", (e) => { - const k = (e as KeyboardEvent).key; - if (k === "Enter" || k === " ") { - e.preventDefault(); - commitEvent(li.dataset.eventCode || ""); - } - }); - }); -} - -// Commit — user picked a result; lock the event as trigger and -// transition to the §4 result view (S2). -function commitEvent(code: string): void { - if (!code) return; - // Reflect in URL before re-mounting so the result view's deep link - // is consistent. - const url = new URL(window.location.href); - url.searchParams.set("overhaul", "1"); - url.searchParams.set("event", code); - // Preserve project / forum / kind filters so a back-navigation - // brings Mode A back with the same filters. - history.pushState(null, "", url.pathname + url.search + url.hash); - void mountResultView({ - eventRef: code, - party: state.party || undefined, - }); -} - -// Helpers ----------------------------------------------------------- - -function chipHtml(axis: string, value: string, label: string, active: boolean, icon?: string, title?: string): string { - const cls = `fristen-mode-a-chip${active ? " is-active" : ""}`; - const t = title ? ` title="${escAttr(title)}"` : ""; - const i = icon ? `` : ""; - return ``; -} - -function eventKindIconForChip(kind?: string): string { - switch (kind) { - case "filing": return "📥"; - case "hearing": return "🏛️"; - case "decision": return "⚖️"; - case "order": return "📜"; - default: return "🔍"; - } -} - -// syncUrl writes the active filter set into the URL so the deep link -// restores Mode A in the same state. -function syncUrl(): void { - const url = new URL(window.location.href); - url.searchParams.set("overhaul", "1"); - setOrClear(url, "forum", state.jurisdiction); - setOrClear(url, "pt", state.proc); - setOrClear(url, "kind", state.eventKind); - setOrClear(url, "party", state.party); - setOrClear(url, "q", state.q); - history.replaceState(null, "", url.pathname + url.search + url.hash); -} - -function setOrClear(url: URL, key: string, val: string): void { - if (val) url.searchParams.set(key, val); - else url.searchParams.delete(key); -} diff --git a/frontend/src/client/fristenrechner-result.test.ts b/frontend/src/client/fristenrechner-result.test.ts deleted file mode 100644 index ea7fc86..0000000 --- a/frontend/src/client/fristenrechner-result.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - defaultChecked, - groupFollowUps, - type FollowUpRule, -} from "./fristenrechner-result"; - -// Pure helpers exercised here; the DOM-driven render path is covered -// by the live page test path (S2 is mount-on-deep-link, S3+S4 add the -// entry-mode UIs in later slices). - -function mk(partial: Partial): FollowUpRule { - return { - rule_id: "r" + Math.random().toString(36).slice(2, 8), - event_code: "evt", - title_de: "Frist", - title_en: "Deadline", - priority: "mandatory", - is_court_set: false, - is_spawn: false, - is_bilateral: false, - has_condition: false, - ...partial, - }; -} - -describe("groupFollowUps — design §4.2 priority+condition buckets", () => { - test("groups by priority; conditional takes precedence over priority", () => { - const rows = [ - mk({ priority: "mandatory" }), - mk({ priority: "recommended" }), - mk({ priority: "optional" }), - mk({ priority: "mandatory", has_condition: true }), // → conditional - mk({ priority: "optional", has_condition: true }), // → conditional - ]; - const g = groupFollowUps(rows); - expect(g.mandatory.length).toBe(1); - expect(g.recommended.length).toBe(1); - expect(g.optional.length).toBe(1); - expect(g.conditional.length).toBe(2); - }); - - test("unknown priority falls through to optional", () => { - const g = groupFollowUps([mk({ priority: "informational" })]); - expect(g.optional.length).toBe(1); - expect(g.mandatory.length).toBe(0); - }); -}); - -describe("defaultChecked — pre-checks mandatory + recommended, not conditional/court-set", () => { - test("mandatory rules pre-checked", () => { - expect(defaultChecked(mk({ priority: "mandatory" }))).toBe(true); - }); - test("recommended rules pre-checked", () => { - expect(defaultChecked(mk({ priority: "recommended" }))).toBe(true); - }); - test("optional rules unchecked", () => { - expect(defaultChecked(mk({ priority: "optional" }))).toBe(false); - }); - test("conditional rules unchecked", () => { - expect(defaultChecked(mk({ priority: "mandatory", has_condition: true }))).toBe(false); - }); - test("court-set rules unchecked even when mandatory", () => { - expect(defaultChecked(mk({ priority: "mandatory", is_court_set: true }))).toBe(false); - }); - test("spawned rules pre-checked when mandatory", () => { - expect(defaultChecked(mk({ priority: "mandatory", is_spawn: true }))).toBe(true); - }); - test("spawned optional rules unchecked", () => { - expect(defaultChecked(mk({ priority: "optional", is_spawn: true }))).toBe(false); - }); -}); diff --git a/frontend/src/client/fristenrechner-result.ts b/frontend/src/client/fristenrechner-result.ts deleted file mode 100644 index 051343b..0000000 --- a/frontend/src/client/fristenrechner-result.ts +++ /dev/null @@ -1,693 +0,0 @@ -// Fristenrechner overhaul — shared result view (design §4). -// -// Given a locked trigger event + a trigger date, this module renders -// the result surface: a sticky trigger card on top, then four priority -// groups (mandatory / recommended / optional / conditional) of follow-up -// rules with computed dates, then a write-back footer that calls the -// existing POST /api/projects/{id}/deadlines/bulk. -// -// The two future entry paths (Mode A "Direkt suchen" in S3, Mode B -// wizard in S4) both land here once they've identified a trigger -// procedural_event. S2 mounts the surface under `?overhaul=1` and is -// deep-linkable on its own via `?overhaul=1&event=&trigger_date=…`. - -import { escAttr, escHtml } from "./views/verfahrensablauf-core"; -import { getLang, t, tDyn } from "./i18n"; - -// Wire shape from GET /api/tools/fristenrechner/follow-ups. Mirrors -// services.FollowUpsResponse server-side. -export interface FollowUpRule { - rule_id: string; - event_code: string; - title_de: string; - title_en: string; - priority: string; - primary_party?: string; - // m/paliad#149 Phase 2 S1 (design §2.4) — true when the rule's - // primary_party is the side opposite the perspective. Drives the - // Gegenseitig badge + muted style + unchecked default. - is_cross_party: boolean; - duration_value?: number; - duration_unit?: string; - timing?: string; - due_date?: string; - original_due_date?: string; - was_adjusted?: boolean; - is_court_set: boolean; - is_spawn: boolean; - is_bilateral: boolean; - has_condition: boolean; - rule_code?: string; - legal_source?: string; - legal_source_display?: string; - legal_source_url?: string; - notes_de?: string; - notes_en?: string; - spawn_label?: string; - spawn_proceeding_code?: string; - concept_id?: string; -} - -export interface FollowUpsResponse { - trigger: { - id: string; - code: string; - name_de: string; - name_en: string; - event_kind?: string; - proceeding_type: { - id: number; - code: string; - name_de: string; - name_en: string; - jurisdiction?: string; - }; - anchor_rule_id: string; - }; - trigger_date: string; - party?: string; - follow_ups: FollowUpRule[]; -} - -// Per-rule UI state — checkbox, optional date override. -interface RuleSelection { - checked: boolean; - override?: string; -} - -// Module-local state. Single result view at a time; the surface -// re-renders in place when the user changes the trigger date or -// re-locks a different event. -let currentResponse: FollowUpsResponse | null = null; -const selections = new Map(); -let currentProjectId: string | null = null; - -// Public API ---------------------------------------------------------- - -// isOverhaulMode reports whether the page is in overhaul mode. -// After Slice S5 (t-paliad-323), overhaul is the default; the legacy -// wizard / row-stack / cascade is only reachable via `?legacy=1` for -// a two-week deprecation window. The `?overhaul=1` deep links from -// S2-S4 still work — they're now redundant with the default but kept -// alive so bookmarks don't 302 / lose state. -export function isOverhaulMode(): boolean { - return new URLSearchParams(window.location.search).get("legacy") !== "1"; -} - -// resolveProjectId reads the active Akte from the URL query string. -// Returns null when in kontextfrei mode (no project picked). -function resolveProjectId(): string | null { - const p = new URLSearchParams(window.location.search).get("project"); - return p && p.length > 0 ? p : null; -} - -// MODE_TAB_KEYS — the two entry-mode tabs landed by S3 + S4. S2's deep -// link path bypasses these (jumps straight to the result view via -// ?event=); the tabs appear when no event is locked yet. -export type ModeTab = "search" | "wizard"; - -// mountModeShell renders the mode-tab pair under the page header and -// hosts whichever mode panel is currently active. Called from the boot -// path when no `?event=` is present. S3 wires Mode A; S4 will add -// Mode B and the actual tab switching. -export async function mountModeShell(activeTab: ModeTab): Promise { - const root = document.getElementById("fristen-overhaul-root"); - if (!root) return; - root.hidden = false; - // Defer to the per-mode module to render into the root. The tab - // strip itself is a small header above the mode panel — for S3 we - // render the shell + Mode A in one shot. - // S4 will replace this with a real tab switcher. - const tabs = ` - -
    - `; - root.innerHTML = tabs; - - // Wire tab switching. S3 only has Mode A wired; Mode B is a - // placeholder until S4. - root.querySelectorAll(".fristen-mode-tab").forEach((btn) => { - btn.addEventListener("click", () => { - const tab = (btn.dataset.tab || "search") as ModeTab; - void mountModeShell(tab); - }); - }); - - // Mount the active mode panel into the host. S3 only routes "search"; - // "wizard" renders a placeholder until S4 lands. - const host = document.getElementById("fristen-overhaul-mode-host"); - if (!host) return; - if (activeTab === "search") { - // Lazy import to keep the bundle layered and avoid a circular ref - // between fristenrechner-result.ts ↔ fristenrechner-mode-a.ts. - const mod = await import("./fristenrechner-mode-a"); - await mod.mountModeA(); - } else { - const mod = await import("./fristenrechner-wizard"); - await mod.mountWizard(); - } -} - -// MountOptions configures the surface entry. Both entry-mode paths -// (Mode A in S3, Mode B in S4) call mount() with the event reference -// that the user committed. -export interface MountOptions { - // eventRef is the procedural_event code OR its uuid OR the anchor - // sequencing_rule id. Resolved server-side; the wire returns the - // canonical code so the URL bookmark is stable. - eventRef: string; - // triggerDate is YYYY-MM-DD. Defaults to today when omitted. - triggerDate?: string; - // party is "claimant" | "defendant"; mode A may pass "both" or - // "court". When omitted, follow-ups are returned without party - // narrowing. - party?: string; - // courtId selects the holiday calendar for the per-rule date - // adjustment. Optional. - courtId?: string; -} - -// mountResultView fetches /follow-ups and renders the result surface -// into the host container. Re-callable: replaces previous state. -export async function mountResultView(opts: MountOptions): Promise { - const root = document.getElementById("fristen-overhaul-root"); - if (!root) return; - root.hidden = false; - - const triggerDate = opts.triggerDate || todayIso(); - currentProjectId = resolveProjectId(); - - // Show a quick "loading…" placeholder so the user sees something - // immediately, even on a cold fetch. - root.innerHTML = `
    ${escHtml(t("deadlines.overhaul.loading"))}
    `; - - const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin); - url.searchParams.set("event", opts.eventRef); - url.searchParams.set("trigger_date", triggerDate); - if (opts.party) url.searchParams.set("party", opts.party); - if (opts.courtId) url.searchParams.set("court_id", opts.courtId); - - let data: FollowUpsResponse; - try { - const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } }); - if (!resp.ok) { - const body = await resp.json().catch(() => ({}) as { error?: string }); - root.innerHTML = `
    ${escHtml(body.error || t("deadlines.overhaul.load_error"))}
    `; - return; - } - data = (await resp.json()) as FollowUpsResponse; - } catch (err) { - root.innerHTML = `
    ${escHtml(t("deadlines.overhaul.load_error"))}
    `; - return; - } - - currentResponse = data; - selections.clear(); - for (const r of data.follow_ups) { - selections.set(r.rule_id, { checked: defaultChecked(r) }); - } - - renderSurface(); - // Reflect the canonical event code + trigger date in the URL so the - // deep-link survives a reload. - syncUrlState(data.trigger.code, data.trigger_date); -} - -// Render -------------------------------------------------------------- - -function renderSurface(): void { - const root = document.getElementById("fristen-overhaul-root"); - if (!root || !currentResponse) return; - - const lang = getLang(); - const trig = currentResponse.trigger; - const triggerName = lang === "en" ? trig.name_en || trig.name_de : trig.name_de; - const ptName = lang === "en" ? trig.proceeding_type.name_en || trig.proceeding_type.name_de : trig.proceeding_type.name_de; - const juris = trig.proceeding_type.jurisdiction || ""; - const kindIcon = eventKindIcon(trig.event_kind); - - const triggerCard = ` -
    -
    - -

    ${escHtml(triggerName)}

    -
    -
    - ${escHtml(trig.code)} - ${escHtml(ptName)} - ${juris ? `${escHtml(juris)}` : ""} -
    -
    - - -
    -
    - `; - - const groups = groupFollowUps(currentResponse.follow_ups); - const groupHtml = renderGroups(groups, lang); - - const nudge = currentProjectId - ? "" - : `
    ${escHtml(t("deadlines.overhaul.nudge.no_project"))}
    `; - - const footer = currentProjectId - ? renderFooter() - : ""; - - root.innerHTML = ` - ${triggerCard} - ${nudge} -
    - ${groupHtml} -
    - ${footer} -
    - `; - - wireSurfaceEvents(); -} - -export interface GroupedFollowUps { - mandatory: FollowUpRule[]; - recommended: FollowUpRule[]; - optional: FollowUpRule[]; - conditional: FollowUpRule[]; -} - -// groupFollowUps splits the wire list into the four visible groups per -// design §4.2. Conditional (sr.condition_expr IS NOT NULL) takes -// precedence over the priority bucket so a "nur wenn CCR" mandatory -// rule renders under Conditional with the gating language visible. -export function groupFollowUps(rows: FollowUpRule[]): GroupedFollowUps { - const out: GroupedFollowUps = { mandatory: [], recommended: [], optional: [], conditional: [] }; - for (const r of rows) { - if (r.has_condition) { - out.conditional.push(r); - continue; - } - switch (r.priority) { - case "mandatory": - out.mandatory.push(r); - break; - case "recommended": - out.recommended.push(r); - break; - case "optional": - out.optional.push(r); - break; - default: - // unknown / informational — fold into optional so the row is at - // least visible. Future Phase 2 'informational' tier gets a - // dedicated bucket once seeded. - out.optional.push(r); - } - } - return out; -} - -function renderGroups(groups: GroupedFollowUps, lang: "de" | "en"): string { - const blocks: string[] = []; - if (groups.mandatory.length > 0) { - blocks.push(renderGroup("mandatory", t("deadlines.overhaul.group.mandatory"), groups.mandatory, lang)); - } - if (groups.recommended.length > 0) { - blocks.push(renderGroup("recommended", t("deadlines.overhaul.group.recommended"), groups.recommended, lang)); - } - if (groups.optional.length > 0) { - blocks.push(renderGroup("optional", t("deadlines.overhaul.group.optional"), groups.optional, lang)); - } - if (groups.conditional.length > 0) { - blocks.push(renderGroup("conditional", t("deadlines.overhaul.group.conditional"), groups.conditional, lang)); - } - if (blocks.length === 0) { - return `
    ${escHtml(t("deadlines.overhaul.empty"))}
    `; - } - return blocks.join(""); -} - -function renderGroup(slug: string, label: string, rows: FollowUpRule[], lang: "de" | "en"): string { - const items = rows.map((r) => renderRule(r, lang)).join(""); - return ` -
    -

    ${escHtml(label)}

    -
      - ${items} -
    -
    - `; -} - -function renderRule(r: FollowUpRule, lang: "de" | "en"): string { - const title = lang === "en" ? r.title_en || r.title_de : r.title_de; - const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de; - const sel = selections.get(r.rule_id); - const checked = sel ? sel.checked : defaultChecked(r); - const dateOverride = sel?.override; - const computedDate = r.due_date || ""; - const effectiveDate = dateOverride || computedDate; - const disabled = r.is_court_set || (r.is_spawn && !r.due_date); - - // Duration phrase: "3 Monate" / "14 Tage" — language-aware. - const durationPhrase = formatDurationPhrase(r, lang); - const dateCell = r.is_court_set - ? `${escHtml(t("deadlines.court.set"))}` - : effectiveDate - ? `${escHtml(formatDateForLang(effectiveDate, lang))}` - : ``; - - const partyBadge = r.primary_party - ? `${escHtml(t(`deadlines.party.${r.primary_party}` as never))}` - : ""; - - const sourceBadge = r.legal_source_display - ? r.legal_source_url - ? `${escHtml(r.legal_source_display)}` - : `${escHtml(r.legal_source_display)}` - : r.rule_code - ? `${escHtml(r.rule_code)}` - : ""; - - const spawnBadge = r.is_spawn - ? `${escHtml(t("deadlines.overhaul.spawn.badge"))}${r.spawn_proceeding_code ? ` · ${escHtml(r.spawn_proceeding_code)}` : ""}` - : ""; - - const condBadge = r.has_condition - ? `${escHtml(t("deadlines.overhaul.condition.badge"))}` - : ""; - - const crossPartyBadge = r.is_cross_party - ? `${escHtml(t("deadlines.overhaul.crossparty.badge"))}` - : ""; - - const notesHtml = notes - ? `
    ${escHtml(t("deadlines.overhaul.notes.summary"))}

    ${escHtml(notes)}

    ` - : ""; - - const editBtn = r.is_court_set || r.is_spawn || !computedDate - ? "" - : ``; - - return ` -
  • - -
    -
    - ${escHtml(title)} - ${spawnBadge} - ${condBadge} - ${crossPartyBadge} -
    -
    - ${durationPhrase ? `${escHtml(durationPhrase)}` : ""} - ${partyBadge} - ${sourceBadge} -
    - ${notesHtml} -
    -
    - ${dateCell} - ${editBtn} -
    -
  • - `; -} - -function renderFooter(): string { - const selectedCount = countSelected(); - return ` -
    - - ${escHtml(tDyn("deadlines.overhaul.footer.count").replace("{n}", String(selectedCount)))} - - -
    - `; -} - -// Event wiring -------------------------------------------------------- - -function wireSurfaceEvents(): void { - // Trigger-date change → re-fetch with new date. - const dateInput = document.getElementById("fristen-overhaul-trigger-date") as HTMLInputElement | null; - if (dateInput && currentResponse) { - dateInput.addEventListener("change", () => { - if (!currentResponse) return; - const newDate = dateInput.value; - if (!newDate) return; - void mountResultView({ - eventRef: currentResponse.trigger.code, - triggerDate: newDate, - party: currentResponse.party, - }); - }); - } - - // Checkbox toggles → update selections + footer count. - const root = document.getElementById("fristen-overhaul-root"); - if (root) { - root.querySelectorAll(".fristen-overhaul-rule-check input[type=checkbox]").forEach((cb) => { - cb.addEventListener("change", () => { - const id = cb.dataset.ruleId || ""; - const sel = selections.get(id) ?? { checked: cb.checked }; - sel.checked = cb.checked; - selections.set(id, sel); - refreshFooterCount(); - }); - }); - - // Per-rule date override. - root.querySelectorAll(".fristen-overhaul-rule-edit-date").forEach((btn) => { - btn.addEventListener("click", () => editRuleDate(btn)); - }); - } - - // Write-back CTA. - const cta = document.getElementById("fristen-overhaul-write-back"); - if (cta) cta.addEventListener("click", () => void submitWriteBack()); -} - -function editRuleDate(btn: HTMLButtonElement): void { - const ruleId = btn.dataset.ruleId || ""; - const rule = currentResponse?.follow_ups.find((r) => r.rule_id === ruleId); - if (!rule) return; - const sel = selections.get(ruleId) ?? { checked: defaultChecked(rule) }; - const current = sel.override || rule.due_date || todayIso(); - - const dateCell = btn.parentElement; - if (!dateCell) return; - const dateSpan = dateCell.querySelector(".fristen-overhaul-rule-date"); - if (!dateSpan) return; - - const input = document.createElement("input"); - input.type = "date"; - input.value = current; - input.className = "fristen-overhaul-rule-date-input"; - dateSpan.replaceWith(input); - btn.disabled = true; - input.focus(); - - const commit = () => { - const newDate = input.value; - if (newDate && newDate !== current) { - sel.override = newDate; - selections.set(ruleId, sel); - } - renderSurface(); - }; - input.addEventListener("blur", commit, { once: true }); - input.addEventListener("keydown", (e) => { - if ((e as KeyboardEvent).key === "Enter") { - e.preventDefault(); - input.blur(); - } else if ((e as KeyboardEvent).key === "Escape") { - renderSurface(); - } - }); -} - -function refreshFooterCount(): void { - const countEl = document.getElementById("fristen-overhaul-footer-count"); - const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null; - const n = countSelected(); - if (countEl) { - countEl.textContent = tDyn("deadlines.overhaul.footer.count").replace("{n}", String(n)); - } - if (cta) cta.disabled = n === 0; -} - -function countSelected(): number { - let n = 0; - if (!currentResponse) return 0; - for (const r of currentResponse.follow_ups) { - if (r.is_court_set) continue; - // Cross-party rows are unconditionally excluded from write-back - // (design §2.4). Even if the user manually checks the box, they - // describe what the opponent files — not Akte work for our side. - if (r.is_cross_party) continue; - const sel = selections.get(r.rule_id); - if (sel?.checked) n++; - } - return n; -} - -// Write-back ---------------------------------------------------------- - -async function submitWriteBack(): Promise { - if (!currentResponse) return; - if (!currentProjectId) return; - const msg = document.getElementById("fristen-overhaul-msg"); - const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null; - const lang = getLang(); - - const deadlines: Array> = []; - for (const r of currentResponse.follow_ups) { - const sel = selections.get(r.rule_id); - if (!sel?.checked) continue; - if (r.is_court_set) continue; - // Skip cross-party rows even if checked — they describe opposing- - // side filings and don't belong in our side's Akte deadline set - // (design §2.4, write-back exclusion). - if (r.is_cross_party) continue; - const dueDate = sel.override || r.due_date; - if (!dueDate) continue; - const title = lang === "en" ? r.title_en || r.title_de : r.title_de; - const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de; - deadlines.push({ - title, - rule_code: r.rule_code || undefined, - due_date: dueDate, - original_due_date: r.original_due_date || r.due_date || undefined, - source: "fristenrechner", - rule_id: r.rule_id, - notes: notes || undefined, - audit_reason: auditReason(), - }); - } - - if (deadlines.length === 0 || !msg || !cta) return; - cta.disabled = true; - msg.textContent = ""; - msg.className = "fristen-overhaul-msg"; - - try { - const resp = await fetch(`/api/projects/${encodeURIComponent(currentProjectId)}/deadlines/bulk`, { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ deadlines }), - }); - if (!resp.ok) { - const body = await resp.json().catch(() => ({}) as { error?: string }); - msg.textContent = body.error || t("deadlines.save.error"); - msg.className = "fristen-overhaul-msg form-msg-error"; - cta.disabled = false; - return; - } - msg.innerHTML = `${escHtml(t("deadlines.save.success"))} ${escHtml(t("deadlines.save.success.link"))}`; - msg.className = "fristen-overhaul-msg form-msg-ok"; - setTimeout(() => { - if (cta) cta.disabled = false; - }, 1500); - } catch { - msg.textContent = t("deadlines.save.error"); - msg.className = "fristen-overhaul-msg form-msg-error"; - cta.disabled = false; - } -} - -// audit reason per design §11.Q12: "Aus Fristenrechner — Trigger: {name} ({date})". -function auditReason(): string { - if (!currentResponse) return ""; - const name = currentResponse.trigger.name_de; - const date = currentResponse.trigger_date; - return `Aus Fristenrechner — Trigger: ${name} (${date})`; -} - -// Helpers ------------------------------------------------------------- - -export function defaultChecked(r: FollowUpRule): boolean { - // Cross-party rows are unchecked by default — they describe what the - // OTHER side files. They render to honestly show the workflow, but - // the Akte write-back excludes them unconditionally (design §2.4). - if (r.is_cross_party) return false; - if (r.is_court_set) return false; - if (r.is_spawn) return r.priority === "mandatory"; - if (r.has_condition) return false; - return r.priority === "mandatory" || r.priority === "recommended"; -} - -function formatDurationPhrase(r: FollowUpRule, lang: "de" | "en"): string { - if (!r.duration_value || !r.duration_unit) return ""; - const unitDE: Record = { - days: "Tage", - months: "Monate", - weeks: "Wochen", - years: "Jahre", - }; - const unitEN: Record = { - days: "days", - months: "months", - weeks: "weeks", - years: "years", - }; - const u = (lang === "en" ? unitEN : unitDE)[r.duration_unit] || r.duration_unit; - return `${r.duration_value} ${u}`; -} - -function formatDateForLang(iso: string, lang: "de" | "en"): string { - // YYYY-MM-DD → DE: DD.MM.YYYY / EN: DD MMM YYYY (short). - if (!iso || iso.length < 10) return iso; - const [y, m, d] = iso.split("-"); - if (!y || !m || !d) return iso; - if (lang === "en") { - const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; - const idx = parseInt(m, 10) - 1; - const mn = idx >= 0 && idx < months.length ? months[idx] : m; - return `${parseInt(d, 10)} ${mn} ${y}`; - } - return `${d}.${m}.${y}`; -} - -function eventKindIcon(kind?: string): string { - switch (kind) { - case "filing": return "📥"; // inbox/letter - case "hearing": return "🏛️"; // courthouse - case "decision": return "⚖️"; // scales - case "order": return "📜"; // page - default: return "📅"; // calendar - } -} - -function todayIso(): string { - return new Date().toISOString().slice(0, 10); -} - -function syncUrlState(eventCode: string, triggerDate: string): void { - const url = new URL(window.location.href); - url.searchParams.set("overhaul", "1"); - url.searchParams.set("event", eventCode); - url.searchParams.set("trigger_date", triggerDate); - history.replaceState(null, "", url.pathname + url.search + url.hash); -} diff --git a/frontend/src/client/fristenrechner-wizard.test.ts b/frontend/src/client/fristenrechner-wizard.test.ts deleted file mode 100644 index 479ceab..0000000 --- a/frontend/src/client/fristenrechner-wizard.test.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { followUpsDifferByParty } from "./fristenrechner-wizard"; - -describe("followUpsDifferByParty — R5 trigger condition (S4, design §3.2)", () => { - test("true when both claimant and defendant rules present", () => { - expect(followUpsDifferByParty([ - { primary_party: "claimant" }, - { primary_party: "defendant" }, - ])).toBe(true); - }); - test("false when all claimant", () => { - expect(followUpsDifferByParty([ - { primary_party: "claimant" }, - { primary_party: "claimant" }, - ])).toBe(false); - }); - test("false when all defendant", () => { - expect(followUpsDifferByParty([ - { primary_party: "defendant" }, - ])).toBe(false); - }); - test("false when only 'both' rules", () => { - // "Both" rules are bilateral procedural moves (Vertraulichkeits- - // Erwiderung); they don't gate R5 because either party can be - // looking at them. - expect(followUpsDifferByParty([ - { primary_party: "both" }, - { primary_party: "both" }, - ])).toBe(false); - }); - test("false when only court rules", () => { - expect(followUpsDifferByParty([ - { primary_party: "court" }, - ])).toBe(false); - }); - test("true when mixed with both / court alongside the asymmetric pair", () => { - expect(followUpsDifferByParty([ - { primary_party: "both" }, - { primary_party: "claimant" }, - { primary_party: "court" }, - { primary_party: "defendant" }, - ])).toBe(true); - }); - test("false on empty list", () => { - expect(followUpsDifferByParty([])).toBe(false); - }); -}); diff --git a/frontend/src/client/fristenrechner-wizard.ts b/frontend/src/client/fristenrechner-wizard.ts deleted file mode 100644 index 904850a..0000000 --- a/frontend/src/client/fristenrechner-wizard.ts +++ /dev/null @@ -1,711 +0,0 @@ -// Fristenrechner overhaul Mode B — "Geführt" / wizard (design §3.2). -// -// 3-5 question row stack that lands the user on one procedural_event -// (the trigger), then transitions to the shared §4 result view. -// -// R1 Was ist passiert? (event_kind) always asked -// R2 Vor welchem Gericht? (jurisdiction) skip if R1 narrows -// R3 In welchem Verfahren? (proceeding_type) auto-skip when 1 option -// R4 Welches Schriftstück? (procedural_event — land) always asked -// R5 Welche Seite vertreten Sie? (party) only when follow-ups differ -// -// Row badges per §11.Q3: R1+R2 = "Filter", R3+R4+R5 = "Qualifier". -// R5 has NO "Beide" option per §11.Q8 (Mode B is the file-mode where -// perspective is a qualifier). -// Pre-fill + collapse rows from project (project.proceeding_type → -// R3 + R2 derived; project.our_side → R5). Preserve compatible -// downstream picks on back-navigation (§11.Q10). - -import { escAttr, escHtml } from "./views/verfahrensablauf-core"; -import { getLang, t, tDyn } from "./i18n"; -import { mountResultView } from "./fristenrechner-result"; - -// Wire shapes — duplicates the parts of fristenrechner-mode-a.ts we -// need; kept local so the wizard doesn't depend on Mode A. - -interface EventSearchHit { - id: string; - code: string; - name_de: string; - name_en: string; - event_kind?: string; - proceeding_type: { - id: number; - code: string; - name_de: string; - name_en: string; - jurisdiction?: string; - }; - follow_up_count: number; -} - -interface EventSearchResponse { - events: EventSearchHit[]; - total: number; -} - -interface ProceedingChip { - code: string; - name: string; - nameEN: string; - group: string; -} - -interface ProjectSummary { - id: string; - proceeding_type_id?: number | null; - our_side?: string | null; -} - -type Forum = "UPC" | "DE" | "EPA" | "DPMA"; -type EventKindRow = "filing" | "hearing" | "decision" | "order" | "missed"; -type WizardParty = "claimant" | "defendant"; - -// WIZARD_HOST_ID is the DOM id the wizard renders into. Mounted by -// fristenrechner-result.mountModeShell which creates the host element -// under the overhaul root. -const WIZARD_HOST_ID = "fristen-overhaul-mode-host"; - -// FORUMS + EVENT_KINDS — closed sets. Keep parallel to Mode A's lists -// so re-grouping happens in one place. -const FORUMS: Forum[] = ["UPC", "DE", "EPA", "DPMA"]; -const EVENT_KINDS: EventKindRow[] = ["filing", "hearing", "decision", "order", "missed"]; - -// Single wizard state. Module-local; one wizard at a time. -interface WizardState { - // Picks. "" = not answered. R5 only set when the question is asked. - r1: EventKindRow | ""; - r2: Forum | ""; - r3: string; // proceeding_types.code - r4: string; // procedural_events.code - r5: WizardParty | ""; - - // Pre-fill provenance — when a pick came from the project context, - // the row renders with an "aus Akte" tag so the user notices. - r2FromProject: boolean; - r3FromProject: boolean; - r5FromProject: boolean; - - // Implicit fills — R2 auto-derived from R1 when R1 narrows to one - // forum (e.g. "missed" → no narrowing, "filing" → cross-forum, but - // if downstream R3 lookup returns a single forum we can mark R2 as - // implicit). - r2Implicit: boolean; - r3Implicit: boolean; -} - -const state: WizardState = { - r1: "", r2: "", r3: "", r4: "", r5: "", - r2FromProject: false, r3FromProject: false, r5FromProject: false, - r2Implicit: false, r3Implicit: false, -}; - -// Loaded from the project (if any). -let projectSummary: ProjectSummary | null = null; - -// Proceeding chip cache key: jurisdiction × event_kind. -let lastProcCacheKey = ""; -let cachedProcChips: ProceedingChip[] = []; - -// Event chip cache: keyed on R3 code + R1 event_kind. -let lastEventCacheKey = ""; -let cachedEventChips: EventSearchHit[] = []; - -// Public API --------------------------------------------------------- - -export async function mountWizard(): Promise { - const host = document.getElementById(WIZARD_HOST_ID); - if (!host) return; - - // Hydrate from URL state (mode=wizard&forum=UPC&pt=upc.inf.cfi&…). - const params = new URLSearchParams(window.location.search); - state.r1 = (params.get("kind") as EventKindRow) || ""; - state.r2 = (params.get("forum") as Forum) || ""; - state.r3 = params.get("pt") || ""; - state.r4 = params.get("event") || ""; - state.r5 = (params.get("party") as WizardParty) || ""; - - // Project prefills. - const projectId = params.get("project"); - if (projectId) { - projectSummary = await fetchProject(projectId); - await applyProjectPrefills(); - } else { - projectSummary = null; - } - - renderShell(); - void renderRows(); -} - -// applyProjectPrefills derives R2 + R3 + R5 from the project when they -// haven't been set explicitly. Project picks take precedence over -// unspecified state, but a user-supplied URL pick wins over the -// project default. -async function applyProjectPrefills(): Promise { - if (!projectSummary) return; - // Map our_side → R5. - if (!state.r5) { - const side = projectSummary.our_side; - if (side === "claimant" || side === "applicant" || side === "appellant") { - state.r5 = "claimant"; - state.r5FromProject = true; - } else if (side === "defendant" || side === "respondent") { - state.r5 = "defendant"; - state.r5FromProject = true; - } - } - // Map proceeding_type_id → R3 + infer R2 jurisdiction. - if (projectSummary.proceeding_type_id && !state.r3) { - const pt = await fetchProceedingByID(projectSummary.proceeding_type_id); - if (pt) { - state.r3 = pt.code; - state.r3FromProject = true; - if (pt.group && !state.r2) { - state.r2 = pt.group as Forum; - state.r2FromProject = true; - } - } - } -} - -// Render ------------------------------------------------------------- - -function renderShell(): void { - const host = document.getElementById(WIZARD_HOST_ID); - if (!host) return; - host.innerHTML = ` -
    -
    -

    ${escHtml(t("deadlines.overhaul.wizard.heading"))}

    -

    ${escHtml(t("deadlines.overhaul.wizard.hint"))}

    -
    -
    -
    - `; -} - -async function renderRows(): Promise { - const host = document.getElementById("fristen-wizard-rows"); - if (!host) return; - - // Resolve dynamic row prerequisites BEFORE building markup so chip - // sets are populated. - if (state.r1 && state.r2) { - await ensureProceedingChips(state.r2, state.r1); - // Auto-skip R3 when the narrowed pool has exactly one option. - if (!state.r3 && cachedProcChips.length === 1) { - state.r3 = cachedProcChips[0].code; - state.r3Implicit = true; - } - } - if (state.r1 && state.r3) { - await ensureEventChips(state.r3, state.r1); - } - - const rows: string[] = []; - rows.push(rowR1()); - if (shouldShowR2()) rows.push(rowR2()); - if (shouldShowR3()) rows.push(rowR3()); - if (shouldShowR4()) rows.push(rowR4()); - if (state.r4 && shouldShowR5Sync()) rows.push(rowR5Loading()); - - host.innerHTML = rows.join(""); - wireRowEvents(); - - // R5 conditional check — fires after R4 picked. Inspects /follow-ups - // to see whether they actually differ by party. If yes, show R5. If - // no, or R5 already set, transition straight to result view. - if (state.r4) { - void maybeAdvanceFromR4(); - } -} - -// Should-show predicates -------------------------------------------- - -function shouldShowR2(): boolean { - // Skip R2 only when R1 narrows to a single forum — which today - // never happens for the closed event_kind set (every kind exists in - // multiple jurisdictions). Always show R2 until we have empirical - // evidence otherwise. - return state.r1 !== "" && state.r1 !== "missed"; -} - -function shouldShowR3(): boolean { - if (state.r1 === "" || state.r2 === "") return false; - if (state.r3 && state.r3Implicit) return true; // visible compact - return true; -} - -function shouldShowR4(): boolean { - return state.r3 !== "" && state.r1 !== ""; -} - -// shouldShowR5Sync renders the placeholder row immediately; the actual -// asked-or-not decision happens after the async follow-ups probe in -// maybeAdvanceFromR4. -function shouldShowR5Sync(): boolean { - return state.r4 !== ""; -} - -// Row builders ------------------------------------------------------ - -function rowR1(): string { - const chips = EVENT_KINDS.map((k) => { - const label = t(`deadlines.overhaul.kind.${k}` as never); - const icon = eventKindIcon(k); - return chipHtml("r1", k, label, state.r1 === k, icon); - }).join(""); - return rowShell({ - n: 1, - badge: "filter", - label: t("deadlines.overhaul.wizard.r1.label"), - active: !state.r1, - answeredText: state.r1 ? t(`deadlines.overhaul.kind.${state.r1}` as never) : "", - body: `
    ${chips}
    `, - }); -} - -function rowR2(): string { - const chips = FORUMS.map((f) => chipHtml("r2", f, f, state.r2 === f)).join(""); - return rowShell({ - n: 2, - badge: "filter", - label: t("deadlines.overhaul.wizard.r2.label"), - active: !state.r2, - fromProject: state.r2FromProject, - answeredText: state.r2 || "", - body: `
    ${chips}
    `, - }); -} - -function rowR3(): string { - if (cachedProcChips.length === 0) { - return rowShell({ - n: 3, badge: "qualifier", - label: t("deadlines.overhaul.wizard.r3.label"), - active: true, - body: `
    ${escHtml(t("deadlines.overhaul.wizard.r3.empty"))}
    `, - }); - } - const lang = getLang(); - const chips = cachedProcChips.map((p) => { - const label = lang === "en" ? p.nameEN || p.name : p.name; - return chipHtml("r3", p.code, label, state.r3 === p.code, undefined, p.code); - }).join(""); - let answered = ""; - if (state.r3) { - const hit = cachedProcChips.find((p) => p.code === state.r3); - if (hit) answered = lang === "en" ? hit.nameEN || hit.name : hit.name; - } - return rowShell({ - n: 3, - badge: "qualifier", - label: t("deadlines.overhaul.wizard.r3.label"), - active: !state.r3, - fromProject: state.r3FromProject, - implicit: state.r3Implicit, - answeredText: answered, - body: `
    ${chips}
    `, - }); -} - -function rowR4(): string { - if (cachedEventChips.length === 0) { - return rowShell({ - n: 4, badge: "qualifier", - label: t("deadlines.overhaul.wizard.r4.label"), - active: true, - body: `
    ${escHtml(t("deadlines.overhaul.wizard.r4.empty"))}
    `, - }); - } - const lang = getLang(); - const chips = cachedEventChips.map((e) => { - const label = lang === "en" ? e.name_en || e.name_de : e.name_de; - return chipHtml("r4", e.code, label, state.r4 === e.code, eventKindIcon(e.event_kind as EventKindRow)); - }).join(""); - let answered = ""; - if (state.r4) { - const hit = cachedEventChips.find((e) => e.code === state.r4); - if (hit) answered = lang === "en" ? hit.name_en || hit.name_de : hit.name_de; - } - return rowShell({ - n: 4, - badge: "qualifier", - label: t("deadlines.overhaul.wizard.r4.label"), - active: !state.r4, - answeredText: answered, - body: `
    ${chips}
    `, - }); -} - -function rowR5Loading(): string { - // Placeholder while we probe whether R5 is needed. The async - // follow-ups probe replaces this with rowR5 chips or skips - // straight to the result view. - return rowShell({ - n: 5, badge: "qualifier", - label: t("deadlines.overhaul.wizard.r5.label"), - active: !state.r5, - fromProject: state.r5FromProject, - answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "", - body: `
    ${escHtml(t("deadlines.overhaul.wizard.r5.probing"))}
    `, - }); -} - -function rowR5Chips(): string { - const chips = (["claimant", "defendant"] as const).map((p) => - chipHtml("r5", p, t(`deadlines.party.${p}` as never), state.r5 === p)).join(""); - return rowShell({ - n: 5, badge: "qualifier", - label: t("deadlines.overhaul.wizard.r5.label"), - active: !state.r5, - fromProject: state.r5FromProject, - answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "", - body: `
    ${chips}
    `, - }); -} - -interface RowShellOpts { - n: number; - badge: "filter" | "qualifier"; - label: string; - active: boolean; - body: string; - answeredText?: string; - fromProject?: boolean; - implicit?: boolean; -} - -function rowShell(o: RowShellOpts): string { - const cls = `fristen-wizard-row fristen-wizard-row--${o.badge}` + - (o.active ? " is-active" : " is-answered") + - (o.fromProject ? " is-from-project" : "") + - (o.implicit ? " is-implicit" : ""); - const badgeText = o.badge === "filter" - ? t("deadlines.overhaul.wizard.badge.filter") - : t("deadlines.overhaul.wizard.badge.qualifier"); - const annotations: string[] = []; - if (o.fromProject) annotations.push(`${escHtml(t("deadlines.overhaul.wizard.anno.from_project"))}`); - if (o.implicit) annotations.push(`${escHtml(t("deadlines.overhaul.wizard.anno.implicit"))}`); - const answered = o.answeredText - ? `${escHtml(o.answeredText)}` - : ""; - const edit = !o.active - ? `` - : ""; - return ` -
    -
    - ${o.n} - ${escHtml(badgeText)} - ${escHtml(o.label)} - ${annotations.join("")} - ${answered} - ${edit} -
    - ${o.active ? `
    ${o.body}
    ` : ""} -
    - `; -} - -// Event wiring ------------------------------------------------------ - -function wireRowEvents(): void { - document.querySelectorAll(".fristen-wizard-row .fristen-mode-a-chip").forEach((btn) => { - btn.addEventListener("click", () => { - const axis = btn.dataset.axis || ""; - const value = btn.dataset.value || ""; - handleChip(axis, value); - }); - }); - document.querySelectorAll(".fristen-wizard-row-edit").forEach((btn) => { - btn.addEventListener("click", () => { - const n = parseInt(btn.dataset.row || "0", 10); - handleEdit(n); - }); - }); -} - -function handleChip(axis: string, value: string): void { - switch (axis) { - case "r1": { - if (state.r1 === value) return; - state.r1 = value as EventKindRow; - // R1 change resets R3/R4 (event-kind narrows the pools). - state.r3 = ""; - state.r3Implicit = false; - state.r4 = ""; - state.r5 = state.r5FromProject ? state.r5 : ""; - cachedEventChips = []; - lastEventCacheKey = ""; - cachedProcChips = []; - lastProcCacheKey = ""; - break; - } - case "r2": { - if (state.r2 === value) return; - state.r2 = value as Forum; - state.r2FromProject = false; - state.r2Implicit = false; - // R2 change may invalidate R3 → reset. - state.r3 = ""; - state.r3FromProject = false; - state.r3Implicit = false; - state.r4 = ""; - cachedProcChips = []; - lastProcCacheKey = ""; - cachedEventChips = []; - lastEventCacheKey = ""; - break; - } - case "r3": { - if (state.r3 === value) return; - state.r3 = value; - state.r3FromProject = false; - state.r3Implicit = false; - state.r4 = ""; - cachedEventChips = []; - lastEventCacheKey = ""; - break; - } - case "r4": { - if (state.r4 === value) return; - state.r4 = value; - break; - } - case "r5": { - if (state.r5 === value) return; - state.r5 = value as WizardParty; - state.r5FromProject = false; - break; - } - } - syncUrl(); - void renderRows(); -} - -function handleEdit(n: number): void { - switch (n) { - case 1: - state.r1 = ""; state.r2 = ""; state.r3 = ""; state.r4 = ""; state.r5 = state.r5FromProject ? state.r5 : ""; - cachedProcChips = []; lastProcCacheKey = ""; - cachedEventChips = []; lastEventCacheKey = ""; - break; - case 2: - state.r2 = ""; state.r2FromProject = false; state.r2Implicit = false; - state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false; - state.r4 = ""; - cachedProcChips = []; lastProcCacheKey = ""; - cachedEventChips = []; lastEventCacheKey = ""; - break; - case 3: - state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false; - state.r4 = ""; - cachedEventChips = []; lastEventCacheKey = ""; - break; - case 4: - state.r4 = ""; - state.r5 = state.r5FromProject ? state.r5 : ""; - break; - case 5: - state.r5 = ""; state.r5FromProject = false; - break; - } - syncUrl(); - void renderRows(); -} - -// maybeAdvanceFromR4 fetches /follow-ups for the picked event to -// decide whether R5 is needed. If R5 is already set OR the -// follow-ups don't differ by party, transition straight to the -// result view. Else swap the R5 loading row for the chip picker. -async function maybeAdvanceFromR4(): Promise { - if (!state.r4) return; - if (state.r5) { - // R5 already answered (project prefill or explicit pick) → go. - void launchResult(); - return; - } - // Probe follow-ups. - const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin); - url.searchParams.set("event", state.r4); - try { - const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } }); - if (!resp.ok) { - // Soft-fail → swap to R5 chips so the user can decide manually. - swapR5(rowR5Chips()); - return; - } - const data = (await resp.json()) as { follow_ups: Array<{ primary_party?: string }> }; - const differs = followUpsDifferByParty(data.follow_ups); - if (!differs) { - void launchResult(); - return; - } - swapR5(rowR5Chips()); - } catch { - swapR5(rowR5Chips()); - } -} - -function swapR5(html: string): void { - const host = document.getElementById("fristen-wizard-rows"); - if (!host) return; - const r5 = host.querySelector('.fristen-wizard-row[data-row="5"]'); - if (!r5) { - host.insertAdjacentHTML("beforeend", html); - } else { - r5.outerHTML = html; - } - wireRowEvents(); -} - -function launchResult(): void { - // Hand off to the §4 result view. The URL already carries the - // picks via syncUrl(); add event= so the boot path treats this - // as a deep-link. - const url = new URL(window.location.href); - url.searchParams.set("overhaul", "1"); - url.searchParams.set("event", state.r4); - if (state.r5) url.searchParams.set("party", state.r5); - else url.searchParams.delete("party"); - history.pushState(null, "", url.pathname + url.search + url.hash); - void mountResultView({ eventRef: state.r4, party: state.r5 || undefined }); -} - -export function followUpsDifferByParty(rows: Array<{ primary_party?: string }>): boolean { - let hasClaimant = false, hasDefendant = false; - for (const r of rows) { - if (r.primary_party === "claimant") hasClaimant = true; - else if (r.primary_party === "defendant") hasDefendant = true; - if (hasClaimant && hasDefendant) return true; - } - return false; -} - -// Fetches ----------------------------------------------------------- - -async function fetchProject(id: string): Promise { - try { - const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`, { headers: { Accept: "application/json" } }); - if (!resp.ok) return null; - return (await resp.json()) as ProjectSummary; - } catch { - return null; - } -} - -async function fetchProceedingByID(id: number): Promise { - // The proceeding-types endpoint returns codes, names, jurisdictions - // but doesn't carry the id (the wire shape FristenrechnerType is - // code-keyed). Walk the unfiltered list and pick by sort-order - // proximity / sort-fallback: we need the row whose id matches; since - // the wire doesn't expose id, fetch the projects detail to get the - // code directly. Cheap workaround: rely on /api/projects/{id}'s - // proceeding_type_id being matched against the proceeding-types list - // by jurisdiction round-trip is not possible without id. Instead - // expose the proceeding-types-by-id mapping via a follow-up endpoint - // later. For now hit the unfiltered list and assume the project's - // pick is in the active set. - // - // Pragmatic fallback: query the full list and return the only entry - // whose pseudo-id-via-sort-order matches. The lookup is unreliable - // until the wire shape includes id; for the project-prefill case the - // user can always re-pick R3 / R2 if the prefill misfires. - try { - const resp = await fetch(`/api/tools/proceeding-types`, { headers: { Accept: "application/json" } }); - if (!resp.ok) return null; - const list = (await resp.json()) as ProceedingChip[] | null; - if (!list || list.length === 0) return null; - // Without id in the wire we cannot match by id. Skip the prefill - // silently — R3 stays unanswered and the user picks manually. - // (S5/follow-up can extend the wire shape to include id.) - void id; - return null; - } catch { - return null; - } -} - -async function ensureProceedingChips(forum: Forum, kind: EventKindRow): Promise { - const key = `${forum}\x00${kind}`; - if (lastProcCacheKey === key) return; - lastProcCacheKey = key; - const url = new URL("/api/tools/proceeding-types", window.location.origin); - url.searchParams.set("kind", "proceeding"); - url.searchParams.set("jurisdiction", forum); - if (kind !== "missed") url.searchParams.set("event_kind", kind); - try { - const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } }); - if (!resp.ok) { - cachedProcChips = []; - return; - } - const data = (await resp.json()) as ProceedingChip[] | null; - cachedProcChips = data || []; - } catch { - cachedProcChips = []; - } -} - -async function ensureEventChips(procCode: string, kind: EventKindRow): Promise { - const key = `${procCode}\x00${kind}`; - if (lastEventCacheKey === key) return; - lastEventCacheKey = key; - const url = new URL("/api/tools/fristenrechner/search", window.location.origin); - url.searchParams.set("kind", "events"); - url.searchParams.set("proc", procCode); - if (kind !== "missed") url.searchParams.set("event_kind", kind); - url.searchParams.set("limit", "100"); - try { - const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } }); - if (!resp.ok) { - cachedEventChips = []; - return; - } - const data = (await resp.json()) as EventSearchResponse; - cachedEventChips = data.events || []; - } catch { - cachedEventChips = []; - } -} - -// Helpers ----------------------------------------------------------- - -function chipHtml(axis: string, value: string, label: string, active: boolean, icon?: string, title?: string): string { - const cls = `fristen-mode-a-chip${active ? " is-active" : ""}`; - const tt = title ? ` title="${escAttr(title)}"` : ""; - const i = icon ? `` : ""; - return ``; -} - -function eventKindIcon(kind?: EventKindRow): string { - switch (kind) { - case "filing": return "📥"; - case "hearing": return "🏛️"; - case "decision": return "⚖️"; - case "order": return "📜"; - case "missed": return "⏲"; - default: return "📅"; - } -} - -function syncUrl(): void { - const url = new URL(window.location.href); - url.searchParams.set("overhaul", "1"); - url.searchParams.set("mode", "wizard"); - setOrClear(url, "kind", state.r1); - setOrClear(url, "forum", state.r2); - setOrClear(url, "pt", state.r3); - // event=… is set only on launchResult; the wizard URL carries the - // R4 candidate via r4= so back/forward navigates within the wizard. - setOrClear(url, "r4", state.r4); - setOrClear(url, "party", state.r5); - history.replaceState(null, "", url.pathname + url.search + url.hash); -} - -function setOrClear(url: URL, key: string, val: string): void { - if (val) url.searchParams.set(key, val); - else url.searchParams.delete(key); -} diff --git a/frontend/src/client/verfahrensablauf.ts b/frontend/src/client/verfahrensablauf.ts deleted file mode 100644 index 2801ecf..0000000 --- a/frontend/src/client/verfahrensablauf.ts +++ /dev/null @@ -1,1264 +0,0 @@ -// /tools/verfahrensablauf client (t-paliad-179 Slice 1) -// -// Abstract-browse surface: pick a proceeding, pick a trigger date, -// see the typical timeline. No Akte, no save-to-project, no anchor -// override editing, no Pathway B cascade. Variant chips + lane view -// (Slice 3) and compare (Slice 4) layer on top of this in later -// slices. Court picker + view toggle + calc fetch + renderers all -// come from ./views/verfahrensablauf-core, which fristenrechner.ts -// shares. - -import { t, tDyn, getLang, onLangChange } from "./i18n"; -import { - type DeadlineResponse, - calculateDeadlines, - escHtml, - formatDate, - populateCourtPicker, - renderColumnsBody, - renderTimelineBody, - wireDateEditClicks, -} from "./views/verfahrensablauf-core"; -import { - attachEventCardChoices, - reseedChips, - type EventChoice, -} from "./views/event-card-choices"; -import { - filterByDetailMode, - getDetailMode, - isRuleSelected, - setDetailMode, - type DetailMode, -} from "./verfahrensablauf-detail-mode"; -import { - fetchScenarioFlags, - onScenarioFlagsChanged, - patchScenarioFlags, - SCENARIO_FLAG_CHANGED_EVENT, -} from "./scenario-flags"; -import { - APPEAL_TARGETS, - SCENARIO_KEYS, - type AppealTarget, - type Side, - type StorageLike, - applyFiltersToSearch, - makeMemoryStorage, - parseAppealTargetFromSearch, - parseProceedingFromSearch, - parseSideFromSearch, - parseTriggerDateFromSearch, - readBoolFlag, - readCourtId, - readEventChoices, - writeBoolFlag, - writeCourtId, - writeEventChoices, -} from "./views/verfahrensablauf-state"; - -let selectedType = ""; -let lastResponse: DeadlineResponse | null = null; - -// m/paliad#149 Phase 2 P3 — detail-level view-mode + scenario-flag state. -// -// detailMode: which of the three filter buckets is active. Persisted in -// localStorage under verfahrensablauf:view_mode so it survives reload -// and follows the user across projects (m's "I want a grade of detail -// in our swimlane display" framing — it's a UI preference, not a -// scenario fact). -// -// projectId: when the page is opened with ?project=, scenario_flag -// reads/writes go through PATCH /api/projects/{id}/scenario-flags (the -// P0 SSoT). Kontextfrei (no project) stays on localStorage via the -// existing perCardChoices path; per-rule selection deviations land in -// scenarioFlagsLocal keyed by proceeding_type code. -// -// scenarioFlags: live map shadow. Refreshed by hydrateScenarioFlags() -// on project pick + listens to the scenario-flag-changed CustomEvent -// so toggles from other surfaces (Mode B Fristenrechner result-view) -// re-trigger re-render here without a fresh fetch. -let detailMode: DetailMode = getDetailMode(); -let projectIdForFlags: string | null = null; -let scenarioFlags: Record = {}; - -// Perspective state. URL-driven so the view is shareable + survives -// reload: -// ?side=claimant|defendant — swaps which column owns the user's -// side (proactive vs reactive label). -// Default null = claimant-on-the-left. -// -// t-paliad-301 / m/paliad#132 collapsed the duplicate ?side= + -// ?appellant= selectors into the single proactive-side picker above. -// For role-swap proceedings (Appeal / EPA Opposition / DE Revision / -// DPMA Appeal) the picker's labels swap to per-proceeding role -// strings (Berufungskläger / Berufungsbeklagter, …) via ROLE_LABELS -// below — but the underlying claimant/defendant value the engine -// consumes is unchanged. -let currentSide: Side = null; - -// Project-driven auto-fill state (t-paliad-279 / m/paliad#111). When the -// page is opened with ?project= and that project has our_side set, -// the side row renders as a read-only chip instead of the radio cluster. -// The user can flip to free-pick via the "Andere Seite wählen" override -// link, which clears this flag (radio cluster takes over again). -let sidePrefilledFromProject = false; - -// Role-swap proceedings — the side picker doubles as the appellant -// axis. After t-paliad-301 collapsed the duplicate selectors, the -// engine reads "appellant" from the single side value for these -// proceedings (so a row with primary_party=both renders only in the -// chosen side's column). For first-instance proceedings (Inf, Rev, -// …) the side picker still narrows columns but doesn't collapse -// the "both" rows. -// -// upc.apl.unified is NOT in this set since t-paliad-307: appeal -// timelines route via per-rule appealRole (engine-stamped under -// appeal_target) instead of the page-level appellant axis collapse. -// Adding upc.apl.unified here would short-circuit the appealAware -// path and re-introduce the dead side selector on upc.apl.unified -// (m/paliad#136 Bug 1). -const APPELLANT_AXIS_PROCEEDINGS = new Set([ - "de.inf.olg", - "de.inf.bgh", - "de.null.bgh", - "dpma.appeal.bpatg", - "dpma.appeal.bgh", - "epa.opp.boa", -]); - -// Per-proceeding role labels (t-paliad-301 / m/paliad#132 Bug A). -// Mirrors paliad.proceeding_types.role_*_label_* — the canonical -// definition lives in the DB; this map is the frontend's view of -// it. Proceedings absent from the map fall back to the generic -// "deadlines.side.claimant" / "deadlines.side.defendant" i18n keys. -// -// Keep in sync with mig 137's backfill. Adding a row here without a -// matching DB row is fine (the DB col is NULL → still falls back to -// default; UI shows the override). Adding to the DB without here -// means the UI uses defaults — harmless but inconsistent. -type RoleLabels = { proDE: string; reDE: string; proEN: string; reEN: string }; -const ROLE_LABELS: Record = { - "upc.apl.unified": { - proDE: "Berufungskläger", - reDE: "Berufungsbeklagter", - proEN: "Appellant", - reEN: "Appellee", - }, - "upc.rev.cfi": { - proDE: "Antragsteller (Nichtigkeit)", - reDE: "Antragsgegner (Nichtigkeit)", - proEN: "Revocation claimant", - reEN: "Revocation defendant", - }, - "epa.opp.opd": { - proDE: "Einsprechende(r)", - reDE: "Patentinhaber(in)", - proEN: "Opponent", - reEN: "Patentee", - }, - "epa.opp.boa": { - proDE: "Einsprechende(r)", - reDE: "Patentinhaber(in)", - proEN: "Opponent", - reEN: "Patentee", - }, -}; - -// Slice B1 (m/paliad#124 §18.1) — Berufung unification. -// Proceedings that surface the appeal-target chip group. Currently -// only the unified upc.apl proceeding; future variants (e.g. de.apl) -// can opt in by adding the code here. -// -// APPEAL_TARGETS itself lives in ./views/verfahrensablauf-state so the -// pure URL parser and this page share the same canonical list. -const APPEAL_TARGET_PROCEEDINGS = new Set([ - "upc.apl.unified", -]); - -function hasAppealTarget(proceedingType: string): boolean { - return APPEAL_TARGET_PROCEEDINGS.has(proceedingType); -} - -function hasAppellantAxis(proceedingType: string): boolean { - return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType); -} - -// Scenario storage — real localStorage in the browser, in-memory -// fallback when localStorage throws (private mode, disabled storage, -// etc.). All scenario writes go through this single handle so a -// failure mode is isolated to one try/catch path. -const scenarioStorage: StorageLike = makeScenarioStorage(); - -function makeScenarioStorage(): StorageLike { - try { - const probe = "__paliad_va_probe__"; - window.localStorage.setItem(probe, "1"); - window.localStorage.removeItem(probe); - return window.localStorage; - } catch { - return makeMemoryStorage(); - } -} - -// URL writers — all four chip params route through this single helper -// so the canonical query-string shape (no empty values, no trailing -// `?`) is enforced in one place. -function applyURLFilters(filters: { - proceeding?: string; - side?: Side; - target?: AppealTarget; - triggerDate?: string; -}): void { - const url = new URL(window.location.href); - const nextSearch = applyFiltersToSearch(url.search, filters); - window.history.replaceState(null, "", url.pathname + nextSearch + url.hash); -} - -// t-paliad-301 / m/paliad#132: applies ROLE_LABELS to the side-row -// radio labels for the currently selected proceeding. Proceedings -// without an entry fall back to the existing -// "deadlines.side.claimant" / "deadlines.side.defendant" i18n keys. -function applyRoleLabels(proceedingType: string) { - const lang = getLang() === "en" ? "en" : "de"; - const claimantSpan = document.querySelector( - "input[type=radio][name=side][value=claimant] + span" - ); - const defendantSpan = document.querySelector( - "input[type=radio][name=side][value=defendant] + span" - ); - if (!claimantSpan || !defendantSpan) return; - - const labels = ROLE_LABELS[proceedingType]; - if (labels) { - claimantSpan.textContent = lang === "en" ? labels.proEN : labels.proDE; - defendantSpan.textContent = lang === "en" ? labels.reEN : labels.reDE; - } else { - // Default — let i18n drive via data-i18n attribute. Reset to the - // canonical i18n value so a previous override doesn't stick when - // switching from upc.apl.unified back to upc.inf.cfi. - claimantSpan.textContent = t("deadlines.side.claimant"); - defendantSpan.textContent = t("deadlines.side.defendant"); - } -} - -// Default target on first picker entry into upc.apl. m: Endentscheidung -// is the most-common appeal target; the chip group also defaults -// "Endentscheidung" checked in verfahrensablauf.tsx. Keep these two in -// sync so the URL-less default render hits the same code path. -let currentAppealTarget: AppealTarget = ""; - -// Per-rule anchor overrides set by the click-to-edit affordance on -// timeline / column date cells. Posted as `anchorOverrides` to the -// /api/tools/fristenrechner calc so downstream rules re-anchor off the -// user's chosen date. Cleared whenever the trigger changes (proceeding, -// trigger date, flag toggle) so a fresh calc starts unanchored — same -// semantic as /tools/fristenrechner. -const anchorOverrides = new Map(); -function clearAnchorOverrides() { anchorOverrides.clear(); } - -// Per-event-card choices (t-paliad-265). Unbound on this page (no -// project context). Persistence moved from URL → localStorage under -// SCENARIO_KEYS.eventChoices (t-paliad-308 / m/paliad#137) — these -// are per-user scenario tweaks, not the timeline kind, so a shared -// link should NOT leak them into the recipient's view. -let perCardChoices: EventChoice[] = readEventChoices(scenarioStorage); - -// Show-hidden toggle (t-paliad-290 / m/paliad#122). When ON, the -// calculator re-surfaces cards whose submission_code is in the active -// skipRules set; they render faded with a "Wieder einblenden" chip. -// Persistence moved from URL → localStorage (t-paliad-308) — it's a -// per-user UX preference, not scenario state worth sharing in a link. -let showHidden = readBoolFlag(scenarioStorage, SCENARIO_KEYS.showHidden); - -type ProcedureView = "timeline" | "columns"; -let procedureView: ProcedureView = "columns"; - -// Notes toggle — when off (default), per-rule descriptive notes render -// as a compact ⓘ icon next to the meta line (hover for full text). When -// on, the full notes block expands under each card. Choice persists in -// localStorage so a reload or recalc keeps the user's preference. -const NOTES_PREF_KEY = "paliad.fristen.notes-show"; -function readNotesPref(): boolean { - try { return localStorage.getItem(NOTES_PREF_KEY) === "1"; } catch { return false; } -} -function writeNotesPref(on: boolean): void { - try { localStorage.setItem(NOTES_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ } -} -let showNotes = readNotesPref(); - -// Durations toggle (m/paliad#133, t-paliad-302) — when off (default), -// the per-rule duration label ("2 Mo. nach") only shows on hover via -// the date span's `title` attribute. When on, the label renders inline -// in the timeline meta row of every event card. Persisted in -// localStorage under its own key so the preference is independent of -// "Hinweise anzeigen". -const DURATIONS_PREF_KEY = "paliad.verfahrensablauf.durations-show"; -function readDurationsPref(): boolean { - try { return localStorage.getItem(DURATIONS_PREF_KEY) === "1"; } catch { return false; } -} -function writeDurationsPref(on: boolean): void { - try { localStorage.setItem(DURATIONS_PREF_KEY, on ? "1" : "0"); } catch { /* no-op */ } -} -let showDurations = readDurationsPref(); - -// Jurisdiction display prefix for the proceeding-summary chip + the -// trigger-event placeholder. Same forum slugs the .proceeding-group -// `data-forum` attribute carries in verfahrensablauf.tsx / -// fristenrechner.tsx (upc / de / epa / dpma). Disambiguates the -// 4 redundancies in the corpus (UPC Verletzungsverfahren vs DE -// Verletzungsklage etc.) once the picker collapses. -const FORUM_LABEL: Record = { - upc: "UPC", - de: "DE", - epa: "EPA", - dpma: "DPMA", -}; - -function jurisdictionFor(btn: HTMLButtonElement): string { - const group = btn.closest(".proceeding-group"); - const forum = group?.dataset.forum || ""; - return FORUM_LABEL[forum] || ""; -} - -function proceedingDisplayName(btn: HTMLButtonElement): string { - const name = btn.querySelector("strong")?.textContent || ""; - const jur = jurisdictionFor(btn); - return jur ? `${jur} ${name}` : name; -} - -function activeProceedingButton(): HTMLButtonElement | null { - return document.querySelector(".proceeding-btn.active"); -} - -// Auto-calc plumbing — sequence + debounce mirror /tools/fristenrechner -// so rapid input changes never let a stale response overwrite a fresh -// one. -let calcSeq = 0; -let calcTimer: ReturnType | null = null; - -function scheduleCalc(delayMs = 200) { - if (calcTimer !== null) clearTimeout(calcTimer); - calcTimer = setTimeout(() => { - calcTimer = null; - void doCalc(); - }, delayMs); -} - -function showStep(n: number) { - for (let i = 1; i <= 3; i++) { - const el = document.getElementById(`step-${i}`); - if (el) el.style.display = i <= n ? "block" : "none"; - } -} - -// Read the proceeding-specific flag checkboxes and assemble the -// payload the calculator expects. Mirrors fristenrechner.ts so the -// gating semantics stay identical: with_amend on upc.inf.cfi is -// nested under with_ccr (R.30 is only available with a CCR); -// upc.rev.cfi exposes with_amend + with_cci as two independent -// gates. R.19 Einspruch is NOT flag-gated (mig 098, m's 2026-05-18 -// call): it's just an always-available optional submission, so it -// has no checkbox. -function readFlags(): string[] { - const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null; - const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null; - const revAmend = document.getElementById("rev-amend-flag") as HTMLInputElement | null; - const revCci = document.getElementById("rev-cci-flag") as HTMLInputElement | null; - const flags: string[] = []; - if (selectedType === "upc.inf.cfi") { - if (ccr?.checked) flags.push("with_ccr"); - if (ccr?.checked && infAmend?.checked) flags.push("with_amend"); - } - if (selectedType === "upc.rev.cfi") { - if (revAmend?.checked) flags.push("with_amend"); - if (revCci?.checked) flags.push("with_cci"); - } - return flags; -} - -async function doCalc() { - const seq = ++calcSeq; - const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null; - const triggerDate = dateInput?.value || ""; - if (!triggerDate || !selectedType) return; - - const courtPickerRow = document.getElementById("court-picker-row"); - const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null; - const courtId = courtPickerRow && courtPickerRow.style.display !== "none" && courtPicker?.value - ? courtPicker.value - : ""; - - const overrides: Record = {}; - for (const [code, date] of anchorOverrides) overrides[code] = date; - - // Slice B1 (m/paliad#124 §18.1): for the unified upc.apl Berufung, - // default to "endentscheidung" when no chip pick is stored in URL. - // For non-appeal proceedings the engine ignores opts.AppealTarget. - const appealTarget = hasAppealTarget(selectedType) - ? (currentAppealTarget || "endentscheidung") - : ""; - - const data = await calculateDeadlines({ - proceedingType: selectedType, - triggerDate, - flags: readFlags(), - anchorOverrides: overrides, - courtId, - perCardChoices, - includeHidden: showHidden, - appealTarget, - }); - if (seq !== calcSeq) return; - if (!data) return; - lastResponse = data; - renderResults(data); - syncHiddenBadge(data.hiddenCount ?? 0); - showStep(3); -} - -// syncHiddenBadge updates the "Ausgeblendete (N)" count next to the -// toggle. Visible regardless of toggle state so the user knows whether -// there's anything to re-surface even when the toggle is OFF. Hides the -// whole row when the projection has zero hidden cards — no clutter on -// a project that's never used the skip feature. (t-paliad-290) -function syncHiddenBadge(count: number) { - const row = document.getElementById("show-hidden-row"); - const badge = document.getElementById("show-hidden-count"); - if (!row || !badge) return; - if (count <= 0) { - row.style.display = "none"; - return; - } - row.style.display = ""; - badge.textContent = tDyn("choices.show_hidden.count").replace("{n}", String(count)); -} - -// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis" -// label from the calc response. Precedence: -// -// 1. Server-supplied triggerEventLabel from proceeding_types -// (mig 121, m/paliad#81). UPC Appeal sets this to -// "Anfechtbare Entscheidung" / "Appealable Decision" — its rules -// all carry a non-zero duration off the trigger date so none is -// the root, and the proceedingName fallback ("Berufungsverfahren") -// misnamed the input as the proceeding itself. -// 2. Root rule (isRootEvent=true) — the first event in the -// proceeding, e.g. Klageerhebung for upc.inf.cfi, -// Nichtigkeitsklage for upc.rev.cfi. -// 3. Active proceeding name — last-resort fallback. Language-aware -// (m/paliad#58: prior code rendered DE on EN for sub-track -// proceedings like upc.ccr.cfi which had no rules → no root). -function triggerEventLabelFor(data: DeadlineResponse): string { - const lang = getLang(); - const curated = lang === "en" - ? (data.triggerEventLabelEN || data.triggerEventLabel) - : (data.triggerEventLabel || data.triggerEventLabelEN); - if (curated) return curated; - const root = data.deadlines.find((d) => d.isRootEvent); - if (root) { - return lang === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN); - } - if (lang === "en") { - return data.proceedingNameEN || data.proceedingName || ""; - } - return data.proceedingName || data.proceedingNameEN || ""; -} - -function syncTriggerEventLabel() { - const triggerEventEl = document.getElementById("trigger-event"); - if (!triggerEventEl) return; - if (lastResponse) { - triggerEventEl.textContent = triggerEventLabelFor(lastResponse); - } else { - triggerEventEl.textContent = "—"; - } -} - -function renderResults(data: DeadlineResponse) { - const container = document.getElementById("timeline-container"); - if (!container) return; - const printBtn = document.getElementById("fristen-print-btn"); - const toggle = document.getElementById("fristen-view-toggle"); - - // Header shows the picked proceeding with its jurisdiction prefix - // so the user can tell UPC Verletzungsverfahren apart from DE - // Verletzungsklage once the picker collapses. - const activeBtn = activeProceedingButton(); - const procName = activeBtn ? proceedingDisplayName(activeBtn) - : tDyn(`deadlines.${data.proceedingType.toLowerCase()}`); - const headerHtml = `
    - ${procName} - ${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)} -
    `; - - // Sub-track contextual note (m/paliad#58). Surfaces above the - // timeline body when the server routed the user-picked proceeding - // through a parent (e.g. upc.ccr.cfi → upc.inf.cfi with with_ccr). - // Plain-text banner — server-side copy is plain text per the - // SubTrackRouting contract. - const noteText = getLang() === "en" - ? (data.contextualNoteEN || data.contextualNote || "") - : (data.contextualNote || data.contextualNoteEN || ""); - const noteHtml = noteText - ? `
    ${escHtml(noteText)}
    ` - : ""; - - // m/paliad#149 Phase 2 P3 — apply the detail-level filter pre-render. - // The calc payload stays the same; we just narrow what the renderer - // sees. Root events always pass through so the proceeding anchor is - // never hidden by the filter. - const filteredDeadlines = filterByDetailMode(data.deadlines, detailMode, scenarioFlags); - const filteredData: DeadlineResponse = { ...data, deadlines: filteredDeadlines }; - - const bodyHtml = procedureView === "columns" - ? renderColumnsBody(filteredData, { - editable: true, - showNotes, - showDurations, - side: currentSide, - // t-paliad-301: the appellant axis collapses into the single - // side picker. For role-swap proceedings, currentSide IS the - // appellant pick (so a row with primary_party=both renders only - // in the picked side's column). For non-role-swap proceedings, - // the appellant axis is irrelevant — pass null. - appellant: hasAppellantAxis(selectedType) ? currentSide : null, - // Appeal-target proceedings get per-rule appealRole routing - // instead of the page-level appellant collapse, so the side - // selector actually splits Berufungskläger vs Berufungs- - // beklagter filings across columns. (t-paliad-307 / - // m/paliad#136 Bug 1) - appealAware: hasAppealTarget(selectedType), - }) - : renderTimelineBody(filteredData, { showParty: true, editable: true, showNotes, showDurations }); - - container.innerHTML = headerHtml + noteHtml + bodyHtml; - if (printBtn) printBtn.style.display = "block"; - if (toggle) toggle.style.display = ""; - - syncTriggerEventLabel(); - - // t-paliad-265: rehydrate per-event-card chip indicators after every - // re-render so the popover-driven active state survives the - // innerHTML rewrite the timeline body just did. - reseedChips(container); -} - -function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) { - const groups = document.querySelectorAll(".proceeding-group"); - const summary = document.getElementById("proceeding-summary") as HTMLElement | null; - const summaryName = document.getElementById("proceeding-summary-name"); - groups.forEach((g) => { g.style.display = collapsed ? "none" : ""; }); - if (summary) summary.style.display = collapsed ? "" : "none"; - if (summaryName && displayName) summaryName.textContent = displayName; -} - -// syncFlagRows shows/hides the proceeding-specific checkbox rows -// based on selectedType. Same disposition as fristenrechner.ts — -// the with_amend nested-under-ccr semantic is enforced via -// syncInfAmendEnabled(). -function syncFlagRows() { - const show = (id: string, when: boolean) => { - const el = document.getElementById(id); - if (el) el.style.display = when ? "" : "none"; - }; - show("ccr-flag-row", selectedType === "upc.inf.cfi"); - show("inf-amend-flag-row", selectedType === "upc.inf.cfi"); - show("rev-amend-flag-row", selectedType === "upc.rev.cfi"); - show("rev-cci-flag-row", selectedType === "upc.rev.cfi"); - syncInfAmendEnabled(); -} - -// R.30 amendment-application is only available with a CCR — disable -// (and clear) the nested inf-amend checkbox while ccr is off so the -// calc payload stays coherent. Mirrors fristenrechner.ts. -function syncInfAmendEnabled() { - const ccr = document.getElementById("ccr-flag") as HTMLInputElement | null; - const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null; - if (!ccr || !infAmend) return; - infAmend.disabled = !ccr.checked; - if (!ccr.checked) infAmend.checked = false; -} - -function selectProceeding(btn: HTMLButtonElement, opts: { writeURL?: boolean } = {}) { - document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active")); - btn.classList.add("active"); - const nextType = btn.dataset.code || ""; - // Different proceeding tree → previously-set overrides reference - // rule codes that don't exist in the new tree. Clear before the - // next calc so the fresh proceeding starts unanchored. - if (selectedType !== nextType) clearAnchorOverrides(); - selectedType = nextType; - - // Persist the picked proceeding to ?proceeding= so a refresh / shared - // link reproduces the same tile. writeURL=false on the load-time - // hydration path so we don't churn history.replaceState when the - // URL already carries the canonical value. - if (opts.writeURL !== false) { - applyURLFilters({ proceeding: selectedType }); - } - - // Trigger-event label fires from the calc response (root rule). - // Until step 3 renders, fall back to an em-dash placeholder. - lastResponse = null; - syncTriggerEventLabel(); - - syncFlagRows(); - syncAppealTargetRowVisibility(); - applyRoleLabels(selectedType); - // Restore flags from localStorage BEFORE the initial calc so the - // first /api/tools/fristenrechner POST already carries the user's - // stored flag state. Court_id is async (populateCourtPicker fetches - // courts from the API) so it restores via the .then() below + a - // follow-up recalc when the picker is ready. - restoreFlagsForProceeding(); - - setProceedingPickerCollapsed(true, proceedingDisplayName(btn)); - - showStep(2); - scheduleCalc(0); - - void populateCourtPicker("court-picker-row", "court-picker", selectedType).then(() => { - if (restoreCourtForProceeding()) scheduleCalc(0); - }); -} - -// restoreFlagsForProceeding seeds the proceeding-specific flag -// checkboxes from localStorage. Mirrors syncFlagRows in scope — only -// flags currently visible for the active proceeding are meaningful -// (the hidden checkboxes still write to localStorage if toggled, but -// that's impossible because they're not in the DOM as visible -// controls). syncInfAmendEnabled enforces the upc.inf.cfi inf-amend -// gating after the restore. -function restoreFlagsForProceeding(): void { - const flagPairs: Array<[string, string]> = [ - ["ccr-flag", SCENARIO_KEYS.ccr], - ["inf-amend-flag", SCENARIO_KEYS.infAmend], - ["rev-amend-flag", SCENARIO_KEYS.revAmend], - ["rev-cci-flag", SCENARIO_KEYS.revCci], - ]; - for (const [domId, storageKey] of flagPairs) { - const cb = document.getElementById(domId) as HTMLInputElement | null; - if (!cb) continue; - cb.checked = readBoolFlag(scenarioStorage, storageKey); - } - syncInfAmendEnabled(); -} - -// restoreCourtForProceeding tries to apply the localStorage court_id -// to the picker after populateCourtPicker resolves. Returns true iff -// a value actually changed (so the caller can schedule a follow-up -// calc). Skips silently when the picker is hidden, the stored ID isn't -// in the options list (court rotated since last visit), or the picker -// already happens to be on the stored value. -function restoreCourtForProceeding(): boolean { - const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null; - const storedCourtId = readCourtId(scenarioStorage); - if (!courtPicker || !storedCourtId) return false; - const has = Array.from(courtPicker.options).some((o) => o.value === storedCourtId); - if (!has) return false; - if (courtPicker.value === storedCourtId) return false; - courtPicker.value = storedCourtId; - return true; -} - -// Slice B1 (m/paliad#124 §18.1) — Berufung unification. -// syncAppealTargetRowVisibility shows the appeal-target chip group -// when the unified upc.apl Berufung tile is selected, hides it -// otherwise. Mirrors syncAppellantRowVisibility's pattern: clears -// state + URL when hiding so a stale ?target= can't leak. -function syncAppealTargetRowVisibility() { - const row = document.getElementById("appeal-target-row"); - if (!row) return; - const visible = hasAppealTarget(selectedType); - row.style.display = visible ? "" : "none"; - if (!visible && currentAppealTarget !== "") { - currentAppealTarget = ""; - applyURLFilters({ target: "" }); - syncRadioGroup("appeal-target", "endentscheidung"); - } -} - -function syncRadioGroup(name: string, value: string) { - document.querySelectorAll(`input[type=radio][name=${name}]`).forEach((input) => { - input.checked = input.value === value; - }); -} - -// Project context (t-paliad-279 / m/paliad#111). When the page is opened -// with ?project= and the project carries an our_side value, the side -// row renders as a read-only chip with an "Andere Seite wählen" override -// link. The proceeding picker + appellant axis stay untouched — only the -// side selector pre-fills. -interface ProjectOurSide { - id: string; - our_side?: - | "claimant" - | "defendant" - | "applicant" - | "appellant" - | "respondent" - | "third_party" - | "other" - | null; -} - -function readProjectFromURL(): string { - return new URLSearchParams(window.location.search).get("project") || ""; -} - -// ourSideToSide maps the project-level our_side enum (t-paliad-222) onto -// the side-selector's two-value axis. Active roles (claimant / applicant / -// appellant) collapse to "claimant"; reactive roles (defendant / -// respondent) collapse to "defendant"; everything else (third_party / -// other / NULL) returns null = no pre-fill. Mirrors fristenrechner.ts -// ourSideToPerspective() so projects render consistently across both -// surfaces. -function ourSideToSide(os: ProjectOurSide["our_side"] | undefined): Side { - switch (os) { - case "claimant": - case "applicant": - case "appellant": - return "claimant"; - case "defendant": - case "respondent": - return "defendant"; - default: - return null; - } -} - -async function fetchProjectOurSide(projectID: string): Promise { - try { - const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}`, { - credentials: "same-origin", - }); - if (!resp.ok) return null; - return (await resp.json()) as ProjectOurSide; - } catch { - return null; - } -} - -function sideLabelI18n(s: Side): string { - if (s === "claimant") return t("deadlines.side.claimant"); - if (s === "defendant") return t("deadlines.side.defendant"); - return t("deadlines.side.undefined"); -} - -// syncSideHintVisibility shows the "pick a side" hint chip only while -// currentSide is unset (m/paliad#120). When the user has picked -// claimant / defendant the columns are already focused, so the prompt -// would be misleading. -function syncSideHintVisibility() { - const hint = document.getElementById("side-hint"); - if (!hint) return; - hint.style.display = currentSide === null ? "" : "none"; -} - -// renderSideChip swaps the radio cluster for a read-only chip showing -// the auto-filled side + an "Andere Seite wählen" override link. Called -// after fetchProjectOurSide resolves to a side. The override link clears -// the prefilled flag and swaps back to the radio cluster — the user can -// then pick any side freely. -function renderSideChip(side: Side) { - const cluster = document.getElementById("side-radio-cluster"); - const chip = document.getElementById("side-chip"); - const value = document.getElementById("side-chip-value"); - if (!cluster || !chip || !value) return; - cluster.style.display = "none"; - chip.style.display = ""; - value.textContent = sideLabelI18n(side); -} - -function showSideRadioCluster() { - const cluster = document.getElementById("side-radio-cluster"); - const chip = document.getElementById("side-chip"); - if (!cluster || !chip) return; - cluster.style.display = ""; - chip.style.display = "none"; - // Cluster re-appears after override → re-evaluate hint visibility so - // we don't leave a stale "pick a side" prompt above a checked radio. - syncSideHintVisibility(); -} - -// applySidePrefill takes a project's our_side, maps it to the side axis, -// and locks the side row to a read-only chip if a mapping exists. URL -// wins — if ?side= is already explicit, the user (or shared link) has -// already chosen and we never overwrite. When we do prefill, write the -// derived side to the URL so reload + back/forward round-trip cleanly. -function applySidePrefill(os: ProjectOurSide["our_side"] | undefined) { - if (parseSideFromSearch(window.location.search) !== null) return; - const next = ourSideToSide(os); - if (next === null) return; - currentSide = next; - applyURLFilters({ side: next }); - syncRadioGroup("side", next); - sidePrefilledFromProject = true; - renderSideChip(next); - if (lastResponse) renderResults(lastResponse); -} - -function clearSidePrefill() { - sidePrefilledFromProject = false; - showSideRadioCluster(); - // Drop ?project= from the URL so a reload doesn't re-lock the side. - // ?side= stays — that's the user's last pick at this point. - const url = new URL(window.location.href); - url.searchParams.delete("project"); - window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash); -} - -async function initProjectAutofill() { - const projectID = readProjectFromURL(); - if (!projectID) return; - const project = await fetchProjectOurSide(projectID); - if (!project) return; - applySidePrefill(project.our_side); -} - -function applyVerfahrensablaufViewBodyClass(view: ProcedureView) { - // Mirrors the events.ts pattern (body.events-view-*). The print - // stylesheet keys `body.verfahrensablauf-view-timeline` to - // `@page paliad-landscape`, so flipping this class is what lets a - // user print the horizontal timeline in landscape without affecting - // the columns view (which stays portrait). - document.body.classList.toggle("verfahrensablauf-view-timeline", view === "timeline"); - document.body.classList.toggle("verfahrensablauf-view-columns", view === "columns"); -} - -// initDetailModeToggle wires the three-way Anzeige toggle (Nur Pflicht / -// Gewählt / Alle Optionen) introduced for m/paliad#149 Phase 2 P3. -// State persists via localStorage (per-user, per-browser); flipping the -// radio re-renders the last response without a fresh calc — the filter -// is pure client-side narrowing on data the page already has. -function initDetailModeToggle() { - const toggle = document.getElementById("verfahrensablauf-detail-toggle"); - if (!toggle) return; - toggle.querySelectorAll("input[name=detail-mode]").forEach((input) => { - input.checked = input.value === detailMode; - input.addEventListener("change", () => { - if (!input.checked) return; - const v = input.value; - if (v === "mandatory_only" || v === "selected" || v === "all_options") { - detailMode = v; - setDetailMode(detailMode); - if (lastResponse) renderResults(lastResponse); - } - }); - }); -} - -// initScenarioFlagsForProject loads the project's persisted scenario_flags -// (mig 154 SSoT). Called when the page is opened with ?project= and -// also when the project autofill resolves a project. Listens for the -// scenario-flag-changed CustomEvent so any peer surface that PATCHes the -// same project (Mode B Fristenrechner result-view) keeps this page in -// sync without polling. -async function hydrateScenarioFlags(projectID: string) { - projectIdForFlags = projectID; - const view = await fetchScenarioFlags(projectID); - if (!view) return; - scenarioFlags = view.flags; - // The named scenario flags (with_ccr / with_amend / with_cci) drive - // the existing flag checkboxes — re-syncing them here makes the page - // reflect the project's persisted state on first paint. - const setChecked = (id: string, val: boolean | undefined): void => { - const el = document.getElementById(id) as HTMLInputElement | null; - if (!el) return; - el.checked = val === true; - }; - setChecked("ccr-flag", scenarioFlags["with_ccr"]); - setChecked("inf-amend-flag", scenarioFlags["with_amend"]); - setChecked("rev-amend-flag", scenarioFlags["with_amend"]); - setChecked("rev-cci-flag", scenarioFlags["with_cci"]); - syncInfAmendEnabled(); - if (lastResponse) renderResults(lastResponse); -} - -// Subscribe to peer-surface scenario-flag changes once at module load. -// The listener is idempotent — we re-read the map and re-render. Skipped -// when projectIdForFlags hasn't been set yet (kontextfrei mode). -onScenarioFlagsChanged((detail) => { - if (!projectIdForFlags || detail.projectId !== projectIdForFlags) return; - scenarioFlags = detail.flags; - if (lastResponse) renderResults(lastResponse); -}); - -// persistNamedScenarioFlag writes a named flag (with_ccr / with_amend / -// with_cci) to the project's scenario_flags via PATCH. No-op in -// kontextfrei mode. The page-level checkbox owns the click; this helper -// just mirrors it into the SSoT so peer surfaces see the change. -function persistNamedScenarioFlag(key: string, value: boolean): void { - if (!projectIdForFlags) return; - void patchScenarioFlags(projectIdForFlags, { [key]: value }); - scenarioFlags = { ...scenarioFlags, [key]: value }; -} - -// onRuleSelectionToggle handles a click on a per-rule [Aufnehmen] or -// [Entfernen] chip (m/paliad#149 Phase 2 P3, design §2.4a). Translates -// the action into a scenario-flag delta: -// -// priority='recommended', aufnehmen=true → delete rule: (back to default-selected) -// priority='recommended', aufnehmen=false → write rule: = false (explicit deselect) -// priority='optional', aufnehmen=true → write rule: = true (explicit select) -// priority='optional', aufnehmen=false → delete rule: (back to default-unselected) -// -// In other words: the chip stores only DEVIATIONS from the priority -// default; flipping back to the default-state deletes the entry. Akte -// mode PATCHes via scenario-flags.ts; kontextfrei mode is no-op -// today (per-rule selections in kontextfrei mode are a future P3 -// extension via localStorage; the chip still hides itself once flipped -// because the page-level scenarioFlags map updates). -function onRuleSelectionToggle(ruleId: string, priority: string, aufnehmen: boolean): void { - const key = `rule:${ruleId}`; - let deltaValue: boolean | null; - if (priority === "recommended") { - deltaValue = aufnehmen ? null : false; - } else if (priority === "optional") { - deltaValue = aufnehmen ? true : null; - } else { - return; // mandatory / unknown — not toggleable - } - - // Update the local shadow first so the re-render below reflects the - // new state regardless of network latency. - const next = { ...scenarioFlags }; - if (deltaValue === null) { - delete next[key]; - } else { - next[key] = deltaValue; - } - scenarioFlags = next; - - // Persist to the project's SSoT when in akte mode. Fire-and-forget; - // a network failure leaves the local shadow ahead of the server, which - // the next hydrate or peer scenario-flag-changed event reconciles. - if (projectIdForFlags) { - void patchScenarioFlags(projectIdForFlags, { [key]: deltaValue }); - } - - if (lastResponse) renderResults(lastResponse); -} - -function initViewToggle() { - const toggle = document.getElementById("fristen-view-toggle"); - if (!toggle) return; - - const initial = new URLSearchParams(window.location.search).get("view"); - if (initial === "timeline") procedureView = "timeline"; - applyVerfahrensablaufViewBodyClass(procedureView); - - toggle.querySelectorAll("input[name=fristen-view]").forEach((input) => { - input.checked = input.value === procedureView; - input.addEventListener("change", () => { - if (!input.checked) return; - procedureView = input.value === "columns" ? "columns" : "timeline"; - applyVerfahrensablaufViewBodyClass(procedureView); - const url = new URL(window.location.href); - if (procedureView === "columns") { - url.searchParams.delete("view"); - } else { - url.searchParams.set("view", procedureView); - } - history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash); - if (lastResponse) renderResults(lastResponse); - }); - }); - - toggle.style.display = "none"; -} - -// initPerspectiveControls hydrates side+appellant from the URL, -// reflects state into the radio inputs, and wires onchange handlers -// that update state + URL + re-render. Re-render path skips the -// /api/tools/fristenrechner round-trip — perspective is a pure -// projection of the last response, no backend involved. -function initPerspectiveControls() { - currentSide = parseSideFromSearch(window.location.search); - currentAppealTarget = parseAppealTargetFromSearch(window.location.search); - syncRadioGroup("side", currentSide ?? ""); - syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung"); - syncSideHintVisibility(); - - document.querySelectorAll("input[type=radio][name=side]").forEach((input) => { - input.addEventListener("change", () => { - if (!input.checked) return; - const v = input.value; - currentSide = (v === "claimant" || v === "defendant") ? v : null; - applyURLFilters({ side: currentSide }); - syncSideHintVisibility(); - if (lastResponse) renderResults(lastResponse); - }); - }); - - // Slice B1 (m/paliad#124 §18.1) — appeal-target chip handler. - // Each chip change re-fetches with the new target slug so the - // timeline re-renders against the matching rule subset. - document.querySelectorAll("input[type=radio][name=appeal-target]").forEach((input) => { - input.addEventListener("change", () => { - if (!input.checked) return; - const v = input.value; - if ((APPEAL_TARGETS as readonly string[]).includes(v)) { - currentAppealTarget = v as AppealTarget; - } else { - currentAppealTarget = ""; - } - applyURLFilters({ target: currentAppealTarget }); - scheduleCalc(0); - }); - }); -} - -// initVerfahrensablauf wires the entire Verfahrensablauf wizard against -// whatever DOM is currently present (proceeding-btn buttons, -// trigger-date input, flag checkboxes, timeline-container, …). -// Re-callable on demand: m/paliad#151 mounts this against the -// /tools/procedures "Verfahren wählen" tab the first time it activates. -// initI18n() + initSidebar() are NOT included here — both are page-boot -// concerns owned by whichever entrypoint hosts the wiring. -export function initVerfahrensablauf(): void { - document.querySelectorAll(".proceeding-btn").forEach((btn) => { - btn.addEventListener("click", () => selectProceeding(btn)); - }); - - document.getElementById("proceeding-summary-reselect")?.addEventListener("click", () => { - setProceedingPickerCollapsed(false); - }); - - document.getElementById("calculate-btn")?.addEventListener("click", () => scheduleCalc(0)); - - const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null; - if (dateInput) { - // Hydrate trigger_date from URL on first paint so a refresh / - // shared link reproduces the same dated timeline. URL wins over - // the verfahrensablauf.tsx today-default that the renders - // with. parseTriggerDateFromSearch validates the shape so a - // malformed link silently falls back to the today-default. - const urlDate = parseTriggerDateFromSearch(window.location.search); - if (urlDate) dateInput.value = urlDate; - const persistDate = () => { - applyURLFilters({ triggerDate: dateInput.value }); - }; - dateInput.addEventListener("change", () => { persistDate(); scheduleCalc(); }); - dateInput.addEventListener("input", () => { persistDate(); scheduleCalc(); }); - dateInput.addEventListener("keydown", (e) => { - if ((e as KeyboardEvent).key === "Enter") { persistDate(); scheduleCalc(0); } - }); - } - - const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null; - if (courtPicker) courtPicker.addEventListener("change", () => { - writeCourtId(scenarioStorage, courtPicker.value); - scheduleCalc(0); - }); - - // Flag-checkbox listeners — each flip triggers a fresh calc so the - // timeline re-projects with the new gating. ccr-flag additionally - // enables/disables the nested inf-amend row. Each flip also writes - // through to localStorage so the choice survives a reload (URL stays - // clean; flags are scenario state, not filter chips — t-paliad-308). - const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null; - if (ccrFlag) ccrFlag.addEventListener("change", () => { - writeBoolFlag(scenarioStorage, SCENARIO_KEYS.ccr, ccrFlag.checked); - syncInfAmendEnabled(); - // Disabling ccr also unchecks inf-amend (see syncInfAmendEnabled). - // Mirror that into storage so the next reload doesn't repopulate a - // disabled checkbox as checked. - const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null; - if (infAmend) writeBoolFlag(scenarioStorage, SCENARIO_KEYS.infAmend, infAmend.checked); - // m/paliad#149 Phase 2 P3 — mirror into the project's scenario_flags - // SSoT when in akte mode. PATCH is fire-and-forget; failure just - // means the local UI keeps its optimistic state and the next - // hydrate reconciles. - persistNamedScenarioFlag("with_ccr", ccrFlag.checked); - if (infAmend) persistNamedScenarioFlag("with_amend", infAmend.checked); - scheduleCalc(0); - }); - const flagStorageKeys: Record = { - "inf-amend-flag": { storageKey: SCENARIO_KEYS.infAmend, flagKey: "with_amend" }, - "rev-amend-flag": { storageKey: SCENARIO_KEYS.revAmend, flagKey: "with_amend" }, - "rev-cci-flag": { storageKey: SCENARIO_KEYS.revCci, flagKey: "with_cci" }, - }; - for (const [id, { storageKey, flagKey }] of Object.entries(flagStorageKeys)) { - const cb = document.getElementById(id) as HTMLInputElement | null; - if (cb) cb.addEventListener("change", () => { - writeBoolFlag(scenarioStorage, storageKey, cb.checked); - persistNamedScenarioFlag(flagKey, cb.checked); - scheduleCalc(0); - }); - } - - document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print()); - - // Click-to-edit on timeline / column date cells — same delegated - // pattern as /tools/fristenrechner. Survives renderResults()'s - // innerHTML rewrites because the listener lives on the container. - const timelineContainer = document.getElementById("timeline-container"); - if (timelineContainer) { - wireDateEditClicks(timelineContainer, (ruleCode, newValue) => { - if (newValue === "") { - anchorOverrides.delete(ruleCode); - } else { - anchorOverrides.set(ruleCode, newValue); - } - scheduleCalc(0); - }); - // m/paliad#149 Phase 2 P3 — delegated handler for the per-rule - // [Aufnehmen] / [Entfernen] chips. The chip carries data-rule-id + - // data-action; the click flips the scenario-flag entry for that - // rule and re-renders. - timelineContainer.addEventListener("click", (e) => { - const target = e.target as HTMLElement | null; - const btn = target?.closest(".timeline-selection-chip"); - if (!btn) return; - e.preventDefault(); - const ruleId = btn.dataset.ruleId; - const priority = btn.dataset.priority; - const action = btn.dataset.action; - if (!ruleId || !priority || !action) return; - onRuleSelectionToggle(ruleId, priority, action === "aufnehmen"); - }); - } - - // Notes toggle — restores last preference on load + re-renders when - // the user flips it. Lives in the same toggle bar as the view picker. - const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null; - if (notesShowCb) { - notesShowCb.checked = showNotes; - notesShowCb.addEventListener("change", () => { - showNotes = notesShowCb.checked; - writeNotesPref(showNotes); - if (lastResponse) renderResults(lastResponse); - }); - } - - // Durations toggle (m/paliad#133, t-paliad-302) — sibling of the - // notes toggle. Hover-only labels (default) become inline labels when - // the user opts in. - const durationsShowCb = document.getElementById("verfahrensablauf-durations-show") as HTMLInputElement | null; - if (durationsShowCb) { - durationsShowCb.checked = showDurations; - durationsShowCb.addEventListener("change", () => { - showDurations = durationsShowCb.checked; - writeDurationsPref(showDurations); - if (lastResponse) renderResults(lastResponse); - }); - } - - // t-paliad-290 — show-hidden toggle. Hydrated from localStorage at - // module load (showHidden); each flip writes back to localStorage - // and triggers a recalc (the backend reshapes the response — we - // can't just re-render lastResponse since the hidden rows aren't - // in it when the toggle was OFF). - const showHiddenCb = document.getElementById("show-hidden-toggle") as HTMLInputElement | null; - if (showHiddenCb) { - showHiddenCb.checked = showHidden; - showHiddenCb.addEventListener("change", () => { - showHidden = showHiddenCb.checked; - writeBoolFlag(scenarioStorage, SCENARIO_KEYS.showHidden, showHidden); - scheduleCalc(0); - }); - } - - initViewToggle(); - initDetailModeToggle(); - initPerspectiveControls(); - - // m/paliad#149 Phase 2 P3 — hydrate scenario_flags from the project's - // SSoT (mig 154) when the page is opened with ?project=. Initial - // fetch sets the page-level flag checkboxes; subsequent peer-surface - // changes arrive via the scenario-flag-changed CustomEvent (subscribed - // at module load above). Kontextfrei (no ?project=) skips the fetch — - // localStorage-only behaviour stays as it was. - const projectQuery = new URLSearchParams(window.location.search).get("project"); - if (projectQuery && /^[0-9a-fA-F-]{36}$/.test(projectQuery)) { - void hydrateScenarioFlags(projectQuery); - } - - // t-paliad-265 — per-event-card choices. Unbound surface; persistence - // is localStorage-only (t-paliad-308) so a shared link doesn't carry - // the recipient's per-card tweaks. The popover module owns the - // popover lifecycle; this page owns the recalc + storage plumbing. - const timelineEl = document.getElementById("timeline-container"); - if (timelineEl) { - attachEventCardChoices({ - container: timelineEl, - initial: perCardChoices, - commit: (choice) => { - perCardChoices = perCardChoices.filter( - (c) => !(c.submission_code === choice.submission_code && c.choice_kind === choice.choice_kind), - ); - perCardChoices.push(choice); - writeEventChoices(scenarioStorage, perCardChoices); - scheduleCalc(0); - }, - remove: (submissionCode, kind) => { - perCardChoices = perCardChoices.filter( - (c) => !(c.submission_code === submissionCode && c.choice_kind === kind), - ); - writeEventChoices(scenarioStorage, perCardChoices); - scheduleCalc(0); - }, - }); - } - - // t-paliad-279 — override link on the prefilled side chip — swaps back - // to the radio cluster and clears ?project= from the URL. - document.getElementById("side-chip-override")?.addEventListener("click", clearSidePrefill); - - // Project autofill — runs after the radio cluster has its URL-driven - // state so we never clobber an explicit ?side= pick. Fire-and-forget; - // the chip swap happens once the project resolves. - void initProjectAutofill(); - - - onLangChange(() => { - // Active-button name updates with language change (the data-i18n - // pass swaps the inner 's text). Re-collapse the summary - // chip and re-derive the trigger event label from the lang-current - // calc response. - const activeBtn = activeProceedingButton(); - if (activeBtn) { - const summary = document.getElementById("proceeding-summary-name"); - if (summary) summary.textContent = proceedingDisplayName(activeBtn); - } - // Side-chip label tracks language so a DE/EN flip while the chip is - // visible re-renders the inferred side in the active language. - if (sidePrefilledFromProject) { - const value = document.getElementById("side-chip-value"); - if (value) value.textContent = sideLabelI18n(currentSide); - } - if (lastResponse) renderResults(lastResponse); - syncTriggerEventLabel(); - }); - - // Pre-select the proceeding tile. URL wins: if ?proceeding= is set - // and points at a known tile, that tile is selected without rewriting - // the URL. Otherwise fall back to the first tile so users see a - // timeline immediately on landing — matches /tools/fristenrechner - // behaviour. The auto-pick does NOT write the URL so the default - // landing stays clean (`?proceeding=` only appears once the user - // makes an explicit choice). (t-paliad-308 / m/paliad#137) - const urlProceeding = parseProceedingFromSearch(window.location.search); - let initialBtn: HTMLButtonElement | null = null; - let urlHit = false; - if (urlProceeding) { - initialBtn = document.querySelector( - `.proceeding-btn[data-code="${urlProceeding.replace(/"/g, '\\"')}"]`, - ); - urlHit = initialBtn !== null; - } - if (!initialBtn) { - initialBtn = document.querySelector(".proceeding-btn"); - } - if (initialBtn) { - // writeURL=false when the URL either already carries this code - // (no churn) or has no proceeding (auto-default → don't pollute - // the clean URL). Only an unknown / stale ?proceeding= triggers - // a rewrite so the URL converges on the resolved tile. - const writeURL = urlProceeding !== "" && !urlHit; - selectProceeding(initialBtn, { writeURL }); - } -} - diff --git a/frontend/src/client/views/event-card-choices.ts b/frontend/src/client/views/event-card-choices.ts deleted file mode 100644 index d92e061..0000000 --- a/frontend/src/client/views/event-card-choices.ts +++ /dev/null @@ -1,320 +0,0 @@ -// Per-event-card choice popover + chip indicator (t-paliad-265 / -// m/paliad#96). -// -// The shared rendering core (verfahrensablauf-core.ts) emits a caret -// button on cards that carry a non-empty `choices_offered` declaration -// and an inert chip span next to the title. This module: -// -// 1. Wires a delegated click handler on the result container so the -// caret opens a popover with the offered choice-kinds. -// 2. Commits the user's pick — either by POSTing to the project- -// bound endpoint or by mutating the in-memory state for the -// unbound (no-project) case. -// 3. Rehydrates the chip on every render + after every commit so the -// glanceable indicator matches the active state. -// -// Two consumer pages — /tools/verfahrensablauf (unbound) and -// /tools/fristenrechner (project-bound) — both wire this module -// once at boot via attachEventCardChoices(). - -import { escAttr, escHtml } from "./verfahrensablauf-core"; -import { t } from "../i18n"; - -export type ChoiceKind = "appellant" | "include_ccr" | "skip"; - -export interface EventChoice { - submission_code: string; - choice_kind: ChoiceKind; - choice_value: string; -} - -// State surface — the page passes in callbacks that own persistence. -// commit / remove must trigger a recalc on the page side (the popover -// only owns its own visual state). -export interface EventCardChoicesOpts { - container: HTMLElement; - // Initial state: a list of choices. The page seeds this from the - // server response (project-bound) or from URL params (unbound). - initial: EventChoice[]; - // commit gets called for an UPSERT. The page POSTs to the API (or - // mutates URL state) AND triggers a recalc. - commit: (choice: EventChoice) => Promise | void; - // remove gets called when the user resets a choice. - remove: (submissionCode: string, kind: ChoiceKind) => Promise | void; -} - -// One mutable bag per attach() call. The current implementation is a -// single-page singleton — paginated views (admin tables) are not in -// scope. Last-write-wins on the in-memory state. -interface AttachedState { - opts: EventCardChoicesOpts; - // active: submission_code → kind → value. Rebuilt from `initial` - // on every reseed() call. - active: Map>; - popover: HTMLDivElement | null; -} - -const states = new WeakMap(); - -// attachEventCardChoices wires the delegated click + popover lifecycle -// to the given container. Call once per page after mount; safe to call -// again with a fresh container. -export function attachEventCardChoices(opts: EventCardChoicesOpts): void { - const state: AttachedState = { - opts, - active: new Map(), - popover: null, - }; - for (const c of opts.initial) { - if (!state.active.has(c.submission_code)) { - state.active.set(c.submission_code, new Map()); - } - state.active.get(c.submission_code)!.set(c.choice_kind, c.choice_value); - } - states.set(opts.container, state); - - opts.container.addEventListener("click", (e) => { - const targetEl = e.target as HTMLElement | null; - const caret = targetEl?.closest(".event-card-choices-caret"); - if (caret) { - e.stopPropagation(); - openPopover(state, caret); - return; - } - // Outside-click closes the popover. - if (state.popover && !state.popover.contains(e.target as Node)) { - closePopover(state); - } - }); - - // ESC also closes. - document.addEventListener("keydown", (e) => { - if (e.key === "Escape" && state.popover) { - closePopover(state); - } - }); - - // Repaint chips on every renderResults() call. The page is - // responsible for calling reseedChips() after re-render so the chip - // dom node (re-created by the renderer) picks the active state up. - reseedChips(opts.container); -} - -// reseedChips walks every chip span in the container and re-renders -// its content from the active state map. Idempotent. -export function reseedChips(container: HTMLElement): void { - const state = states.get(container); - if (!state) return; - container.querySelectorAll(".event-card-choices-chip").forEach((chip) => { - const code = chip.dataset.submissionCode || ""; - const kinds = state.active.get(code); - if (!kinds || kinds.size === 0) { - chip.innerHTML = ""; - chip.dataset.empty = "true"; - return; - } - chip.dataset.empty = "false"; - chip.innerHTML = renderChip(kinds); - }); - // Skipped rows fade out via a class on the card-item ancestor. - container.querySelectorAll(".event-card-choices-chip").forEach((chip) => { - const code = chip.dataset.submissionCode || ""; - const skipped = state.active.get(code)?.get("skip") === "true"; - const itemEl = chip.closest(".timeline-item, .fr-col-item"); - if (itemEl) itemEl.classList.toggle("timeline-item--skipped", skipped); - }); -} - -function renderChip(kinds: Map): string { - const parts: string[] = []; - if (kinds.get("skip") === "true") { - parts.push(`${escHtml(t("choices.skipped.chip"))}`); - } - const ap = kinds.get("appellant"); - if (ap && ap !== "" ) { - let label = ""; - switch (ap) { - case "claimant": label = t("choices.appellant.claimant"); break; - case "defendant": label = t("choices.appellant.defendant"); break; - case "both": label = t("choices.appellant.both"); break; - case "none": label = t("choices.appellant.none"); break; - } - if (label) { - parts.push(`${escHtml(t("choices.appellant.chip"))} ${escHtml(label)}`); - } - } - if (kinds.get("include_ccr") === "true") { - parts.push(`${escHtml(t("choices.include_ccr.chip"))}`); - } - return parts.join(" "); -} - -function openPopover(state: AttachedState, caret: HTMLElement): void { - closePopover(state); - const code = caret.dataset.submissionCode || ""; - if (!code) return; - let offered: Record = {}; - try { - offered = JSON.parse(caret.dataset.choicesOffered || "{}"); - } catch { - return; - } - const isHidden = caret.dataset.isHidden === "1"; - - const pop = document.createElement("div"); - pop.className = "event-card-choices-popover"; - pop.setAttribute("role", "dialog"); - pop.setAttribute("aria-label", t("choices.caret.title")); - - const blocks: string[] = []; - // t-paliad-293: hidden-card prominence. When the user opens the - // popover on a re-surfaced hidden card, "Wieder einblenden" is the - // most likely intent — surface it as a single high-contrast action - // at the top of the popover (rather than burying it under the skip - // toggle's reset link). Clicking it clears the `skip` choice, which - // is the same wire effect as the legacy inline chip from t-paliad-290. - if (isHidden) { - blocks.push(renderUnhideBlock()); - } - if (Array.isArray(offered.appellant)) { - blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[])); - } - if (Array.isArray(offered.include_ccr)) { - blocks.push(renderToggleBlock(state, code, "include_ccr")); - } - if (Array.isArray(offered.skip)) { - blocks.push(renderToggleBlock(state, code, "skip")); - } - pop.innerHTML = blocks.join(""); - - document.body.appendChild(pop); - state.popover = pop; - positionPopover(pop, caret); - - pop.addEventListener("click", async (e) => { - const btn = (e.target as HTMLElement | null)?.closest("button[data-choice-action]"); - if (!btn) return; - e.stopPropagation(); - const kind = btn.dataset.choiceKind as ChoiceKind | undefined; - const value = btn.dataset.choiceValue || ""; - const action = btn.dataset.choiceAction; - if (!kind) return; - try { - if (action === "set") { - await state.opts.commit({ submission_code: code, choice_kind: kind, choice_value: value }); - if (!state.active.has(code)) state.active.set(code, new Map()); - state.active.get(code)!.set(kind, value); - } else if (action === "clear") { - await state.opts.remove(code, kind); - state.active.get(code)?.delete(kind); - } - reseedChips(state.opts.container); - closePopover(state); - } catch (err) { - console.error("event card choice commit failed", err); - // Surface a soft inline error inside the popover; do NOT close. - const errEl = document.createElement("div"); - errEl.className = "event-card-choices-error"; - errEl.textContent = t("choices.commit.error"); - pop.appendChild(errEl); - } - }); -} - -function renderAppellantBlock(state: AttachedState, code: string, values: unknown[]): string { - const current = state.active.get(code)?.get("appellant") || ""; - const buttons = values - .filter((v): v is string => typeof v === "string") - .map((v) => { - const labelKey = `choices.appellant.${v}` as const; - const isActive = v === current; - return ``; - }) - .join(""); - const reset = current - ? `` - : ""; - return `
    -
    ${escHtml(t("choices.appellant.title"))}
    -
    ${buttons}
    - ${reset} -
    `; -} - -function renderToggleBlock(state: AttachedState, code: string, kind: "include_ccr" | "skip"): string { - const current = state.active.get(code)?.get(kind) || "false"; - const titleKey = kind === "include_ccr" ? "choices.include_ccr.title" : "choices.skip.title"; - const trueKey = kind === "include_ccr" ? "choices.include_ccr.true" : "choices.skip.true"; - const falseKey = kind === "include_ccr" ? "choices.include_ccr.false" : "choices.skip.false"; - const opt = (v: "true" | "false", labelKey: string) => ``; - const reset = state.active.get(code)?.has(kind) - ? `` - : ""; - return `
    -
    ${escHtml(t(titleKey as any))}
    -
    - ${opt("true", trueKey)} - ${opt("false", falseKey)} -
    - ${reset} -
    `; -} - -// renderUnhideBlock is the popover's prominent "Wieder einblenden" -// action — surfaced only when the caret is opened on a re-surfaced -// hidden card (data-is-hidden="1" on the caret). Clicking it dispatches -// the same `clear` action as the skip-block reset link below, but -// labelled in the user's terms ("restore this card" rather than -// "reset skip choice"). Drops out of the popover automatically on -// non-hidden cards so the popover stays minimal. (t-paliad-293) -function renderUnhideBlock(): string { - const label = t("choices.unhide.chip"); - return `
    - -
    `; -} - -function closePopover(state: AttachedState): void { - if (state.popover) { - state.popover.remove(); - state.popover = null; - } -} - -function positionPopover(pop: HTMLDivElement, caret: HTMLElement): void { - const rect = caret.getBoundingClientRect(); - const scrollY = window.scrollY || document.documentElement.scrollTop; - const scrollX = window.scrollX || document.documentElement.scrollLeft; - pop.style.position = "absolute"; - pop.style.top = `${rect.bottom + scrollY + 4}px`; - pop.style.left = `${Math.max(8, rect.right + scrollX - 240)}px`; - pop.style.zIndex = "1000"; -} - -// Returns the current in-memory choice list for the given container — -// used by the unbound /tools/verfahrensablauf page to keep the URL -// param in sync. -export function currentChoices(container: HTMLElement): EventChoice[] { - const state = states.get(container); - if (!state) return []; - const out: EventChoice[] = []; - state.active.forEach((kinds, code) => { - kinds.forEach((value, kind) => { - out.push({ submission_code: code, choice_kind: kind, choice_value: value }); - }); - }); - return out; -} diff --git a/frontend/src/client/views/verfahrensablauf-state.test.ts b/frontend/src/client/views/verfahrensablauf-state.test.ts deleted file mode 100644 index d91f93d..0000000 --- a/frontend/src/client/views/verfahrensablauf-state.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -// Unit tests for the /tools/verfahrensablauf URL + scenario-localStorage -// state contract (t-paliad-308 / m/paliad#137). Run with `bun test`. -// -// The contract: -// 1. URL params (proceeding, side, target, trigger_date) define which -// timeline kind the user is looking at — paste-able, shareable, -// refresh-resistant. -// 2. localStorage (paliad.verfahrensablauf.scenario.*) holds the -// per-user scenario tweaks (event_choices, court_id, flags, -// show_hidden) — these never leak into a shared link. -// 3. On hydrate, URL wins. localStorage fills the rest. - -import { describe, expect, test } from "bun:test"; -import { - APPEAL_TARGETS, - SCENARIO_KEYS, - SCENARIO_PREFIX, - URL_KEYS, - applyFiltersToSearch, - hydrate, - makeMemoryStorage, - parseAppealTargetFromSearch, - parseProceedingFromSearch, - parseSideFromSearch, - parseTriggerDateFromSearch, - readBoolFlag, - readCourtId, - readEventChoices, - readScenario, - writeBoolFlag, - writeCourtId, - writeEventChoices, -} from "./verfahrensablauf-state"; - -describe("URL parsers — filter chips", () => { - test("parseProceedingFromSearch returns empty string when absent", () => { - expect(parseProceedingFromSearch("")).toBe(""); - expect(parseProceedingFromSearch("?side=claimant")).toBe(""); - }); - - test("parseProceedingFromSearch echoes the raw value", () => { - expect(parseProceedingFromSearch("?proceeding=upc.inf.cfi")).toBe("upc.inf.cfi"); - expect(parseProceedingFromSearch("?proceeding=upc.apl.unified&side=claimant")).toBe("upc.apl.unified"); - }); - - test("parseSideFromSearch validates the enum", () => { - expect(parseSideFromSearch("?side=claimant")).toBe("claimant"); - expect(parseSideFromSearch("?side=defendant")).toBe("defendant"); - expect(parseSideFromSearch("?side=neither")).toBe(null); - expect(parseSideFromSearch("")).toBe(null); - }); - - test("parseAppealTargetFromSearch only accepts canonical slugs", () => { - for (const t of APPEAL_TARGETS) { - expect(parseAppealTargetFromSearch(`?target=${t}`)).toBe(t); - } - expect(parseAppealTargetFromSearch("?target=unknown")).toBe(""); - expect(parseAppealTargetFromSearch("")).toBe(""); - }); - - test("parseTriggerDateFromSearch validates the ISO-date shape", () => { - expect(parseTriggerDateFromSearch("?trigger_date=2026-05-26")).toBe("2026-05-26"); - expect(parseTriggerDateFromSearch("?trigger_date=2024-02-29")).toBe("2024-02-29"); // leap year - }); - - test("parseTriggerDateFromSearch rejects malformed and impossible dates", () => { - expect(parseTriggerDateFromSearch("?trigger_date=2026-02-30")).toBe(""); // Feb 30 - expect(parseTriggerDateFromSearch("?trigger_date=2026-13-01")).toBe(""); // month 13 - expect(parseTriggerDateFromSearch("?trigger_date=tomorrow")).toBe(""); - expect(parseTriggerDateFromSearch("?trigger_date=2026-5-26")).toBe(""); // 1-digit month - expect(parseTriggerDateFromSearch("")).toBe(""); - }); -}); - -describe("URL encoder — applyFiltersToSearch", () => { - test("empty filters preserve the existing query string", () => { - expect(applyFiltersToSearch("?other=keep", {})).toBe("?other=keep"); - }); - - test("setting a filter writes the canonical key", () => { - expect(applyFiltersToSearch("", { proceeding: "upc.inf.cfi" })).toBe("?proceeding=upc.inf.cfi"); - expect(applyFiltersToSearch("", { side: "claimant" })).toBe("?side=claimant"); - expect(applyFiltersToSearch("", { target: "endentscheidung" })).toBe("?target=endentscheidung"); - expect(applyFiltersToSearch("", { triggerDate: "2026-05-26" })).toBe("?trigger_date=2026-05-26"); - }); - - test("setting null / empty / undefined deletes the key", () => { - expect(applyFiltersToSearch("?side=claimant", { side: null })).toBe(""); - expect(applyFiltersToSearch("?proceeding=upc.inf.cfi", { proceeding: "" })).toBe(""); - expect(applyFiltersToSearch("?target=endentscheidung", { target: "" })).toBe(""); - expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "" })).toBe(""); - }); - - test("invalid trigger_date is deleted (never written as-is)", () => { - expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "bogus" })).toBe(""); - }); - - test("setting all four filters together emits all four keys", () => { - const out = applyFiltersToSearch("", { - proceeding: "upc.apl.unified", - side: "defendant", - target: "endentscheidung", - triggerDate: "2026-05-26", - }); - expect(out).toContain("proceeding=upc.apl.unified"); - expect(out).toContain("side=defendant"); - expect(out).toContain("target=endentscheidung"); - expect(out).toContain("trigger_date=2026-05-26"); - }); - - test("other params (project, view) are preserved", () => { - const out = applyFiltersToSearch("?project=abc&view=timeline", { side: "claimant" }); - expect(out).toContain("project=abc"); - expect(out).toContain("view=timeline"); - expect(out).toContain("side=claimant"); - }); - - test("absent keys in the filter object don't touch existing URL values", () => { - // Only updating side — proceeding should be untouched. - const out = applyFiltersToSearch("?proceeding=upc.inf.cfi&side=defendant", { side: "claimant" }); - expect(out).toContain("proceeding=upc.inf.cfi"); - expect(out).toContain("side=claimant"); - }); -}); - -describe("URL round-trip — encode then parse yields the same value", () => { - test("proceeding", () => { - const enc = applyFiltersToSearch("", { proceeding: "upc.inf.cfi" }); - expect(parseProceedingFromSearch(enc)).toBe("upc.inf.cfi"); - }); - - test("side", () => { - const enc = applyFiltersToSearch("", { side: "defendant" }); - expect(parseSideFromSearch(enc)).toBe("defendant"); - }); - - test("target", () => { - const enc = applyFiltersToSearch("", { target: "kostenentscheidung" }); - expect(parseAppealTargetFromSearch(enc)).toBe("kostenentscheidung"); - }); - - test("trigger_date", () => { - const enc = applyFiltersToSearch("", { triggerDate: "2026-05-26" }); - expect(parseTriggerDateFromSearch(enc)).toBe("2026-05-26"); - }); -}); - -describe("Scenario localStorage helpers", () => { - test("SCENARIO_PREFIX is paliad.verfahrensablauf.scenario and all keys live under it", () => { - expect(SCENARIO_PREFIX).toBe("paliad.verfahrensablauf.scenario"); - for (const key of Object.values(SCENARIO_KEYS)) { - expect(key.startsWith(SCENARIO_PREFIX + ".")).toBe(true); - } - }); - - test("readEventChoices returns [] on empty storage", () => { - const s = makeMemoryStorage(); - expect(readEventChoices(s)).toEqual([]); - }); - - test("writeEventChoices + readEventChoices round-trip", () => { - const s = makeMemoryStorage(); - const choices = [ - { submission_code: "upc.inf.cfi.r12", choice_kind: "appellant" as const, choice_value: "claimant" }, - { submission_code: "upc.inf.cfi.r30", choice_kind: "include_ccr" as const, choice_value: "1" }, - ]; - writeEventChoices(s, choices); - expect(readEventChoices(s)).toEqual(choices); - }); - - test("writeEventChoices([]) clears the key (removeItem semantic, not empty string)", () => { - const s = makeMemoryStorage(); - writeEventChoices(s, [{ submission_code: "r1", choice_kind: "skip", choice_value: "1" }]); - expect(s.getItem(SCENARIO_KEYS.eventChoices)).not.toBe(null); - writeEventChoices(s, []); - expect(s.getItem(SCENARIO_KEYS.eventChoices)).toBe(null); - }); - - test("readEventChoices ignores unknown choice_kind values", () => { - const s = makeMemoryStorage(); - s.setItem(SCENARIO_KEYS.eventChoices, "r1:appellant=claimant,r2:bogus=x,r3:skip=1"); - expect(readEventChoices(s)).toEqual([ - { submission_code: "r1", choice_kind: "appellant", choice_value: "claimant" }, - { submission_code: "r3", choice_kind: "skip", choice_value: "1" }, - ]); - }); - - test("readCourtId returns '' on empty storage, echoes stored value otherwise", () => { - const s = makeMemoryStorage(); - expect(readCourtId(s)).toBe(""); - writeCourtId(s, "UPC-LD-MUC"); - expect(readCourtId(s)).toBe("UPC-LD-MUC"); - }); - - test("writeCourtId('') removes the key", () => { - const s = makeMemoryStorage(); - writeCourtId(s, "UPC-LD-MUC"); - expect(s.getItem(SCENARIO_KEYS.courtId)).toBe("UPC-LD-MUC"); - writeCourtId(s, ""); - expect(s.getItem(SCENARIO_KEYS.courtId)).toBe(null); - }); - - test("readBoolFlag / writeBoolFlag round-trip with removeItem on false", () => { - const s = makeMemoryStorage(); - expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false); - writeBoolFlag(s, SCENARIO_KEYS.ccr, true); - expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(true); - expect(s.getItem(SCENARIO_KEYS.ccr)).toBe("1"); - writeBoolFlag(s, SCENARIO_KEYS.ccr, false); - expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false); - expect(s.getItem(SCENARIO_KEYS.ccr)).toBe(null); - }); - - test("readScenario returns all fields defaulted on empty storage", () => { - const s = makeMemoryStorage(); - expect(readScenario(s)).toEqual({ - eventChoices: [], - courtId: "", - ccr: false, - infAmend: false, - revAmend: false, - revCci: false, - showHidden: false, - }); - }); -}); - -describe("Hydration order — URL wins, localStorage fills the rest", () => { - test("URL fills filter chips, localStorage fills scenario state", () => { - const s = makeMemoryStorage(); - writeCourtId(s, "UPC-LD-MUC"); - writeBoolFlag(s, SCENARIO_KEYS.showHidden, true); - writeBoolFlag(s, SCENARIO_KEYS.ccr, true); - const out = hydrate( - "?proceeding=upc.inf.cfi&side=defendant&target=endentscheidung&trigger_date=2026-05-26", - s, - ); - // URL-sourced - expect(out.proceeding).toBe("upc.inf.cfi"); - expect(out.side).toBe("defendant"); - expect(out.target).toBe("endentscheidung"); - expect(out.triggerDate).toBe("2026-05-26"); - // localStorage-sourced - expect(out.courtId).toBe("UPC-LD-MUC"); - expect(out.showHidden).toBe(true); - expect(out.ccr).toBe(true); - }); - - test("absent URL → all filter fields are empty/null, localStorage still hydrates scenario", () => { - const s = makeMemoryStorage(); - writeCourtId(s, "UPC-LD-MUC"); - const out = hydrate("", s); - expect(out.proceeding).toBe(""); - expect(out.side).toBe(null); - expect(out.target).toBe(""); - expect(out.triggerDate).toBe(""); - expect(out.courtId).toBe("UPC-LD-MUC"); - }); - - test("absent localStorage → URL still fills filter chips, scenario defaults", () => { - const s = makeMemoryStorage(); - const out = hydrate( - "?proceeding=upc.apl.unified&side=claimant&target=anordnung&trigger_date=2026-07-01", - s, - ); - expect(out.proceeding).toBe("upc.apl.unified"); - expect(out.side).toBe("claimant"); - expect(out.target).toBe("anordnung"); - expect(out.triggerDate).toBe("2026-07-01"); - expect(out.courtId).toBe(""); - expect(out.eventChoices).toEqual([]); - expect(out.showHidden).toBe(false); - }); - - test("a shared link doesn't leak the recipient's scenario state in", () => { - // Two storages: m's (loaded with court + flags) and a recipient's - // (empty). The same URL should reproduce filter chips identically - // but leave each user's scenario state untouched. - const mStorage = makeMemoryStorage(); - writeCourtId(mStorage, "UPC-LD-MUC"); - writeBoolFlag(mStorage, SCENARIO_KEYS.ccr, true); - const recipientStorage = makeMemoryStorage(); - - const sharedURL = "?proceeding=upc.inf.cfi&side=defendant&trigger_date=2026-05-26"; - - const mView = hydrate(sharedURL, mStorage); - const recipientView = hydrate(sharedURL, recipientStorage); - - // Filter chips identical - expect(mView.proceeding).toBe(recipientView.proceeding); - expect(mView.side).toBe(recipientView.side); - expect(mView.triggerDate).toBe(recipientView.triggerDate); - - // Scenario state diverges — recipient sees defaults - expect(mView.courtId).toBe("UPC-LD-MUC"); - expect(recipientView.courtId).toBe(""); - expect(mView.ccr).toBe(true); - expect(recipientView.ccr).toBe(false); - }); -}); - -describe("URL key constants match the documented contract", () => { - test("URL_KEYS uses the spec'd snake_case names", () => { - expect(URL_KEYS.proceeding).toBe("proceeding"); - expect(URL_KEYS.side).toBe("side"); - expect(URL_KEYS.target).toBe("target"); - expect(URL_KEYS.triggerDate).toBe("trigger_date"); - }); -}); diff --git a/frontend/src/client/views/verfahrensablauf-state.ts b/frontend/src/client/views/verfahrensablauf-state.ts deleted file mode 100644 index b5d40e0..0000000 --- a/frontend/src/client/views/verfahrensablauf-state.ts +++ /dev/null @@ -1,263 +0,0 @@ -// /tools/verfahrensablauf URL + scenario-localStorage state contract -// (t-paliad-308 / m/paliad#137). Splits the page's persisted state into -// two namespaces: -// -// URL params (filter chips — the timeline kind the user is looking -// at; paste-able, shareable, refresh-resistant): -// proceeding, side, target, trigger_date -// -// localStorage `paliad.verfahrensablauf.scenario.*` (per-user -// scenario inputs — the noisy parts that don't belong in a URL): -// event_choices, court_id, ccr, inf_amend, rev_amend, rev_cci, -// show_hidden -// -// Hydration order: URL wins. On page load, URL fills the filter chips; -// localStorage fills the rest. Filter-chip changes write to URL only. -// Scenario changes write to localStorage only. A shared link from a -// colleague reproduces the timeline kind (proceeding + side + target + -// trigger_date) but never leaks the recipient's court / flag / -// event_choices state in. -// -// All helpers in this module are pure: they take a search string (or a -// StorageLike) and return values, no DOM. The wiring in -// ../verfahrensablauf.ts mounts them onto window.location + -// window.localStorage at runtime. - -import type { EventChoice, ChoiceKind } from "./event-card-choices"; - -// ----- URL params (filter chips) ---------------------------------- - -export type Side = "claimant" | "defendant" | null; - -export const APPEAL_TARGETS = [ - "endentscheidung", - "kostenentscheidung", - "anordnung", - "schadensbemessung", - "bucheinsicht", -] as const; -export type AppealTarget = (typeof APPEAL_TARGETS)[number] | ""; - -export const URL_KEYS = { - proceeding: "proceeding", - side: "side", - target: "target", - triggerDate: "trigger_date", -} as const; - -// parseProceedingFromSearch extracts the proceeding code. Returns "" -// if absent. No validation against the proceeding registry — that's -// the caller's job (an unknown code from a stale link should leave -// the first-tile auto-select fallback running). -export function parseProceedingFromSearch(search: string): string { - const v = new URLSearchParams(search).get(URL_KEYS.proceeding); - return v ?? ""; -} - -export function parseSideFromSearch(search: string): Side { - const raw = new URLSearchParams(search).get(URL_KEYS.side); - return raw === "claimant" || raw === "defendant" ? raw : null; -} - -export function parseAppealTargetFromSearch(search: string): AppealTarget { - const raw = new URLSearchParams(search).get(URL_KEYS.target) || ""; - if ((APPEAL_TARGETS as readonly string[]).includes(raw)) { - return raw as AppealTarget; - } - return ""; -} - -// parseTriggerDateFromSearch validates the ISO-date shape so a -// malformed link can't poison the date input. Accepts "YYYY-MM-DD" -// only. Round-tripped against Date to reject 2026-02-30 etc. -export function parseTriggerDateFromSearch(search: string): string { - const raw = new URLSearchParams(search).get(URL_KEYS.triggerDate) || ""; - if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return ""; - const d = new Date(raw + "T00:00:00Z"); - if (Number.isNaN(d.getTime())) return ""; - if (d.toISOString().slice(0, 10) !== raw) return ""; - return raw; -} - -// applyFiltersToSearch produces the canonical query string for the -// four URL-owned params. Other params (e.g. ?view=, ?project=) are -// preserved verbatim. Empty values are deleted, never written as -// empty string, so the URL stays clean on the default. -export function applyFiltersToSearch( - search: string, - filters: { proceeding?: string; side?: Side; target?: AppealTarget; triggerDate?: string }, -): string { - const params = new URLSearchParams(search); - if ("proceeding" in filters) { - if (filters.proceeding && filters.proceeding !== "") { - params.set(URL_KEYS.proceeding, filters.proceeding); - } else { - params.delete(URL_KEYS.proceeding); - } - } - if ("side" in filters) { - if (filters.side === "claimant" || filters.side === "defendant") { - params.set(URL_KEYS.side, filters.side); - } else { - params.delete(URL_KEYS.side); - } - } - if ("target" in filters) { - if (filters.target && filters.target !== "") { - params.set(URL_KEYS.target, filters.target); - } else { - params.delete(URL_KEYS.target); - } - } - if ("triggerDate" in filters) { - if (filters.triggerDate && /^\d{4}-\d{2}-\d{2}$/.test(filters.triggerDate)) { - params.set(URL_KEYS.triggerDate, filters.triggerDate); - } else { - params.delete(URL_KEYS.triggerDate); - } - } - const s = params.toString(); - return s ? `?${s}` : ""; -} - -// ----- localStorage (scenario state) ------------------------------ - -export const SCENARIO_PREFIX = "paliad.verfahrensablauf.scenario"; -export const SCENARIO_KEYS = { - eventChoices: `${SCENARIO_PREFIX}.event_choices`, - courtId: `${SCENARIO_PREFIX}.court_id`, - ccr: `${SCENARIO_PREFIX}.ccr`, - infAmend: `${SCENARIO_PREFIX}.inf_amend`, - revAmend: `${SCENARIO_PREFIX}.rev_amend`, - revCci: `${SCENARIO_PREFIX}.rev_cci`, - showHidden: `${SCENARIO_PREFIX}.show_hidden`, -} as const; - -// StorageLike is the tiny subset of the Web Storage API the scenario -// helpers actually use. Lets the tests pass a Map-backed fake without -// pulling in a full localStorage polyfill. -export interface StorageLike { - getItem(key: string): string | null; - setItem(key: string, value: string): void; - removeItem(key: string): void; -} - -// readEventChoices is forgiving: malformed tuples or unknown -// choice_kinds are dropped silently. Same shape as the legacy URL -// codec (comma-separated `submission_code:kind=value`). -export function readEventChoices(storage: StorageLike): EventChoice[] { - const raw = storage.getItem(SCENARIO_KEYS.eventChoices); - if (!raw) return []; - const out: EventChoice[] = []; - for (const tuple of raw.split(",")) { - const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/); - if (!m) continue; - const kind = m[2] as ChoiceKind; - if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue; - out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] }); - } - return out; -} - -export function writeEventChoices(storage: StorageLike, choices: EventChoice[]): void { - if (choices.length === 0) { - storage.removeItem(SCENARIO_KEYS.eventChoices); - return; - } - const enc = choices - .map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`) - .join(","); - storage.setItem(SCENARIO_KEYS.eventChoices, enc); -} - -// readCourtId / writeCourtId — empty string == no court picked. The -// "" value is stored as a removed key, not an empty string entry, so -// reading it back yields null rather than "". -export function readCourtId(storage: StorageLike): string { - return storage.getItem(SCENARIO_KEYS.courtId) ?? ""; -} - -export function writeCourtId(storage: StorageLike, courtId: string): void { - if (courtId === "") { - storage.removeItem(SCENARIO_KEYS.courtId); - return; - } - storage.setItem(SCENARIO_KEYS.courtId, courtId); -} - -// Boolean flags — "1" / "0" string encoding, removeItem on default -// (false for flags, also false for show_hidden) so the storage stays -// uncluttered on a fresh page. -export function readBoolFlag(storage: StorageLike, key: string): boolean { - return storage.getItem(key) === "1"; -} - -export function writeBoolFlag(storage: StorageLike, key: string, on: boolean): void { - if (on) storage.setItem(key, "1"); - else storage.removeItem(key); -} - -// Read all scenario state in one call — convenience for the page's -// load-time hydration. Caller decides whether to apply each field -// (e.g. court_id is proceeding-specific; the page may discard the -// stored value if the active proceeding doesn't expose a court row). -export interface ScenarioState { - eventChoices: EventChoice[]; - courtId: string; - ccr: boolean; - infAmend: boolean; - revAmend: boolean; - revCci: boolean; - showHidden: boolean; -} - -export function readScenario(storage: StorageLike): ScenarioState { - return { - eventChoices: readEventChoices(storage), - courtId: readCourtId(storage), - ccr: readBoolFlag(storage, SCENARIO_KEYS.ccr), - infAmend: readBoolFlag(storage, SCENARIO_KEYS.infAmend), - revAmend: readBoolFlag(storage, SCENARIO_KEYS.revAmend), - revCci: readBoolFlag(storage, SCENARIO_KEYS.revCci), - showHidden: readBoolFlag(storage, SCENARIO_KEYS.showHidden), - }; -} - -// ----- URL → localStorage hydration order ------------------------- - -// The page's load-time contract: read URL filters, then read -// scenario state from localStorage. URL wins on conflict — but the -// only field that can conflict is none of them today (URL owns -// proceeding/side/target/trigger_date; localStorage owns the rest). -// The order matters for one edge case: if a future field migrates -// from URL → localStorage with overlap, the URL value MUST be honored. - -export interface HydratedState extends ScenarioState { - proceeding: string; - side: Side; - target: AppealTarget; - triggerDate: string; -} - -export function hydrate(search: string, storage: StorageLike): HydratedState { - const scenario = readScenario(storage); - return { - proceeding: parseProceedingFromSearch(search), - side: parseSideFromSearch(search), - target: parseAppealTargetFromSearch(search), - triggerDate: parseTriggerDateFromSearch(search), - ...scenario, - }; -} - -// makeMemoryStorage — tiny StorageLike for tests / SSR fallback. -// Not used by the runtime page (which mounts real localStorage), but -// kept here so test files have one well-known import. -export function makeMemoryStorage(): StorageLike { - const store = new Map(); - return { - getItem: (k) => (store.has(k) ? store.get(k)! : null), - setItem: (k, v) => { store.set(k, v); }, - removeItem: (k) => { store.delete(k); }, - }; -} diff --git a/frontend/src/components/VerfahrensablaufBody.tsx b/frontend/src/components/VerfahrensablaufBody.tsx deleted file mode 100644 index 4951387..0000000 --- a/frontend/src/components/VerfahrensablaufBody.tsx +++ /dev/null @@ -1,293 +0,0 @@ -import { h } from "../jsx"; - -interface ProceedingDef { - code: string; - i18nKey: string; - name: string; -} - -function proceedingBtn(p: ProceedingDef): string { - return ( - - ); -} - -// Slice B1 (m/paliad#124 §18.1): the 3 separate Berufung tiles -// (upc.apl.merits / upc.apl.cost / upc.apl.order) collapse into ONE -// unified "Berufung" tile (upc.apl). After picking it, the user -// selects which decision the appeal is directed AT via the -// .appeal-target-row chip group below — the engine then filters -// rules whose applies_to_target contains the picked slug. -const UPC_TYPES: ProceedingDef[] = [ - { code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" }, - { code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" }, - { code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" }, - { code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" }, - { code: "upc.apl.unified", i18nKey: "deadlines.upc.apl.unified", name: "Berufung" }, - { code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" }, - { code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" }, -]; - -const DE_INF_TYPES: ProceedingDef[] = [ - { code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" }, - { code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" }, - { code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" }, -]; - -const DE_NULL_TYPES: ProceedingDef[] = [ - { code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" }, - { code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" }, -]; - -const EPA_TYPES: ProceedingDef[] = [ - { code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" }, - { code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" }, - { code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" }, -]; - -const DPMA_TYPES: ProceedingDef[] = [ - { code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" }, - { code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" }, - { code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" }, -]; - -// Shared Verfahrensablauf wizard body. Renders the proceeding picker, -// perspective + date inputs, scenario flag rows, detail-mode toggle, -// view toggle, and the timeline-container that client/verfahrensablauf.ts -// (via initVerfahrensablauf()) wires against. Used by both -// /tools/verfahrensablauf (legacy) and /tools/procedures (unified). -export function VerfahrensablaufBody({ todayIso }: { todayIso: string }): string { - return ( -
    -
    -

    - 1 - Verfahrensart wählen -

    - -
    -

    UPC

    -
    - {UPC_TYPES.map((p) => proceedingBtn(p))} -
    -
    - -
    -

    Deutsche Gerichte

    -
    -
    Verletzungsverfahren
    -
    - {DE_INF_TYPES.map((p) => proceedingBtn(p))} -
    -
    -
    -
    Nichtigkeitsverfahren
    -
    - {DE_NULL_TYPES.map((p) => proceedingBtn(p))} -
    -
    -
    - -
    -

    EPA

    -
    - {EPA_TYPES.map((p) => proceedingBtn(p))} -
    -
    - -
    -

    DPMA

    -
    - {DPMA_TYPES.map((p) => proceedingBtn(p))} -
    -
    - - -
    - - - - -
    - ); -}