From 9ab8dd8e0fcc3f7c74918f3cc245c72ef5f6fc26 Mon Sep 17 00:00:00 2001
From: mAi
Date: Tue, 26 May 2026 22:09:27 +0200
Subject: [PATCH] =?UTF-8?q?feat(fristenrechner):=20Slice=20S2=20=E2=80=94?=
=?UTF-8?q?=20result=20view=20under=20=3Foverhaul=3D1=20(m/paliad#146)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
New `frontend/src/client/fristenrechner-result.ts` module renders the
shared result surface defined in
docs/design-fristenrechner-overhaul-2026-05-26.md §4:
* Sticky trigger card — event icon + name, proceeding/jurisdiction
chips, inline trigger-date input that re-fetches on change.
* Four follow-up groups — Mandatory / Recommended / Optional /
Conditional. SPAWNED rules fold into their priority bucket with
a `⇲ neues Verfahren` badge (§11.Q5). Conditional bucket holds
every rule with sr.condition_expr IS NOT NULL.
* Per-rule rows — title, duration phrase, party chip, legal-source
citation (with youpc.org link when available), pre-checked
checkbox driven by `defaultChecked(r)` (mandatory + recommended
on; conditional + court-set + optional off), inline ✏ Datum
override that re-renders.
* Write-back footer — conditional on `?project=` per §11.Q7;
in kontextfrei mode the footer is hidden and an inline nudge
invites the user to pick an Akte. CTA submits to the existing
POST /api/projects/{id}/deadlines/bulk endpoint, stamping each
row with `audit_reason: "Aus Fristenrechner — Trigger: {name}
({date})"` per §11.Q12.
Mount + URL contract — when `?overhaul=1` is set in the URL,
`fristenrechner.ts` hides every legacy panel (`fristen-step1`,
`fristen-step2`, `fristen-pathway-a`, `fristen-pathway-b`,
`fristen-step3a`, the step-1 summary) and shows the overhaul root
instead. With `?overhaul=1&event=&trigger_date=…` the surface
is deep-linkable end-to-end. Without `?event=` the empty-shell
nudge renders — S3+S4 will mount the entry-mode UIs onto this same
root.
Verified — bun build clean, 249 frontend tests pass (incl. 9 new
helper tests for groupFollowUps + defaultChecked), go build + vet
clean, S1 live-DB tests still green.
---
.../src/client/fristenrechner-result.test.ts | 72 +++
frontend/src/client/fristenrechner-result.ts | 611 ++++++++++++++++++
frontend/src/client/fristenrechner.ts | 58 ++
frontend/src/client/i18n.ts | 52 ++
frontend/src/fristenrechner.tsx | 9 +
frontend/src/i18n-keys.ts | 20 +
frontend/src/styles/global.css | 340 ++++++++++
7 files changed, 1162 insertions(+)
create mode 100644 frontend/src/client/fristenrechner-result.test.ts
create mode 100644 frontend/src/client/fristenrechner-result.ts
diff --git a/frontend/src/client/fristenrechner-result.test.ts b/frontend/src/client/fristenrechner-result.test.ts
new file mode 100644
index 0000000..ea7fc86
--- /dev/null
+++ b/frontend/src/client/fristenrechner-result.test.ts
@@ -0,0 +1,72 @@
+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
new file mode 100644
index 0000000..d0857a9
--- /dev/null
+++ b/frontend/src/client/fristenrechner-result.ts
@@ -0,0 +1,611 @@
+// 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;
+ 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 (S2+).
+// True when `?overhaul=1` is present. Once S5 flips the flag, the
+// reverse check (?legacy=1) replaces this.
+export function isOverhaulMode(): boolean {
+ return new URLSearchParams(window.location.search).get("overhaul") === "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;
+}
+
+// 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 = `
+
+ `;
+
+ 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}
+
+ ${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 `
+
+ `;
+}
+
+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 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}
+
+
+ ${durationPhrase ? `${escHtml(durationPhrase)}` : ""}
+ ${partyBadge}
+ ${sourceBadge}
+
+ ${notesHtml}
+
+
+ ${dateCell}
+ ${editBtn}
+
+
+ `;
+}
+
+function renderFooter(): string {
+ const selectedCount = countSelected();
+ return `
+
+ `;
+}
+
+// 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;
+ 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;
+ 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 {
+ 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.ts b/frontend/src/client/fristenrechner.ts
index 71c7450..bb4c548 100644
--- a/frontend/src/client/fristenrechner.ts
+++ b/frontend/src/client/fristenrechner.ts
@@ -30,6 +30,7 @@ import {
type EventChoice,
type ChoiceKind,
} from "./views/event-card-choices";
+import { isOverhaulMode, mountResultView } from "./fristenrechner-result";
let lastResponse: DeadlineResponse | null = null;
@@ -113,6 +114,49 @@ onLangChange(() => {
let selectedType = "";
+// t-paliad-323 Slice S2 — Fristenrechner overhaul boot. Hides the
+// legacy step / pathway shells and mounts the result view. S3+S4 will
+// hook entry-mode UIs into this; S2 is deep-link only.
+function bootOverhaulMode(): void {
+ // Hide every legacy section so only the overhaul root is visible.
+ // The page wrapper (``, ``, the
+ // tool-header) stays so the sidebar + title carry through.
+ const hideIds = [
+ "fristen-step1",
+ "fristen-step1-summary",
+ "fristen-step2",
+ "fristen-pathway-b",
+ "fristen-step3a",
+ "fristen-pathway-a",
+ ];
+ for (const id of hideIds) {
+ const el = document.getElementById(id);
+ if (el) {
+ el.hidden = true;
+ el.style.display = "none";
+ }
+ }
+
+ // S2 deep-link contract: ?overhaul=1&event=&trigger_date=…
+ // When event is missing, leave the surface empty — S3/S4 will mount
+ // entry-mode UIs onto this surface in later slices.
+ const params = new URLSearchParams(window.location.search);
+ const eventRef = params.get("event") || "";
+ const triggerDate = params.get("trigger_date") || undefined;
+ const party = params.get("party") || undefined;
+ const courtId = params.get("court_id") || undefined;
+
+ if (!eventRef) {
+ const root = document.getElementById("fristen-overhaul-root");
+ if (root) {
+ root.hidden = false;
+ root.innerHTML = `${t("deadlines.overhaul.empty")}
`;
+ }
+ return;
+ }
+ void mountResultView({ eventRef, triggerDate, party, courtId });
+}
+
function showStep(n: number) {
for (let i = 1; i <= 3; i++) {
const el = document.getElementById(`step-${i}`);
@@ -656,6 +700,20 @@ document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
+ // t-paliad-323 Slice S2 — Fristenrechner overhaul boot.
+ // When ?overhaul=1 is set, hide the legacy three-step wizard /
+ // Pathway A+B shells and mount the new result view in their place.
+ // Deep-linkable via ?overhaul=1&event=&trigger_date=…&project=…
+ // (the trigger date defaults to today when omitted). S3 (Mode A
+ // search) and S4 (Mode B wizard) will land users here once they
+ // identify a trigger event — for now the surface is reached only
+ // via deep link, but ?overhaul=1 alone shows the empty shell so
+ // the path is exercisable end-to-end.
+ if (isOverhaulMode()) {
+ bootOverhaulMode();
+ return;
+ }
+
// Proceeding type selection
document.querySelectorAll(".proceeding-btn").forEach((btn) => {
btn.addEventListener("click", () => selectProceeding(btn));
diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts
index d6e151c..bff6c15 100644
--- a/frontend/src/client/i18n.ts
+++ b/frontend/src/client/i18n.ts
@@ -1010,6 +1010,32 @@ const translations: Record> = {
"deadlines.save.error": "\u00dcbernahme fehlgeschlagen.",
"deadlines.save.skip_court_set": "Gerichtsbestimmte Termine ohne Datum werden \u00fcbersprungen.",
+ // Fristenrechner overhaul \u2014 shared result view (S2, design \u00a74).
+ "deadlines.overhaul.loading": "Folge-Fristen werden geladen\u2026",
+ "deadlines.overhaul.load_error": "Folge-Fristen konnten nicht geladen werden.",
+ "deadlines.overhaul.empty": "Keine Folge-Fristen f\u00fcr dieses Ereignis hinterlegt.",
+ "deadlines.overhaul.trigger.label": "Trigger-Ereignis",
+ "deadlines.overhaul.trigger.date": "Trigger-Datum:",
+ "deadlines.overhaul.followups.label": "Folge-Fristen",
+ "deadlines.overhaul.group.mandatory": "Pflicht",
+ "deadlines.overhaul.group.recommended": "Empfohlen",
+ "deadlines.overhaul.group.optional": "Kann (auf Antrag)",
+ "deadlines.overhaul.group.conditional": "Bedingt",
+ "deadlines.overhaul.spawn.badge": "\u21f2 neues Verfahren",
+ "deadlines.overhaul.spawn.tooltip": "Diese Regel leitet ein neues Verfahren ein.",
+ "deadlines.overhaul.condition.badge": "Nur unter Bedingung",
+ "deadlines.overhaul.notes.summary": "Hinweis",
+ "deadlines.overhaul.edit_date.label": "\u270f Datum",
+ "deadlines.overhaul.edit_date.title": "Datum manuell anpassen",
+ "deadlines.overhaul.select_rule": "Frist ausw\u00e4hlen",
+ "deadlines.overhaul.footer.count": "{n} Fristen ausgew\u00e4hlt",
+ "deadlines.overhaul.footer.cta": "In Akte eintragen",
+ "deadlines.overhaul.nudge.no_project": "Tipp: W\u00e4hle oben eine Akte, um diese Fristen einzutragen.",
+ "deadlines.party.claimant": "Kl\u00e4gerseite",
+ "deadlines.party.defendant": "Beklagtenseite",
+ "deadlines.party.both": "Beide Seiten",
+ "deadlines.party.court": "Gericht",
+
// Office labels (shared)
"office.munich": "M\u00fcnchen",
"office.duesseldorf": "D\u00fcsseldorf",
@@ -4122,6 +4148,32 @@ const translations: Record> = {
"deadlines.save.error": "Import failed.",
"deadlines.save.skip_court_set": "Court-set entries with no date will be skipped.",
+ // Fristenrechner overhaul — shared result view (S2, design §4).
+ "deadlines.overhaul.loading": "Loading follow-up deadlines…",
+ "deadlines.overhaul.load_error": "Could not load follow-up deadlines.",
+ "deadlines.overhaul.empty": "No follow-up deadlines configured for this event.",
+ "deadlines.overhaul.trigger.label": "Trigger event",
+ "deadlines.overhaul.trigger.date": "Trigger date:",
+ "deadlines.overhaul.followups.label": "Follow-up deadlines",
+ "deadlines.overhaul.group.mandatory": "Mandatory",
+ "deadlines.overhaul.group.recommended": "Recommended",
+ "deadlines.overhaul.group.optional": "Optional",
+ "deadlines.overhaul.group.conditional": "Conditional",
+ "deadlines.overhaul.spawn.badge": "⇲ new proceeding",
+ "deadlines.overhaul.spawn.tooltip": "This rule initiates a new proceeding.",
+ "deadlines.overhaul.condition.badge": "Conditional",
+ "deadlines.overhaul.notes.summary": "Note",
+ "deadlines.overhaul.edit_date.label": "✏ Date",
+ "deadlines.overhaul.edit_date.title": "Edit date manually",
+ "deadlines.overhaul.select_rule": "Select deadline",
+ "deadlines.overhaul.footer.count": "{n} deadlines selected",
+ "deadlines.overhaul.footer.cta": "Add to project",
+ "deadlines.overhaul.nudge.no_project": "Tip: pick a project above to import these deadlines.",
+ "deadlines.party.claimant": "Claimant",
+ "deadlines.party.defendant": "Defendant",
+ "deadlines.party.both": "Both parties",
+ "deadlines.party.court": "Court",
+
// Office labels (shared)
"office.munich": "Munich",
"office.duesseldorf": "D\u00fcsseldorf",
diff --git a/frontend/src/fristenrechner.tsx b/frontend/src/fristenrechner.tsx
index fcf1230..c8e101f 100644
--- a/frontend/src/fristenrechner.tsx
+++ b/frontend/src/fristenrechner.tsx
@@ -123,6 +123,15 @@ export function renderFristenrechner(): string {
+ {/* t-paliad-323 Slice S2 — overhaul result view mount root.
+ Hidden by default; the client module shows this and hides
+ the legacy panels when `?overhaul=1` is present in the
+ URL. Deep-linkable on its own via
+ `?overhaul=1&event=&trigger_date=…`. Mode A (S3)
+ and Mode B wizard (S4) will land users on this surface
+ once they identify a trigger procedural_event. */}
+
+
{/* m's 2026-05-08 18:08 Determinator redesign — Step 1: pick the
Akte (project) that scopes the rest of the flow. Filtered
list of visible projects + "Neue Akte anlegen" link +
diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts
index 1abfdc8..283f6d1 100644
--- a/frontend/src/i18n-keys.ts
+++ b/frontend/src/i18n-keys.ts
@@ -1377,6 +1377,26 @@ export type I18nKey =
| "deadlines.neu.title"
| "deadlines.notes.show"
| "deadlines.optional.badge"
+ | "deadlines.overhaul.condition.badge"
+ | "deadlines.overhaul.edit_date.label"
+ | "deadlines.overhaul.edit_date.title"
+ | "deadlines.overhaul.empty"
+ | "deadlines.overhaul.followups.label"
+ | "deadlines.overhaul.footer.count"
+ | "deadlines.overhaul.footer.cta"
+ | "deadlines.overhaul.group.conditional"
+ | "deadlines.overhaul.group.mandatory"
+ | "deadlines.overhaul.group.optional"
+ | "deadlines.overhaul.group.recommended"
+ | "deadlines.overhaul.load_error"
+ | "deadlines.overhaul.loading"
+ | "deadlines.overhaul.notes.summary"
+ | "deadlines.overhaul.nudge.no_project"
+ | "deadlines.overhaul.select_rule"
+ | "deadlines.overhaul.spawn.badge"
+ | "deadlines.overhaul.spawn.tooltip"
+ | "deadlines.overhaul.trigger.date"
+ | "deadlines.overhaul.trigger.label"
| "deadlines.party.both"
| "deadlines.party.both.label"
| "deadlines.party.claimant"
diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css
index bc009eb..83095cf 100644
--- a/frontend/src/styles/global.css
+++ b/frontend/src/styles/global.css
@@ -18886,3 +18886,343 @@ dialog.quick-add-sheet::backdrop {
gap: 0.5rem;
}
}
+
+/* === Fristenrechner overhaul (t-paliad-323 Slice S2) =================
+ *
+ * Result-view surface mounted under `?overhaul=1`. Sticky trigger card
+ * on top, four priority groups of follow-up rules, write-back footer
+ * conditional on `?project=`. See
+ * docs/design-fristenrechner-overhaul-2026-05-26.md §4.
+ * ==================================================================== */
+
+.fristen-overhaul-root {
+ display: block;
+ margin-top: 1.5rem;
+}
+
+.fristen-overhaul-loading,
+.fristen-overhaul-error,
+.fristen-overhaul-empty,
+.fristen-overhaul-nudge {
+ padding: 0.9rem 1.1rem;
+ border-radius: 0.6rem;
+ margin: 0.5rem 0;
+ background: #f4f4f0;
+ border: 1px solid #e3e3da;
+ color: #444;
+ font-size: 0.95rem;
+}
+
+.fristen-overhaul-error {
+ background: #fde9e7;
+ border-color: #f0b8b1;
+ color: #732f25;
+}
+
+.fristen-overhaul-nudge {
+ background: #f8fbe8;
+ border-color: #d2e08b;
+ color: #4d5a2a;
+}
+
+.fristen-overhaul-trigger {
+ background: #fff;
+ border: 1px solid #d8d8cf;
+ border-radius: 0.8rem;
+ padding: 1rem 1.2rem;
+ margin-bottom: 1.2rem;
+ box-shadow: 0 2px 6px rgba(0,0,0,0.04);
+}
+
+.fristen-overhaul-trigger-header {
+ display: flex;
+ align-items: center;
+ gap: 0.7rem;
+}
+
+.fristen-overhaul-kind-icon {
+ font-size: 1.5rem;
+ line-height: 1;
+}
+
+.fristen-overhaul-trigger-title {
+ margin: 0;
+ font-size: 1.25rem;
+ color: #1f1f1f;
+}
+
+.fristen-overhaul-trigger-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+ font-size: 0.9rem;
+ color: #555;
+}
+
+.fristen-overhaul-trigger-code,
+.fristen-overhaul-trigger-pt,
+.fristen-overhaul-trigger-juris {
+ padding: 0.15rem 0.55rem;
+ border-radius: 0.4rem;
+ background: #f1f1eb;
+ color: #555;
+ font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
+ font-size: 0.8rem;
+}
+
+.fristen-overhaul-trigger-juris {
+ background: #d3edb7;
+ color: #38531a;
+ font-family: inherit;
+ font-weight: 600;
+}
+
+.fristen-overhaul-trigger-date {
+ display: flex;
+ align-items: center;
+ gap: 0.7rem;
+ margin-top: 0.8rem;
+}
+
+.fristen-overhaul-trigger-date-label {
+ font-size: 0.9rem;
+ color: #555;
+}
+
+.fristen-overhaul-trigger-date-input {
+ padding: 0.35rem 0.55rem;
+ font-size: 0.95rem;
+ border: 1px solid #c8c8be;
+ border-radius: 0.4rem;
+ background: #fff;
+}
+
+.fristen-overhaul-groups {
+ display: flex;
+ flex-direction: column;
+ gap: 1.1rem;
+}
+
+.fristen-overhaul-group {
+ background: #fff;
+ border: 1px solid #e2e2d6;
+ border-radius: 0.7rem;
+ padding: 0.9rem 1.1rem;
+}
+
+.fristen-overhaul-group--mandatory { border-left: 4px solid #c6f41c; }
+.fristen-overhaul-group--recommended { border-left: 4px solid #99c4e3; }
+.fristen-overhaul-group--optional { border-left: 4px solid #d4d4cc; }
+.fristen-overhaul-group--conditional { border-left: 4px solid #f5b66a; }
+
+.fristen-overhaul-group-title {
+ margin: 0 0 0.6rem 0;
+ font-size: 1rem;
+ color: #2a2a2a;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.fristen-overhaul-rule-list {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.6rem;
+}
+
+.fristen-overhaul-rule {
+ display: grid;
+ grid-template-columns: auto 1fr auto;
+ gap: 0.7rem;
+ align-items: start;
+ padding: 0.5rem 0.6rem;
+ background: #fafaf6;
+ border: 1px solid #ececde;
+ border-radius: 0.5rem;
+}
+
+.fristen-overhaul-rule.is-disabled {
+ opacity: 0.7;
+}
+
+.fristen-overhaul-rule-check {
+ display: flex;
+ align-items: center;
+ height: 1.4rem;
+ cursor: pointer;
+}
+
+.fristen-overhaul-rule-body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ min-width: 0;
+}
+
+.fristen-overhaul-rule-title-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: baseline;
+ gap: 0.5rem;
+}
+
+.fristen-overhaul-rule-title {
+ font-weight: 600;
+ color: #1f1f1f;
+}
+
+.fristen-overhaul-rule-spawn,
+.fristen-overhaul-rule-cond {
+ font-size: 0.75rem;
+ padding: 0.05rem 0.45rem;
+ border-radius: 0.35rem;
+ background: #f3e5cf;
+ color: #6e4a1d;
+ white-space: nowrap;
+}
+
+.fristen-overhaul-rule-cond {
+ background: #fff2d6;
+ color: #7a570e;
+}
+
+.fristen-overhaul-rule-meta-row {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ font-size: 0.85rem;
+ color: #555;
+}
+
+.fristen-overhaul-rule-duration {
+ color: #2a2a2a;
+}
+
+.fristen-overhaul-rule-party {
+ padding: 0.05rem 0.45rem;
+ border-radius: 0.35rem;
+ font-size: 0.75rem;
+ background: #eef2e3;
+ color: #4a5d2a;
+}
+
+.fristen-overhaul-rule-party--claimant { background: #d2e9ff; color: #1c4567; }
+.fristen-overhaul-rule-party--defendant { background: #ffe2d7; color: #6e2c14; }
+.fristen-overhaul-rule-party--court { background: #f0e2f7; color: #4f2c66; }
+
+.fristen-overhaul-rule-source {
+ font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
+ font-size: 0.8rem;
+ color: #444;
+}
+
+a.fristen-overhaul-rule-source {
+ color: #2d4f1a;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+.fristen-overhaul-rule-notes {
+ margin-top: 0.3rem;
+ font-size: 0.85rem;
+ color: #555;
+}
+
+.fristen-overhaul-rule-notes summary {
+ cursor: pointer;
+ color: #666;
+}
+
+.fristen-overhaul-rule-date-cell {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-end;
+ gap: 0.25rem;
+ font-size: 0.95rem;
+ min-width: 6.5rem;
+}
+
+.fristen-overhaul-rule-date {
+ font-weight: 600;
+ color: #1f1f1f;
+}
+
+.fristen-overhaul-rule-date--unknown {
+ color: #999;
+ font-weight: 400;
+}
+
+.fristen-overhaul-rule-court-set {
+ color: #6e4a1d;
+ font-style: italic;
+ font-size: 0.85rem;
+}
+
+.fristen-overhaul-rule-date-input {
+ padding: 0.2rem 0.4rem;
+ font-size: 0.95rem;
+ border: 1px solid #c8c8be;
+ border-radius: 0.3rem;
+ background: #fff;
+}
+
+.fristen-overhaul-rule-edit-date {
+ border: 0;
+ background: transparent;
+ color: #4a6f1f;
+ font-size: 0.8rem;
+ cursor: pointer;
+ padding: 0.1rem 0.3rem;
+ border-radius: 0.3rem;
+}
+
+.fristen-overhaul-rule-edit-date:hover {
+ background: #eef4dd;
+}
+
+.fristen-overhaul-footer {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-top: 1.2rem;
+ padding: 0.9rem 1.1rem;
+ background: #f7fbe6;
+ border: 1px solid #d3e08b;
+ border-radius: 0.7rem;
+}
+
+.fristen-overhaul-footer-count {
+ font-size: 0.95rem;
+ color: #3d501c;
+ font-weight: 500;
+}
+
+.fristen-overhaul-footer-cta {
+ /* leans on btn-primary / btn-cta-lime classes from global */
+}
+
+.fristen-overhaul-msg {
+ margin-top: 0.8rem;
+ padding: 0.6rem 0.9rem;
+ font-size: 0.9rem;
+ border-radius: 0.4rem;
+}
+
+.fristen-overhaul-msg.form-msg-ok { background: #e7f4d6; color: #3a5113; }
+.fristen-overhaul-msg.form-msg-error { background: #fde9e7; color: #732f25; }
+
+@media (max-width: 600px) {
+ .fristen-overhaul-rule {
+ grid-template-columns: auto 1fr;
+ }
+ .fristen-overhaul-rule-date-cell {
+ grid-column: 1 / -1;
+ flex-direction: row;
+ justify-content: flex-end;
+ align-items: center;
+ min-width: 0;
+ }
+}