Merge: t-paliad-308 — Verfahrensablauf URL state hybrid (chips in URL, scenario in localStorage) (m/paliad#137)
Some checks failed
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled

This commit is contained in:
mAi
2026-05-26 18:46:32 +02:00
3 changed files with 770 additions and 119 deletions

View File

@@ -12,7 +12,6 @@ import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import {
type DeadlineResponse,
type Side,
calculateDeadlines,
escHtml,
formatDate,
@@ -24,10 +23,27 @@ import {
import {
attachEventCardChoices,
reseedChips,
currentChoices,
type EventChoice,
type ChoiceKind,
} from "./views/event-card-choices";
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;
@@ -119,21 +135,13 @@ const ROLE_LABELS: Record<string, RoleLabels> = {
// 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",
]);
// Five canonical appeal-target slugs (lp.AppealTargets — keep ordered
// in sync with pkg/litigationplanner/types.go AppealTargets).
const APPEAL_TARGETS = [
"endentscheidung",
"kostenentscheidung",
"anordnung",
"schadensbemessung",
"bucheinsicht",
] as const;
type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
function hasAppealTarget(proceedingType: string): boolean {
return APPEAL_TARGET_PROCEEDINGS.has(proceedingType);
}
@@ -142,16 +150,35 @@ function hasAppellantAxis(proceedingType: string): boolean {
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
}
function readSideFromURL(): Side {
const raw = new URLSearchParams(window.location.search).get("side");
return raw === "claimant" || raw === "defendant" ? raw : null;
// 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();
}
}
function writeSideToURL(s: Side) {
// 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);
if (s === null) url.searchParams.delete("side");
else url.searchParams.set("side", s);
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
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
@@ -181,26 +208,6 @@ function applyRoleLabels(proceedingType: string) {
}
}
// Slice B1 — appeal-target URL state. Empty string = no target picked
// (the row is hidden because the proceeding isn't an appeal). Any
// other value must be one of APPEAL_TARGETS; unknown values are
// rejected by readAppealTargetFromURL so a stale link can't break
// the engine filter.
function readAppealTargetFromURL(): AppealTarget {
const raw = new URLSearchParams(window.location.search).get("target") || "";
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
return raw as AppealTarget;
}
return "";
}
function writeAppealTargetToURL(t: AppealTarget) {
const url = new URL(window.location.href);
if (t === "") url.searchParams.delete("target");
else url.searchParams.set("target", t);
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
// 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
@@ -217,54 +224,18 @@ const anchorOverrides = new Map<string, string>();
function clearAnchorOverrides() { anchorOverrides.clear(); }
// Per-event-card choices (t-paliad-265). Unbound on this page (no
// project context), so persistence is URL-only via `?event_choices=`.
// Format: comma-separated `submission_code:kind=value` tuples. Same
// idiom as `?side=` + `?appellant=`.
let perCardChoices: EventChoice[] = [];
// 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);
function readChoicesFromURL(): EventChoice[] {
const raw = new URLSearchParams(window.location.search).get("event_choices");
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;
}
function writeChoicesToURL(choices: EventChoice[]) {
const url = new URL(window.location.href);
if (choices.length === 0) {
url.searchParams.delete("event_choices");
} else {
const enc = choices.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`).join(",");
url.searchParams.set("event_choices", enc);
}
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
// Show-hidden toggle state (t-paliad-290 / m/paliad#122). When ON, the
// 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.
// URL-driven via ?show_hidden=1 so a shared link or reload preserves
// the visibility. Default OFF — m's not asking to see hidden by
// default, just to be able to.
function readShowHiddenFromURL(): boolean {
return new URLSearchParams(window.location.search).get("show_hidden") === "1";
}
function writeShowHiddenToURL(on: boolean) {
const url = new URL(window.location.href);
if (on) url.searchParams.set("show_hidden", "1");
else url.searchParams.delete("show_hidden");
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
let showHidden = readShowHiddenFromURL();
// 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";
@@ -568,7 +539,7 @@ function syncInfAmendEnabled() {
if (!ccr.checked) infAmend.checked = false;
}
function selectProceeding(btn: HTMLButtonElement) {
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 || "";
@@ -578,20 +549,76 @@ function selectProceeding(btn: HTMLButtonElement) {
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();
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
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.
@@ -606,7 +633,7 @@ function syncAppealTargetRowVisibility() {
row.style.display = visible ? "" : "none";
if (!visible && currentAppealTarget !== "") {
currentAppealTarget = "";
writeAppealTargetToURL("");
applyURLFilters({ target: "" });
syncRadioGroup("appeal-target", "endentscheidung");
}
}
@@ -720,11 +747,11 @@ function showSideRadioCluster() {
// 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 (readSideFromURL() !== null) return;
if (parseSideFromSearch(window.location.search) !== null) return;
const next = ourSideToSide(os);
if (next === null) return;
currentSide = next;
writeSideToURL(next);
applyURLFilters({ side: next });
syncRadioGroup("side", next);
sidePrefilledFromProject = true;
renderSideChip(next);
@@ -793,8 +820,8 @@ function initViewToggle() {
// /api/tools/fristenrechner round-trip — perspective is a pure
// projection of the last response, no backend involved.
function initPerspectiveControls() {
currentSide = readSideFromURL();
currentAppealTarget = readAppealTargetFromURL();
currentSide = parseSideFromSearch(window.location.search);
currentAppealTarget = parseAppealTargetFromSearch(window.location.search);
syncRadioGroup("side", currentSide ?? "");
syncRadioGroup("appeal-target", currentAppealTarget || "endentscheidung");
syncSideHintVisibility();
@@ -804,7 +831,7 @@ function initPerspectiveControls() {
if (!input.checked) return;
const v = input.value;
currentSide = (v === "claimant" || v === "defendant") ? v : null;
writeSideToURL(currentSide);
applyURLFilters({ side: currentSide });
syncSideHintVisibility();
if (lastResponse) renderResults(lastResponse);
});
@@ -822,7 +849,7 @@ function initPerspectiveControls() {
} else {
currentAppealTarget = "";
}
writeAppealTargetToURL(currentAppealTarget);
applyURLFilters({ target: currentAppealTarget });
scheduleCalc(0);
});
});
@@ -844,28 +871,57 @@ document.addEventListener("DOMContentLoaded", () => {
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
if (dateInput) {
dateInput.addEventListener("change", () => scheduleCalc());
dateInput.addEventListener("input", () => scheduleCalc());
// 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 <input> 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") scheduleCalc(0);
if ((e as KeyboardEvent).key === "Enter") { persistDate(); scheduleCalc(0); }
});
}
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0));
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.
// 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);
scheduleCalc(0);
});
(["inf-amend-flag", "rev-amend-flag", "rev-cci-flag"]).forEach((id) => {
const flagStorageKeys: Record<string, string> = {
"inf-amend-flag": SCENARIO_KEYS.infAmend,
"rev-amend-flag": SCENARIO_KEYS.revAmend,
"rev-cci-flag": SCENARIO_KEYS.revCci,
};
for (const [id, storageKey] of Object.entries(flagStorageKeys)) {
const cb = document.getElementById(id) as HTMLInputElement | null;
if (cb) cb.addEventListener("change", () => scheduleCalc(0));
});
if (cb) cb.addEventListener("change", () => {
writeBoolFlag(scenarioStorage, storageKey, cb.checked);
scheduleCalc(0);
});
}
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
@@ -909,16 +965,17 @@ document.addEventListener("DOMContentLoaded", () => {
});
}
// t-paliad-290 — show-hidden toggle. Hydrate from URL, wire change
// to URL + 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).
// 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;
writeShowHiddenToURL(showHidden);
writeBoolFlag(scenarioStorage, SCENARIO_KEYS.showHidden, showHidden);
scheduleCalc(0);
});
}
@@ -926,11 +983,10 @@ document.addEventListener("DOMContentLoaded", () => {
initViewToggle();
initPerspectiveControls();
// t-paliad-265 — per-event-card choices. Unbound surface, so commits
// mutate the in-memory list + URL, then trigger a recalc. The
// popover module owns the popover lifecycle; this page owns the
// recalc + URL plumbing.
perCardChoices = readChoicesFromURL();
// 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({
@@ -941,14 +997,14 @@ document.addEventListener("DOMContentLoaded", () => {
(c) => !(c.submission_code === choice.submission_code && c.choice_kind === choice.choice_kind),
);
perCardChoices.push(choice);
writeChoicesToURL(perCardChoices);
writeEventChoices(scenarioStorage, perCardChoices);
scheduleCalc(0);
},
remove: (submissionCode, kind) => {
perCardChoices = perCardChoices.filter(
(c) => !(c.submission_code === submissionCode && c.choice_kind === kind),
);
writeChoicesToURL(perCardChoices);
writeEventChoices(scenarioStorage, perCardChoices);
scheduleCalc(0);
},
});
@@ -984,8 +1040,31 @@ document.addEventListener("DOMContentLoaded", () => {
syncTriggerEventLabel();
});
// Pre-select the first proceeding tile so users see a timeline
// immediately on landing — matches /tools/fristenrechner behaviour.
const firstBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
if (firstBtn) selectProceeding(firstBtn);
// 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<HTMLButtonElement>(
`.proceeding-btn[data-code="${urlProceeding.replace(/"/g, '\\"')}"]`,
);
urlHit = initialBtn !== null;
}
if (!initialBtn) {
initialBtn = document.querySelector<HTMLButtonElement>(".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 });
}
});

View File

@@ -0,0 +1,309 @@
// 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");
});
});

View File

@@ -0,0 +1,263 @@
// /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<string, string>();
return {
getItem: (k) => (store.has(k) ? store.get(k)! : null),
setItem: (k, v) => { store.set(k, v); },
removeItem: (k) => { store.delete(k); },
};
}