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?

    -
    - + + + +
    +
    + + {/* Step 1 collapsed summary, shown after a pick. Mirrors the + proceeding-summary collapse pattern from 097e21c. */} + + + {/* Step 2 — Do / Happened bifurcation. Hidden until Step 1 is + satisfied. Click on a card routes to the existing Pathway A + (Verfahrensablauf wizard) or Pathway B (cascade) shells — + we keep the routing primitive in showPathway()/showBMode(). */} +