From df04e500f7db30b2ff8b730b10327c76e5fd2e07 Mon Sep 17 00:00:00 2001
From: m
Date: Fri, 8 May 2026 19:50:59 +0200
Subject: [PATCH] =?UTF-8?q?feat(fristenrechner/determinator):=20Slice=201?=
=?UTF-8?q?=20=E2=80=94=20project=20picker=20+=20do/happened=20bifurcation?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
m's 2026-05-08 18:08 Determinator redesign Slice 1. Replaces the
legacy "Was möchten Sie tun?" fork (Pathway A vs B) with a two-step
funnel that puts the project (Akte) at the foundation:
Step 1 — Welche Akte?
- Filtered list of visible projects, search-as-you-type.
- "Neue Akte anlegen" link → /projects/new (bare; the bounce-back
with auto-preselect lands as Slice 2 per Maria's gating).
- Four ad-hoc explore-mode chips (Custom UPC / DE / EPA / DPMA
proceeding) for users who just want to look up a rule. No DB
write; URL becomes ?ad_hoc=upc|de|epa|dpma.
Step 2 — Was möchten Sie tun?
- Two cards: "Etwas einreichen" → Pathway A (Verfahrensablauf
wizard) and "Etwas ist passiert" → Pathway B (cascade, mode=tree).
- Quick-pick chips moved here from the old fork's shortcut row.
Once Step 1 picks a context, the picker collapses to a one-line
summary "Akte: X · [Andere Akte]" mirroring the proceeding-summary
collapse pattern (097e21c). Reselect re-expands and clears downstream
state.
State on URL:
?project= project context
?ad_hoc=upc|... ad-hoc explore-mode
?path=a|b Step 2 outcome (kept for back-compat)
?mode=tree|filter Pathway B sub-mode (kept)
The legacy back-from-Pathway buttons now return to Step 2 (the new
"fork" state). showPathway() / showBMode() unchanged — Step 2 cards
just drive the same primitive.
Save-to-project CTA on Pathway A's wizard detects ad-hoc mode and
disables itself with the hint "Ad-hoc — kein Projekt, kein Speichern"
(EN: "Ad-hoc — no matter, no save"). Hiding the CTA would leave the
user wondering where the action went; disabling makes the constraint
legible (per m's lock #2).
Frontend pieces:
- fristenrechner.tsx — Step 1 + Step 2 markup; legacy
fristen-pathway-fork removed wholesale.
- client/fristenrechner.ts — new Step1Context type + URL hydration
+ render helpers; initPathwayFork rewired to drive the new
cards; renderProcedureResults gates the save CTA on
isAdhocMode().
- client/i18n.ts — 19 new keys (DE+EN) under deadlines.step1.* +
deadlines.step2.* + the save CTA hint.
- styles/global.css — .fristen-step1 / .fristen-step2 block + chip
+ summary styles, all bound to the existing --color-* token
palette. Mobile breakpoint stacks the Step 2 cards at <600px.
Out of scope for this slice (will land later):
- Slice 2: /projects/new bounce-back with auto-preselect via
?return=/tools/fristenrechner.
- Slice 3+: scoping the picker / cascade by project's
proceeding-type + role; replacing the existing wizard with the
Step 3a "File / Draft / Enter" chooser.
Refs t-paliad-157 / m/paliad#15.
---
frontend/src/client/fristenrechner.ts | 267 +++++++++++++++++++++++--
frontend/src/client/i18n.ts | 38 ++++
frontend/src/fristenrechner.tsx | 104 ++++++++--
frontend/src/i18n-keys.ts | 19 ++
frontend/src/styles/global.css | 268 ++++++++++++++++++++++++++
5 files changed, 665 insertions(+), 31 deletions(-)
diff --git a/frontend/src/client/fristenrechner.ts b/frontend/src/client/fristenrechner.ts
index 85c6886..d8dfd40 100644
--- a/frontend/src/client/fristenrechner.ts
+++ b/frontend/src/client/fristenrechner.ts
@@ -494,7 +494,22 @@ function renderProcedureResults(data: DeadlineResponse) {
container.innerHTML = headerHtml + bodyHtml;
printBtn.style.display = "block";
- if (saveBtn) saveBtn.style.display = "block";
+ if (saveBtn) {
+ // Ad-hoc explore-mode has no project to save against — show the
+ // CTA disabled with a hint so the user understands why the action
+ // is blocked (m's 2026-05-08 Slice 1 lock #2). Hiding it would
+ // leave the user wondering where the save went.
+ saveBtn.style.display = "block";
+ if (isAdhocMode()) {
+ saveBtn.disabled = true;
+ saveBtn.title = t("deadlines.save.cta.adhoc.hint");
+ saveBtn.dataset.adhocDisabled = "true";
+ } else {
+ saveBtn.disabled = false;
+ saveBtn.removeAttribute("title");
+ delete saveBtn.dataset.adhocDisabled;
+ }
+ }
if (toggle) toggle.style.display = "";
applyPendingFocus();
@@ -2325,12 +2340,31 @@ function setPathwayURL(path: Pathway, mode?: BMode, replace = false) {
}
function showPathway(path: Pathway, mode?: BMode) {
- const fork = document.getElementById("fristen-pathway-fork");
+ // m's 2026-05-08 18:08 redesign retired the legacy fork; Step 1 and
+ // Step 2 sit where it used to live. The "fork" Pathway value now
+ // means "show Step 1 + Step 2 (Step 2 visibility gated by whether a
+ // project / ad-hoc context is selected — managed by initStep1Step2)";
+ // "a" / "b" still drive the existing wizard / cascade shells.
+ const step1 = document.getElementById("fristen-step1");
+ const step1Summary = document.getElementById("fristen-step1-summary");
+ const step2 = document.getElementById("fristen-step2");
const a = document.getElementById("fristen-pathway-a");
const b = document.getElementById("fristen-pathway-b");
- if (!fork || !a || !b) return;
+ if (!a || !b) return;
- fork.hidden = path !== "fork";
+ // Step 1 + 2 stay mounted under "fork". Step 2 visibility is also
+ // gated by the Step 1 context state — initStep1Step2 owns the
+ // toggle between Step 1 expanded vs collapsed-with-summary, and
+ // the "show Step 2" gate. We just hide them wholesale when not on
+ // the fork.
+ if (step1) step1.style.display = path === "fork" ? "" : "none";
+ if (step1Summary) {
+ // Summary stays visible from Step 2 onward so the user always
+ // sees their selected Akte. Hidden only when no context is set.
+ const ctx = readStep1ContextFromURL();
+ step1Summary.style.display = (ctx.kind !== "none" && path !== "fork") ? "" : step1Summary.style.display;
+ }
+ if (step2) step2.hidden = path !== "fork";
a.hidden = path !== "a";
b.hidden = path !== "b";
@@ -2374,29 +2408,226 @@ function navigateToPathway(path: Pathway, mode?: BMode) {
}
}
+// ============================================================================
+// m's 2026-05-08 18:08 Determinator redesign — Step 1 + Step 2 state
+// ============================================================================
+// Step 1: pick the project (Akte) that scopes everything downstream, OR
+// pick an ad-hoc explore-mode chip (4 jurisdictions). Step 2: choose
+// between outgoing intent (Pathway A / Verfahrensablauf) and incoming
+// event (Pathway B / cascade). Step 3+ stay as today (Pathway A wizard,
+// B1 cascade, B2 search). The legacy "Was möchten Sie tun?" fork is
+// retired; back-buttons inside Pathway A/B return to Step 2.
+
+type Step1ContextKind = "project" | "adhoc" | "none";
+type AdhocForum = "upc" | "de" | "epa" | "dpma";
+
+interface Step1Context {
+ kind: Step1ContextKind;
+ projectId?: string;
+ project?: ProjectOption;
+ adhocForum?: AdhocForum;
+}
+
+let currentStep1Context: Step1Context = { kind: "none" };
+let cachedAkten: ProjectOption[] = [];
+
+function readStep1ContextFromURL(): Step1Context {
+ const sp = new URLSearchParams(window.location.search);
+ const project = sp.get("project");
+ const adhoc = sp.get("ad_hoc");
+ if (project) return { kind: "project", projectId: project };
+ if (adhoc === "upc" || adhoc === "de" || adhoc === "epa" || adhoc === "dpma") {
+ return { kind: "adhoc", adhocForum: adhoc };
+ }
+ return { kind: "none" };
+}
+
+function writeStep1ContextToURL(ctx: Step1Context, replace = false) {
+ const url = new URL(window.location.href);
+ if (ctx.kind === "project" && ctx.projectId) {
+ url.searchParams.set("project", ctx.projectId);
+ url.searchParams.delete("ad_hoc");
+ } else if (ctx.kind === "adhoc" && ctx.adhocForum) {
+ url.searchParams.set("ad_hoc", ctx.adhocForum);
+ url.searchParams.delete("project");
+ } else {
+ url.searchParams.delete("project");
+ url.searchParams.delete("ad_hoc");
+ }
+ if (replace) window.history.replaceState({}, "", url.toString());
+ else window.history.pushState({}, "", url.toString());
+}
+
+// isAdhocMode is read by the save-to-project CTA — ad-hoc has no
+// project to save against, so the CTA disables and renders a hint.
+function isAdhocMode(): boolean {
+ return currentStep1Context.kind === "adhoc";
+}
+
+function adhocSummaryLabel(forum: AdhocForum): string {
+ switch (forum) {
+ case "upc": return "Ad-hoc UPC";
+ case "de": return "Ad-hoc DE";
+ case "epa": return "Ad-hoc EPA";
+ case "dpma": return "Ad-hoc DPMA";
+ }
+}
+
+function renderAkteList(query: string) {
+ const list = document.getElementById("fristen-akte-list");
+ if (!list) return;
+ const q = query.trim().toLowerCase();
+ const filtered = q === ""
+ ? cachedAkten.slice(0, 12)
+ : cachedAkten.filter((p) =>
+ (p.title || "").toLowerCase().includes(q) ||
+ (p.reference || "").toLowerCase().includes(q));
+ if (filtered.length === 0) {
+ list.innerHTML = `${escHtml(t("deadlines.step1.search.empty"))}`;
+ return;
+ }
+ list.innerHTML = filtered.map((p) => {
+ const ref = p.reference ? `${escHtml(p.reference)} · ` : "";
+ return ``;
+ }).join("");
+ list.querySelectorAll(".fristen-akte-list-item").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const id = btn.dataset.projectId!;
+ const project = cachedAkten.find((p) => p.id === id);
+ if (project) selectProject(project);
+ });
+ });
+}
+
+function selectProject(project: ProjectOption) {
+ currentStep1Context = { kind: "project", projectId: project.id, project };
+ writeStep1ContextToURL(currentStep1Context);
+ renderStep1Summary();
+ showStep2Card();
+}
+
+function selectAdhoc(forum: AdhocForum) {
+ currentStep1Context = { kind: "adhoc", adhocForum: forum };
+ writeStep1ContextToURL(currentStep1Context);
+ renderStep1Summary();
+ showStep2Card();
+}
+
+function clearStep1Context() {
+ currentStep1Context = { kind: "none" };
+ writeStep1ContextToURL(currentStep1Context);
+ renderStep1Summary();
+ hideStep2Card();
+}
+
+function renderStep1Summary() {
+ const summary = document.getElementById("fristen-step1-summary") as HTMLElement | null;
+ const name = document.getElementById("fristen-step1-summary-name");
+ const meta = document.getElementById("fristen-step1-summary-meta");
+ const step1 = document.getElementById("fristen-step1") as HTMLElement | null;
+ if (!summary || !name || !step1) return;
+
+ if (currentStep1Context.kind === "none") {
+ summary.style.display = "none";
+ step1.style.display = "";
+ return;
+ }
+
+ if (currentStep1Context.kind === "project" && currentStep1Context.project) {
+ const p = currentStep1Context.project;
+ const ref = p.reference ? p.reference + " · " : "";
+ name.textContent = ref + p.title;
+ if (meta) meta.textContent = "";
+ } else if (currentStep1Context.kind === "adhoc" && currentStep1Context.adhocForum) {
+ name.textContent = adhocSummaryLabel(currentStep1Context.adhocForum);
+ if (meta) meta.textContent = " · " + t("deadlines.step1.summary.adhoc.suffix");
+ }
+ summary.style.display = "";
+ step1.style.display = "none";
+}
+
+function showStep2Card() {
+ const step2 = document.getElementById("fristen-step2");
+ if (step2) step2.hidden = false;
+}
+
+function hideStep2Card() {
+ const step2 = document.getElementById("fristen-step2");
+ if (step2) step2.hidden = true;
+}
+
function initPathwayFork() {
// Set chip labels to active language before user sees them.
relabelChips();
- // Initial render from URL (or saved preference if URL is bare).
+
+ // Hydrate Step 1 context from URL first — Step 2 visibility depends
+ // on whether a project / ad-hoc chip is already locked in.
+ currentStep1Context = readStep1ContextFromURL();
+
+ // Initial Pathway render. Inherits the URL ?path= semantic — Step 2
+ // having been satisfied is implied if path = a / b.
const initial = readPathwayFromURL();
const initialMode = readBModeFromURL();
showPathway(initial, initialMode);
- // Persist initial choice from URL.
+ // Step 1 summary visibility flows from the context kind.
+ renderStep1Summary();
+ if (currentStep1Context.kind !== "none") {
+ showStep2Card();
+ }
+
if (initial !== "fork") {
try { localStorage.setItem(PATHWAY_STORAGE_KEY, initial); } catch { /* */ }
}
- // Click handlers on the two fork cards.
- document.getElementById("fristen-pathway-a-cta")?.addEventListener("click", () => {
+ // Step 1 — fetch projects + render filtered list. Search filters the
+ // list in-place; click on a row drops the user into Step 2.
+ void (async () => {
+ cachedAkten = await fetchProjects();
+ if (currentStep1Context.kind === "project" && currentStep1Context.projectId) {
+ currentStep1Context.project = cachedAkten.find((p) => p.id === currentStep1Context.projectId);
+ renderStep1Summary();
+ }
+ renderAkteList("");
+ })();
+
+ const akteSearch = document.getElementById("fristen-akte-search") as HTMLInputElement | null;
+ if (akteSearch) {
+ akteSearch.addEventListener("input", () => renderAkteList(akteSearch.value));
+ }
+
+ // Ad-hoc chips — explore-mode escape hatch. No DB write; the
+ // save-modal CTA disables itself in this state.
+ document.querySelectorAll(".fristen-adhoc-chip").forEach((chip) => {
+ chip.addEventListener("click", () => {
+ const forum = chip.dataset.adHoc as AdhocForum | undefined;
+ if (forum) selectAdhoc(forum);
+ });
+ });
+
+ // Reselect: drop the locked context, return to Step 1.
+ document.getElementById("fristen-step1-summary-reselect")?.addEventListener("click", () => {
+ clearStep1Context();
+ // Bounce back to fork (Step 1 + 2) so the user sees the picker,
+ // even if they were currently in Pathway A or B.
+ if (initial !== "fork") {
+ navigateToPathway("fork");
+ }
+ });
+
+ // Step 2 cards — outgoing (Pathway A wizard) vs incoming (Pathway B
+ // cascade). showPathway() owns the actual A/B transition; we just
+ // drive it from the action choice.
+ document.getElementById("fristen-step2-file")?.addEventListener("click", () => {
navigateToPathway("a");
});
- document.getElementById("fristen-pathway-b-cta")?.addEventListener("click", () => {
- // Default to tree mode on first entry to Pathway B.
+ document.getElementById("fristen-step2-happened")?.addEventListener("click", () => {
navigateToPathway("b", "tree");
});
- // Back-to-fork buttons inside each pathway shell.
+ // Back-from-Pathway buttons return to Step 2 (the new "fork" state).
document.getElementById("fristen-pathway-a-back")?.addEventListener("click", () => {
navigateToPathway("fork");
});
@@ -2415,7 +2646,9 @@ function initPathwayFork() {
});
});
- // Quick-pick chips on the fork shortcut row → jump straight to Pathway B + filter mode + prefilled query.
+ // Quick-pick chips on the Step 2 shortcut row → jump straight to
+ // Pathway B + filter mode + prefilled query. Same behaviour as the
+ // legacy fork; only the ID's mounting point changed.
document.querySelectorAll("#fristen-fork-chips .fristen-search-chip").forEach((chip) => {
chip.addEventListener("click", () => {
const q = chipQueryFor(chip);
@@ -2425,8 +2658,6 @@ function initPathwayFork() {
if (q) url.searchParams.set("q", q);
window.history.pushState({}, "", url.toString());
showPathway("b", "filter");
- // initSearch listens for popstate, but we used pushState; sync the
- // search input directly.
const input = document.getElementById("fristen-search-input") as HTMLInputElement | null;
if (input && q) {
input.value = q;
@@ -2435,8 +2666,14 @@ function initPathwayFork() {
});
});
- // Browser back/forward should restore pathway state.
+ // Browser back/forward restores pathway + Step 1 context.
window.addEventListener("popstate", () => {
+ currentStep1Context = readStep1ContextFromURL();
+ if (currentStep1Context.kind === "project" && currentStep1Context.projectId) {
+ currentStep1Context.project = cachedAkten.find((p) => p.id === currentStep1Context.projectId);
+ }
+ renderStep1Summary();
+ if (currentStep1Context.kind !== "none") showStep2Card(); else hideStep2Card();
const path = readPathwayFromURL();
const mode = readBModeFromURL();
showPathway(path, mode);
diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts
index 7998b7a..eed8f00 100644
--- a/frontend/src/client/i18n.ts
+++ b/frontend/src/client/i18n.ts
@@ -247,6 +247,25 @@ const translations: Record> = {
"deadlines.optional.badge": "auf Antrag",
"deadlines.proceeding.selected": "Verfahren:",
"deadlines.proceeding.reselect": "Anderes Verfahren wählen",
+ "deadlines.step1.heading": "Schritt 1 — Welche Akte?",
+ "deadlines.step1.search.placeholder": "Akte suchen…",
+ "deadlines.step1.search.empty": "Keine passende Akte gefunden.",
+ "deadlines.step1.divider.new": "oder eine neue Akte",
+ "deadlines.step1.divider.adhoc": "oder ad-hoc, ohne Akte",
+ "deadlines.step1.new.cta": "+ Neue Akte anlegen",
+ "deadlines.step1.adhoc.upc": "Custom UPC-Verfahren",
+ "deadlines.step1.adhoc.de": "Custom DE-Verfahren",
+ "deadlines.step1.adhoc.epa": "Custom EPA-Verfahren",
+ "deadlines.step1.adhoc.dpma": "Custom DPMA-Verfahren",
+ "deadlines.step1.selected": "Akte:",
+ "deadlines.step1.reselect": "Andere Akte",
+ "deadlines.step1.summary.adhoc.suffix": "ohne Akte (Erkundung)",
+ "deadlines.step2.heading": "Schritt 2 — Was möchten Sie tun?",
+ "deadlines.step2.file.title": "Etwas einreichen",
+ "deadlines.step2.file.desc": "Outgoing — eine Frist tritt aus eigener Handlung ein.",
+ "deadlines.step2.happened.title": "Etwas ist passiert",
+ "deadlines.step2.happened.desc": "Incoming — ein Ereignis hat eine Frist ausgelöst.",
+ "deadlines.save.cta.adhoc.hint": "Ad-hoc — kein Projekt, kein Speichern",
"deadlines.date.edit.hint": "Datum bearbeiten — Folgefristen werden neu berechnet",
"deadlines.view.label": "Ansicht:",
"deadlines.view.timeline": "Zeitstrahl",
@@ -2324,6 +2343,25 @@ const translations: Record> = {
"deadlines.optional.badge": "on request",
"deadlines.proceeding.selected": "Proceeding:",
"deadlines.proceeding.reselect": "Choose another proceeding",
+ "deadlines.step1.heading": "Step 1 — Which matter?",
+ "deadlines.step1.search.placeholder": "Search matters…",
+ "deadlines.step1.search.empty": "No matching matter.",
+ "deadlines.step1.divider.new": "or a new matter",
+ "deadlines.step1.divider.adhoc": "or ad-hoc, without a matter",
+ "deadlines.step1.new.cta": "+ Create new matter",
+ "deadlines.step1.adhoc.upc": "Custom UPC proceeding",
+ "deadlines.step1.adhoc.de": "Custom DE proceeding",
+ "deadlines.step1.adhoc.epa": "Custom EPA proceeding",
+ "deadlines.step1.adhoc.dpma": "Custom DPMA proceeding",
+ "deadlines.step1.selected": "Matter:",
+ "deadlines.step1.reselect": "Other matter",
+ "deadlines.step1.summary.adhoc.suffix": "no matter (exploration)",
+ "deadlines.step2.heading": "Step 2 — What do you want to do?",
+ "deadlines.step2.file.title": "File something",
+ "deadlines.step2.file.desc": "Outgoing — your action triggers a deadline.",
+ "deadlines.step2.happened.title": "Something happened",
+ "deadlines.step2.happened.desc": "Incoming — an event triggered a deadline.",
+ "deadlines.save.cta.adhoc.hint": "Ad-hoc — no matter, no save",
"deadlines.date.edit.hint": "Edit date — downstream deadlines will recalculate",
"deadlines.view.label": "View:",
"deadlines.view.timeline": "Timeline",
diff --git a/frontend/src/fristenrechner.tsx b/frontend/src/fristenrechner.tsx
index af8f120..ef27c65 100644
--- a/frontend/src/fristenrechner.tsx
+++ b/frontend/src/fristenrechner.tsx
@@ -112,27 +112,99 @@ export function renderFristenrechner(): string {
- {/* v3 landing fork (t-paliad-133) — visible by default, hidden once
- the user picks a pathway. URL ?path= drives visibility. */}
-
-
Was möchten Sie tun?
-
-