Compare commits

...

7 Commits

Author SHA1 Message Date
mAi
43de8f9c7b feat(verfahrensablauf): URL state hybrid — filter chips in URL, scenario in localStorage (t-paliad-308, m/paliad#137)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Splits /tools/verfahrensablauf persisted state into two namespaces:

URL params (timeline kind — paste-able, shareable, refresh-resistant):
  proceeding, side, target, trigger_date

localStorage `paliad.verfahrensablauf.scenario.*` (per-user tweaks
that should never leak into a shared link):
  event_choices, court_id, ccr, inf_amend, rev_amend, rev_cci,
  show_hidden

Hydration order: URL wins. localStorage fills the rest. A shared link
reproduces the timeline kind but each user sees their own scenario
state.

Added trigger_date and proceeding to URL (previously DOM-only — a
refresh lost the date and the proceeding tile). Moved event_choices
and show_hidden from URL to localStorage (verbose, per-user). Added
court_id + flag persistence to localStorage (previously DOM-only).

New pure module `views/verfahrensablauf-state.ts` owns the URL +
localStorage contract: URL parsers + encoder (`applyFiltersToSearch`),
scenario read/write helpers, and a `hydrate()` orchestrator that
documents the URL→localStorage order. 31 unit tests pin the contract,
including the "shared link doesn't leak scenario state" invariant.

Anti-patterns explicitly avoided:
- No ?appellant= resurrection (#132 removed it; engine reads from
  the single side picker for role-swap proceedings).
- trigger_date in URL not localStorage (a shared link must reproduce
  the same dated timeline).
- URL→localStorage hydration order is contract; localStorage never
  overrides an explicit URL value.

Project-driven side-fill chip (?project=<id>) still overrides as
before — parseSideFromSearch is called before the project's our_side
is applied so an explicit ?side= still wins.

Build clean: `bun run build`, `bun test` (240 pass / 594 expect calls),
`go test ./...`, `go vet ./...`.
2026-05-26 18:45:00 +02:00
mAi
b6c2df95cc Merge: t-paliad-307 — Verfahrensablauf appeal mode fixes (side filter + synthetic trigger row + duration label + notes dedup) (m/paliad#136)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 17:57:39 +02:00
mAi
367627af0d fix(verfahrensablauf): appeal side filter + parent in duration label + notes dedup (t-paliad-307, m/paliad#136)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Frontend half of the four Verfahrensablauf appeal bugs.

Bug 1 (frontend half) — Side selector dead on appeal. The column
bucketer now reads dl.appealRole (engine-stamped under
appeal_target) and routes each "both" appeal rule via the user
side: side=claimant maps the user to the appellant, so appellant
filings land in 'ours' and appellee filings in 'opponent';
side=defendant mirrors. side=null keeps the legacy mirror so every
appeal rule renders in both columns (every-rule-visible behaviour
the brief calls out). The new appealAware opt gates the path so
non-appeal proceedings keep their existing bucketing untouched.

Removed upc.apl.unified from APPELLANT_AXIS_PROCEEDINGS — appeal
routing is now per-rule via appealRole, not a page-level appellant
collapse. Other role-swap proceedings (EPA opp, DE/DPMA appeals)
keep the appellant axis since they have no appeal_target metadata.

Bug 3 — Duration label appends parent name. formatDurationLabel now
takes an optional parent fallback and renders "<n> <unit> <timing>
<parent>". deadlineCardHtml resolves the parent per-rule
(dl.parentRuleName / EN variant), falling back to opts.trigger
EventLabel for root rules with a non-zero duration (e.g.
Berufungseinlegung 2 mo. after the Endentscheidung). renderColumns
Body + renderTimelineBody auto-derive the trigger event label from
the response via the new pickTriggerEventLabel helper unless the
caller passes one explicitly.

Bug 4 — Duration prefix stripped from deadline_notes. New
stripLeadingDurationFromNotes regex peels off leading
"Frist N <unit> <vor|nach|ab|seit> …. " (DE) and
"<N>-<unit> period from …" / "N <unit> BEFORE …" / "Period is N
<unit> from …" (EN) up to the first sentence boundary. Wired into
deadlineCardHtml so noteHint + notesBlock both render the deduped
text. Per the brief's option (a): conservative regex, composite
durations with "ODER" / "whichever is the longer" stay untouched
as a follow-up editorial cleanup. deadline_rules DB untouched.

Tests: 22 new test cases across appeal-aware bucketing,
formatDurationLabel parent append, deadlineCardHtml duration
tooltip resolution, and stripLeadingDurationFromNotes regex
(positive + negative + composite + EN/DE variants). All 209
frontend tests pass.

Engine wire fields added in the preceding commit (AppealRole,
IsTriggerEvent). Reads them from CalculatedDeadline without
breaking the wire contract for non-appeal callers.
2026-05-26 17:56:32 +02:00
mAi
7d7b20651d feat(litigationplanner): appeal-target synthetic trigger row + appeal-role stamping (t-paliad-307, m/paliad#136)
Engine side of the four Verfahrensablauf appeal bugs in m/paliad#136.

Bug 2 — Missing trigger event row. When CalcOptions.AppealTarget is set,
Calculate now prepends a synthetic TimelineEntry to the deadlines slice
dated to the trigger date, carrying the per-appeal-target label from
TriggerEventLabelForAppealTarget (Endentscheidung (R.118), Kosten-
entscheidung, Anordnung, Schadensbemessung, Bucheinsicht). Marked
IsRootEvent + IsTriggerEvent + party=court + priority=informational
so the frontend renders it as a dimmed anchor card without a save
button / choices caret / click-to-edit affordance. Empty Code so it
doesn't collide with real rule UUIDs downstream.

Bug 1 (engine half) — Side selector dead on appeal. Every appeal
filing rule carries primary_party='both' in the catalog, so the
column bucketer couldn't distinguish Berufungskläger vs Berufungs-
beklagter filings from primary_party alone. Engine now stamps the
new TimelineEntry.AppealRole field with appellant/appellee from the
rule-semantic AppealFilerRole mapping (appeal_role.go) when an
appeal_target is in scope. The frontend half of the fix (next commit)
consumes this to route each "both" rule into the user-perspective
column once the user picks a side.

Mapping covers all 12 appeal filing rules across the three
applies_to_target tracks (endentscheidung/schadensbemessung,
kostenentscheidung, anordnung/bucheinsicht). Court-issued events
(merits.decision, merits.oral, cost.decision, order.order) stay
empty — they continue to route on Party='court'. Unmapped
submission_codes return empty so a new appeal rule we forgot to map
falls through to the bucketer's legacy path rather than silently
picking a side.

Tests: TestAppealFilerRole pins the mapping; TestCalculate_Appeal
SyntheticTriggerRow covers (a) synthetic row prepended + AppealRole
stamped when target is set, (b) no synthetic row + no AppealRole
when target is unset (regression guard), (c) unknown target
short-circuits to no-op. Existing tests untouched — both behaviours
gate on opts.AppealTarget != "".

No DB migration — the bugs are calc-side. deadline_rules untouched.
2026-05-26 17:56:12 +02:00
mAi
8f1a287549 Merge: t-paliad-305 — Slice B.2: dual-write to deadline_rules + procedural_events/sequencing_rules/legal_sources (m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 17:50:57 +02:00
mAi
3b601f156b Merge: t-paliad-306 — Slice D: paliad.scenarios + Catalog API + engine adapter (mig 145) (m/paliad#124 §5)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 17:49:36 +02:00
mAi
cd5f752a0e feat(litigationplanner): scenarios — paliad.scenarios jsonb table + Catalog API + engine adapter (Slice D, t-paliad-306, m/paliad#124 §5)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
A scenario is a named composition of existing proceedings + flags +
per-card choices + anchor dates. Users compose, they don't author —
spec references existing rules by submission_code; never creates new
rules. Per m's 2026-05-26 AskUserQuestion picks (doc commit 6e58595):
  Q1 composition: primary + spawned (v1); multi-proceeding peer
                  compose is the v2 goal (spec.proceedings[] array)
  Q2 scope:       per-project + abstract (project_id NULL = abstract)
  Q3 trigger:     per-anchor overrides over one base date
  Q4 storage:     NEW paliad.scenarios table with jsonb spec
                  (NOT a project_event_choices column extension)

Migration 145 — additive only. Pre-flight coordination check:
  - On-disk max: 138 (Berufung backfill, just merged).
  - Live DB tracker: 106 (significantly behind — many migs pending
    deploy).
  - curie's #93 B.2-B.6 migs not pushed yet — reserved 139-143 + 144
    as buffer; claimed 145 as the safe minimum that won't collide.
  - paliad.scenarios has audit_reason NOT applicable (no audit
    trigger on the table); updated_at trigger added on the table
    itself.
  - paliad.projects gains active_scenario_id uuid NULL FK with ON
    DELETE SET NULL (mig 134 lesson — no updated_at clauses on
    proceeding_types-style assumptions).

Schema:
  paliad.scenarios (
    id uuid pk,
    project_id uuid NULL FK → projects(id) ON DELETE CASCADE,
    name text NOT NULL CHECK char_length > 0,
    description text NULL,
    spec jsonb NOT NULL CHECK jsonb_typeof = 'object',
    created_by uuid NULL FK → users(id) ON DELETE SET NULL,
    created_at + updated_at timestamptz,
    UNIQUE NULLS NOT DISTINCT (project_id, created_by, name)
  );
  paliad.projects.active_scenario_id uuid NULL FK;
  RLS: project-scoped → can_see_project; abstract → created_by = auth.uid();
  Trigger: scenarios_touch_updated_at_trg.

pkg/litigationplanner additions:
  - Scenario struct (db + json tags)
  - ScenarioSpec / ScenarioProceeding / ScenarioCardChoice — parsed
    view of the jsonb (version-1 today, v2 multi-peer-ready)
  - ParseSpec(raw) + ScenarioSpec.PrimaryProceeding() + CalcOptionsFromSpec()
  - ScenarioFilter + Catalog.LoadScenarios + Catalog.MatchScenario
  - CalculateFromScenario(scenario, catalog, holidays, courts) — high-
    level engine entry: parses spec → builds CalcOptions → delegates
    to Calculate
  - Sentinel errors: ErrUnknownScenario, ErrInvalidScenario,
    ErrScenarioNoPrimary

paliadCatalog impl:
  - LoadScenarios with progressively-built WHERE clauses (project-id
    filter, abstract-for-user filter, or all)
  - MatchScenario by id — returns ErrUnknownScenario on not-found
  - Services connection bypasses RLS; ScenarioService enforces
    visibility at the application layer (mirrors EventChoiceService
    pattern from t-paliad-265)

SnapshotCatalog impl (embedded/upc):
  - LoadScenarios returns empty slice (no scenarios in the snapshot)
  - MatchScenario returns ErrUnknownScenario

internal/services/scenario_service.go:
  - Create / Get / ListForProject / ListAbstractForUser / Patch /
    SetActive / Delete with visibility checks
  - validateSpec checks version, base_trigger_date format, every
    proceedings[*].code resolves to an active paliad.proceeding_types
    row, every appeal_target is valid, every anchor_overrides date
    parses, every role ∈ {primary, peer}
  - SetActive validates the scenario belongs to the requested project
    (a scenario from a different project can't be active here)
  - Returns ErrScenarioNotVisible for failed visibility checks

REST endpoints (registered in handlers.go):
  GET    /api/scenarios?project=<id>             — list project's
  GET    /api/scenarios?abstract=true            — list user's abstract
  GET    /api/scenarios/{id}                     — one
  POST   /api/scenarios                          — create
  PATCH  /api/scenarios/{id}                     — partial update
  DELETE /api/scenarios/{id}                     — remove
  PUT    /api/projects/{id}/active-scenario      — set / clear active

Handler error mapping:
  - ErrUnknownScenario / ErrScenarioNotVisible → 404
  - ErrInvalidInput / ErrInvalidScenario / ErrScenarioNoPrimary → 400
  - everything else → 500

Tests:
  - pkg/litigationplanner/scenarios_test.go: ParseSpec roundtrip
    (well-formed + unknown version + malformed json),
    PrimaryProceeding zero/multi/single, CalcOptionsFromSpec full
    unpack, trigger_date_override path, no-base-trigger safety check.
    8 cases total, all DB-free.

Wired in cmd/server/main.go alongside EventChoice — same pattern,
nil-safe when DATABASE_URL is unset (handlers 503 in that mode).

Acceptance:
  - go build ./... clean
  - go test ./... all green (incl. new scenarios tests)
  - Pre-flight audit confirmed mig 145 number is safe vs curie's
    pending B.2-B.6 range
2026-05-26 17:48:56 +02:00
22 changed files with 2840 additions and 136 deletions

View File

@@ -222,6 +222,8 @@ func main() {
Export: services.NewExportService(pool, branding.Name),
// t-paliad-265 / m/paliad#96 — per-event-card optional choices.
EventChoice: services.NewEventChoiceService(pool, projectSvc, users),
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions.
Scenario: services.NewScenarioService(pool, projectSvc, rules),
}
// t-paliad-246 Slice A — Backup Mode runner. Wired only when

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;
@@ -61,8 +77,14 @@ let sidePrefilledFromProject = false;
// chosen side's column). For first-instance proceedings (Inf, Rev,
// …) the side picker still narrows columns but doesn't collapse
// the "both" rows.
//
// upc.apl.unified is NOT in this set since t-paliad-307: appeal
// timelines route via per-rule appealRole (engine-stamped under
// appeal_target) instead of the page-level appellant axis collapse.
// Adding upc.apl.unified here would short-circuit the appealAware
// path and re-introduce the dead side selector on upc.apl.unified
// (m/paliad#136 Bug 1).
const APPELLANT_AXIS_PROCEEDINGS = new Set([
"upc.apl.unified",
"de.inf.olg",
"de.inf.bgh",
"de.null.bgh",
@@ -113,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);
}
@@ -136,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
@@ -175,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
@@ -211,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";
@@ -505,6 +482,12 @@ function renderResults(data: DeadlineResponse) {
// in the picked side's column). For non-role-swap proceedings,
// the appellant axis is irrelevant — pass null.
appellant: hasAppellantAxis(selectedType) ? currentSide : null,
// Appeal-target proceedings get per-rule appealRole routing
// instead of the page-level appellant collapse, so the side
// selector actually splits Berufungskläger vs Berufungs-
// beklagter filings across columns. (t-paliad-307 /
// m/paliad#136 Bug 1)
appealAware: hasAppealTarget(selectedType),
})
: renderTimelineBody(data, { showParty: true, editable: true, showNotes, showDurations });
@@ -556,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 || "";
@@ -566,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.
@@ -594,7 +633,7 @@ function syncAppealTargetRowVisibility() {
row.style.display = visible ? "" : "none";
if (!visible && currentAppealTarget !== "") {
currentAppealTarget = "";
writeAppealTargetToURL("");
applyURLFilters({ target: "" });
syncRadioGroup("appeal-target", "endentscheidung");
}
}
@@ -708,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);
@@ -781,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();
@@ -792,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);
});
@@ -810,7 +849,7 @@ function initPerspectiveControls() {
} else {
currentAppealTarget = "";
}
writeAppealTargetToURL(currentAppealTarget);
applyURLFilters({ target: currentAppealTarget });
scheduleCalc(0);
});
});
@@ -832,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());
@@ -897,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);
});
}
@@ -914,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({
@@ -929,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);
},
});
@@ -972,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

@@ -4,7 +4,9 @@ import {
type DeadlineResponse,
bucketDeadlinesIntoColumns,
deadlineCardHtml,
formatDurationLabel,
renderColumnsBody,
stripLeadingDurationFromNotes,
} from "./verfahrensablauf-core";
// Regression tests for the editable→click-to-edit wiring on timeline date
@@ -487,3 +489,287 @@ describe("renderColumnsBody — side-aware column header labels (m/paliad#127)",
expect(html).not.toContain(">Reaktiv<");
});
});
// t-paliad-307 / m/paliad#136 Bug 1 — appeal-aware column routing.
// All appeal rules carry party='both' (either side could be the
// appellant). With appealAware=true + dl.appealRole set, the bucketer
// routes by (filer matches user) instead of collapsing every 'both'
// row into the user's column. Without a side picked, the bucketer
// keeps the legacy mirror so every appeal rule is visible.
describe("bucketDeadlinesIntoColumns — appeal-aware routing (t-paliad-307)", () => {
const appeal = (
name: string,
role: "appellant" | "appellee",
due: string,
): CalculatedDeadline => ({
code: name,
name,
nameEN: name,
party: "both",
priority: "mandatory",
ruleRef: "",
dueDate: due,
originalDate: due,
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
appealRole: role,
});
const notice = appeal("Berufungseinlegung", "appellant", "2026-07-26");
const grounds = appeal("Berufungsbegründung", "appellant", "2026-09-26");
const response = appeal("Berufungserwiderung", "appellee", "2026-12-26");
test("appealAware + side=claimant: appellant rules → ours, appellee rules → opponent", () => {
const rows = bucketDeadlinesIntoColumns([notice, grounds, response], {
side: "claimant",
appealAware: true,
});
const byKey = new Map(rows.map((r) => [r.key, r]));
expect(byKey.get(notice.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
expect(byKey.get(notice.dueDate)?.opponent).toHaveLength(0);
expect(byKey.get(response.dueDate)?.ours).toHaveLength(0);
expect(byKey.get(response.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
});
test("appealAware + side=defendant: appellant rules → opponent, appellee rules → ours", () => {
const rows = bucketDeadlinesIntoColumns([notice, response], {
side: "defendant",
appealAware: true,
});
const byKey = new Map(rows.map((r) => [r.key, r]));
expect(byKey.get(notice.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
expect(byKey.get(notice.dueDate)?.ours).toHaveLength(0);
expect(byKey.get(response.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
expect(byKey.get(response.dueDate)?.opponent).toHaveLength(0);
});
test("appealAware + side=null: mirror to both columns (every rule visible)", () => {
const rows = bucketDeadlinesIntoColumns([notice, response], {
side: null,
appealAware: true,
});
const byKey = new Map(rows.map((r) => [r.key, r]));
expect(byKey.get(notice.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
expect(byKey.get(notice.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
expect(byKey.get(response.dueDate)?.ours.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
expect(byKey.get(response.dueDate)?.opponent.map((d) => d.name)).toEqual(["Berufungserwiderung"]);
});
test("appealAware off: appealRole is ignored and legacy bucketing applies", () => {
// Regression guard: a stale frontend that drops `appealAware: true`
// must not silently route via appealRole — the side selector
// would visibly change behaviour without a UI control to opt in.
const rows = bucketDeadlinesIntoColumns([notice, response], { side: "defendant" });
// Legacy "side without appellant" collapse → both rows into ours.
const allOurs = rows.flatMap((r) => r.ours.map((d) => d.name));
expect(allOurs).toEqual(["Berufungseinlegung", "Berufungserwiderung"]);
rows.forEach((r) => expect(r.opponent).toHaveLength(0));
});
test("appealAware respects court party — court rows always route to court column", () => {
const decision: CalculatedDeadline = {
...notice,
name: "Entscheidung",
party: "court",
appealRole: "", // court events deliberately stay empty
dueDate: "",
};
const rows = bucketDeadlinesIntoColumns([decision], { side: "claimant", appealAware: true });
expect(rows[0].court.map((d) => d.name)).toEqual(["Entscheidung"]);
expect(rows[0].ours).toHaveLength(0);
expect(rows[0].opponent).toHaveLength(0);
});
test("appealAware + rule without appealRole falls back to legacy bucketing", () => {
// A future appeal rule we forgot to map: appealRole='' falls
// through the appealAware branch and lands in the legacy
// side-collapse path → ours.
const unmapped: CalculatedDeadline = { ...notice, appealRole: "" };
const rows = bucketDeadlinesIntoColumns([unmapped], { side: "claimant", appealAware: true });
expect(rows[0].ours.map((d) => d.name)).toEqual(["Berufungseinlegung"]);
expect(rows[0].opponent).toHaveLength(0);
});
});
// t-paliad-307 / m/paliad#136 Bug 3 — duration label appends the
// parent rule name (or the proceeding's trigger event label for
// root rules) so the chip reads "4 Monate nach Endentscheidung"
// instead of the dangling "4 Monate nach".
describe("formatDurationLabel — appends parent name (t-paliad-307)", () => {
const dl = (overrides: Partial<CalculatedDeadline> = {}): CalculatedDeadline => ({
code: "x",
name: "x",
nameEN: "x",
party: "both",
priority: "mandatory",
ruleRef: "",
dueDate: "",
originalDate: "",
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
durationValue: 4,
durationUnit: "months",
timing: "after",
...overrides,
});
test("with parent label: appends to head", () => {
expect(formatDurationLabel(dl(), "Endentscheidung (R.118)"))
.toBe("4 Monate nach Endentscheidung (R.118)");
});
test("without parent label: bare head — caller decides whether to render", () => {
expect(formatDurationLabel(dl())).toBe("4 Monate nach");
});
test("without timing: parent is not appended (degenerate phrasing)", () => {
// No timing == we can't form "4 Monate <timing> <parent>" cleanly,
// so the bare "4 Monate" head stays. Pinned to catch a future
// edit that would emit "4 Monate Endentscheidung" without a
// preposition.
expect(formatDurationLabel(dl({ timing: "" }), "Endentscheidung")).toBe("4 Monate");
});
test("singular value: switches to .one unit key", () => {
expect(formatDurationLabel(dl({ durationValue: 1 }), "X")).toBe("1 Monat nach X");
});
test("zero / missing duration: empty string", () => {
expect(formatDurationLabel(dl({ durationValue: 0 }), "X")).toBe("");
expect(formatDurationLabel(dl({ durationValue: 0, durationUnit: "" }), "X")).toBe("");
});
});
describe("deadlineCardHtml — duration tooltip reads parent name (t-paliad-307)", () => {
test("root rule with non-zero duration uses opts.triggerEventLabel as parent fallback", () => {
// upc.apl.merits.notice has no parent_id but a 2-month duration
// off the trigger event (the appealed decision). The duration
// tooltip must read the appeal-target label, not just "2 Monate
// nach".
const dl: CalculatedDeadline = {
code: "upc.apl.merits.notice",
name: "Berufungseinlegung",
nameEN: "Notice of Appeal",
party: "both",
priority: "mandatory",
ruleRef: "",
dueDate: "2026-07-26",
originalDate: "2026-07-26",
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
durationValue: 2,
durationUnit: "months",
timing: "after",
};
const html = deadlineCardHtml(dl, {
showParty: false,
editable: true,
triggerEventLabel: "Endentscheidung (R.118)",
});
expect(html).toContain("title=\"2 Monate nach Endentscheidung (R.118)\"");
});
test("non-root rule prefers parent rule name over triggerEventLabel", () => {
// merits.response chains off merits.grounds; the duration label
// should read "3 Monate nach Berufungsbegründung", not the
// appeal-target fallback.
const dl: CalculatedDeadline = {
code: "upc.apl.merits.response",
name: "Berufungserwiderung",
nameEN: "Response to Appeal",
party: "both",
priority: "mandatory",
ruleRef: "",
dueDate: "2026-12-26",
originalDate: "2026-12-26",
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
durationValue: 3,
durationUnit: "months",
timing: "after",
parentRuleCode: "upc.apl.merits.grounds",
parentRuleName: "Berufungsbegründung",
parentRuleNameEN: "Statement of Grounds",
};
const html = deadlineCardHtml(dl, {
showParty: false,
editable: true,
triggerEventLabel: "Endentscheidung (R.118)",
});
expect(html).toContain("title=\"3 Monate nach Berufungsbegründung\"");
});
});
// t-paliad-307 / m/paliad#136 Bug 4 — leading "Frist N <unit> …"
// substring is stripped before deadline_notes renders so the new
// duration affordance and the legacy free-text don't duplicate.
describe("stripLeadingDurationFromNotes — render-side dedup (t-paliad-307)", () => {
test("DE: strips 'Frist 1 Monat VOR …. ' and keeps the rest", () => {
const out = stripLeadingDurationFromNotes(
"Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag auf Simultanübersetzung.",
"de",
);
expect(out).toBe("Antrag auf Simultanübersetzung.");
});
test("DE: strips 'Frist 15 Tage ab …' when the whole notes is the duration prose", () => {
const out = stripLeadingDurationFromNotes(
"Frist 15 Tage ab Zustellung der Kostenentscheidung",
"de",
);
expect(out).toBe("");
});
test("DE: strips 'Frist beträgt 2 Monate ab …. ' (Wiedereinsetzung variant)", () => {
const out = stripLeadingDurationFromNotes(
"Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens 1 Jahr.",
"de",
);
expect(out).toBe("Spätestens 1 Jahr.");
});
test("DE: composite 'Frist N … ODER M …' is preserved (option b follow-up)", () => {
const composite =
"Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung der einstweiligen Maßnahme.";
expect(stripLeadingDurationFromNotes(composite, "de")).toBe(composite);
});
test("DE: 'Frist vom Gericht' (no number) is preserved", () => {
const out = stripLeadingDurationFromNotes("Frist vom Gericht bestimmt", "de");
expect(out).toBe("Frist vom Gericht bestimmt");
});
test("EN: strips '1 month BEFORE …. ' and keeps the rest", () => {
const out = stripLeadingDurationFromNotes(
"1 month BEFORE the oral hearing (R.109.1). Request for simultaneous interpretation.",
"en",
);
expect(out).toBe("Request for simultaneous interpretation.");
});
test("EN: strips '15-day period from …'", () => {
const out = stripLeadingDurationFromNotes(
"15-day period from service of the cost decision",
"en",
);
expect(out).toBe("");
});
test("EN: strips 'Period is N <unit> from …'", () => {
const out = stripLeadingDurationFromNotes(
"Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest 12 months.",
"en",
);
expect(out).toBe("Latest 12 months.");
});
test("EN: empty / non-matching notes pass through unchanged", () => {
expect(stripLeadingDurationFromNotes("", "en")).toBe("");
expect(stripLeadingDurationFromNotes("Time limit set by the court", "en"))
.toBe("Time limit set by the court");
});
});

View File

@@ -104,19 +104,92 @@ export interface CalculatedDeadline {
durationValue?: number;
durationUnit?: string;
timing?: string;
// appealRole carries the rule's appeal-filer identity when the
// server computed the timeline under an appeal_target filter:
// "appellant" (Berufungskläger files this rule), "appellee"
// (Berufungsbeklagter files this rule), or empty for court events
// and non-appeal timelines. The column bucketer reads this in
// preference to primary_party='both' so a user-perspective `?side=`
// pick can split appeal filings into the user's column vs the
// opponent's, instead of routing every "both" rule into the
// user's column. (t-paliad-307 / m/paliad#136 Bug 1)
appealRole?: "appellant" | "appellee" | "";
// isTriggerEvent marks the synthetic row the engine prepends to the
// timeline when computing an appeal: a court-set decision dated to
// the trigger date with the per-appeal-target label
// (Endentscheidung / Kostenentscheidung / Anordnung / …). The row
// carries no real rule_id — it's a UI marker so the timeline reads
// decision → appeal filings → next decision. (t-paliad-307 /
// m/paliad#136 Bug 2)
isTriggerEvent?: boolean;
}
// formatDurationLabel renders the per-rule duration ("2 Mo. nach") for
// the Verfahrensablauf card affordance (m/paliad#133, t-paliad-302).
// Returns empty string for rules without a usable duration so the
// caller can skip the tooltip / inline span entirely.
// stripLeadingDurationFromNotes drops the leading
// "Frist N <unit> <preposition> <subject>." (DE) /
// "N <unit> <preposition> <subject>." (EN) prefix from a rule's
// deadline_notes so it doesn't duplicate the new duration affordance
// added in m/paliad#133 (t-paliad-307 Bug 4).
//
// Pluralisation key naming mirrors the Fristenrechner event-mode
// renderer (deadlines.event.unit.<unit>.{one,many}) — the unit and
// timing translations already exist for /tools/fristenrechner's
// "Was kommt nach…" mode and are reused here as the single
// source of truth.
export function formatDurationLabel(dl: CalculatedDeadline): string {
// The duration affordance now renders the same prose as a badge on
// the card ("4 Monate nach Endentscheidung (R.118)"); a free-text
// notes string that opens with the same prose reads as a verbatim
// duplicate. Only the leading-prefix shape is stripped — anything
// after the first sentence is preserved (the editorial commentary
// the lawyers actually want to read).
//
// Conservative: composite-duration prefaces with "ODER" /
// "whichever is the longer" don't match and stay untouched — those
// are the follow-up editorial cleanup (option b in the issue brief).
//
// Examples:
// "Frist 1 Monat VOR der mündlichen Verhandlung (R.109.1). Antrag …"
// → "Antrag …"
// "Frist 15 Tage ab Zustellung der Kostenentscheidung"
// → ""
// "Frist beträgt 2 Monate ab Wegfall des Hindernisses (§ 123(2) PatG). Spätestens …"
// → "Spätestens …"
// "1-month period from service of the main decision"
// → ""
// "1 month BEFORE the oral hearing (R.109.1). Request for …"
// → "Request for …"
// "Period is 2 months from removal of the obstacle (Rule 136(1) EPC). Latest …"
// → "Latest …"
// "Frist 31 Kalendertage ODER 20 Arbeitstage (jeweils das längere) ab Anordnung …"
// → unchanged (composite — option b follow-up)
export function stripLeadingDurationFromNotes(notes: string, lang: "de" | "en"): string {
if (!notes) return notes;
// Terminator `(?:\.\s+|$)` matches the FIRST sentence boundary
// (period followed by whitespace) OR end of input. Embedded dots
// inside parenthesised citations (R.109.1, § 123(2), Rule 136(1))
// are skipped because the char right after them isn't whitespace.
// `[^]*?` is the JS-portable form of `.*?` with the dotAll flag —
// any character including newlines, non-greedy.
const re = lang === "en"
? /^(?:Period\s+is\s+)?\d+(?:[-\s]\S+)?\s+(?:\S+\s+)?(?:before|from|after|since)\b[^]*?(?:\.\s+|$)/i
: /^Frist\s+(?:beträgt\s+)?\d+\s+\S+\s+(?:VOR|vor|nach|ab|seit)\b[^]*?(?:\.\s+|$)/;
return notes.replace(re, "");
}
// formatDurationLabel renders the per-rule duration label for the
// Verfahrensablauf card affordance: "2 Monate nach Endentscheidung",
// "1 Monat vor Mündlicher Verhandlung", …
// (m/paliad#133, t-paliad-302; parent-name append: t-paliad-307 /
// m/paliad#136 Bug 3).
//
// Returns empty string for rules without a usable duration so the
// caller can skip the tooltip / inline span entirely. Pluralisation
// key naming mirrors the Fristenrechner event-mode renderer
// (deadlines.event.unit.<unit>.{one,many}) — the unit and timing
// translations already exist for /tools/fristenrechner's
// "Was kommt nach…" mode and are reused here as the single source
// of truth.
//
// `parentLabel` is the rule's anchor name (parent rule's name when
// the rule has a parent_id; otherwise the proceeding's
// triggerEventLabel from the wire). Empty falls back to bare
// "<n> <unit> <timing>" — bare phrasing is the pre-fix shape and
// remains the default for fixtures / tests that omit a parent.
export function formatDurationLabel(dl: CalculatedDeadline, parentLabel: string = ""): string {
const value = dl.durationValue ?? 0;
const unit = dl.durationUnit || "";
if (value <= 0 || !unit) return "";
@@ -124,7 +197,9 @@ export function formatDurationLabel(dl: CalculatedDeadline): string {
const unitStr = tDyn(unitKey);
const timing = dl.timing || "";
const timingStr = timing ? tDyn(`deadlines.event.timing.${timing}`) : "";
return timingStr ? `${value} ${unitStr} ${timingStr}` : `${value} ${unitStr}`;
const head = timingStr ? `${value} ${unitStr} ${timingStr}` : `${value} ${unitStr}`;
if (!timingStr || !parentLabel) return head;
return `${head} ${parentLabel}`;
}
// priorityRendering returns the per-priority UX hints the save-modal
@@ -363,16 +438,34 @@ export interface CardOpts {
// flips this and re-renders; persisted via the localStorage key
// `paliad.verfahrensablauf.durations-show`. Default false.
showDurations?: boolean;
// triggerEventLabel: per-language label of the proceeding's anchor
// event ("Endentscheidung (R.118)" for an Endentscheidung appeal;
// "Klageerhebung" for upc.inf.cfi; …). Used by formatDurationLabel
// as the parent-name fallback when a rule is a root rule (no
// parent_id) but carries a non-zero duration — e.g. the
// Berufungseinlegung 2 months after Endentscheidung. Pages pass the
// already-language-resolved string. (t-paliad-307 / m/paliad#136
// Bug 3)
triggerEventLabel?: string;
}
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
const wantsEditable = !!opts.editable;
const editable = wantsEditable && !dl.isRootEvent && dl.code !== "";
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
// Parent name for the duration label (t-paliad-307 / m/paliad#136
// Bug 3): use the rule's parent if set, else fall back to the
// proceeding's trigger event label (e.g. "Endentscheidung (R.118)"
// for an Endentscheidung appeal; "Klageerhebung" for upc.inf.cfi).
// Empty for rules whose anchor isn't surface-able — the duration
// label degrades to the bare "<n> <unit> <timing>" form in that case.
const parentLabelForDuration = (getLang() === "en"
? (dl.parentRuleNameEN || dl.parentRuleName)
: (dl.parentRuleName || dl.parentRuleNameEN)) || opts.triggerEventLabel || "";
// Duration affordance (m/paliad#133, t-paliad-302). Computed once so
// both the date-span tooltip and the inline meta-row span pull from
// the same string. Empty for rules without a usable duration.
const durationLabel = formatDurationLabel(dl);
const durationLabel = formatDurationLabel(dl, parentLabelForDuration);
// Hover affordance on the date span: prefer the duration tooltip when
// we have one, else fall back to the edit-hint when the cell is
// click-to-edit. The edit affordance still works either way — the
@@ -478,7 +571,14 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
ruleRef = `<span class="timeline-rule">${escHtml(dl.ruleRef)}</span>`;
}
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
const rawNoteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
// Strip the leading-duration prefix so the new duration affordance
// doesn't duplicate what the lawyer wrote verbatim into deadline_notes
// for those legacy rule rows that still carry it.
// (t-paliad-307 / m/paliad#136 Bug 4)
const noteText = rawNoteText
? stripLeadingDurationFromNotes(rawNoteText, getLang() === "en" ? "en" : "de")
: rawNoteText;
const showNotes = opts.showNotes === true;
const notesBlock = noteText && showNotes
? `<div class="timeline-notes">${noteText}</div>`
@@ -608,7 +708,32 @@ export function wireDateEditClicks(
});
}
// pickTriggerEventLabel returns the per-language trigger event label
// from a DeadlineResponse, used as the parent-fallback for root-rule
// duration labels. Mirrors the precedence the page-level
// triggerEventLabelFor uses (curated server label > proceedingName
// fallback). Distinct from the page helper in that it stays language-
// scoped to the current getLang() — root-rule duration labels render
// in the user's current language. (t-paliad-307 / m/paliad#136 Bug 3)
export function pickTriggerEventLabel(data: DeadlineResponse): string {
const lang = getLang();
const curated = lang === "en"
? (data.triggerEventLabelEN || data.triggerEventLabel || "")
: (data.triggerEventLabel || data.triggerEventLabelEN || "");
if (curated) return curated;
return lang === "en"
? (data.proceedingNameEN || data.proceedingName || "")
: (data.proceedingName || data.proceedingNameEN || "");
}
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
// Resolve the trigger event label once so the duration affordance on
// root rules (no parent) can read it as the anchor fallback. Caller-
// provided value wins (lets the page override for sub-track flows).
const cardOpts: CardOpts = {
...opts,
triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data),
};
let html = '<div class="timeline">';
for (const dl of data.deadlines) {
const itemClasses = [
@@ -630,7 +755,7 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
<div class="timeline-line"></div>
</div>
<div class="timeline-content">
${deadlineCardHtml(dl, opts)}
${deadlineCardHtml(dl, cardOpts)}
</div>
</div>
`;
@@ -689,6 +814,15 @@ export interface ColumnsBodyOpts {
// (no mirror). Default null = mirror "both" into both cells
// (legacy behaviour). Independent of `side`.
appellant?: Side;
// appealAware: forwarded to bucketDeadlinesIntoColumns when the
// page is rendering an appeal_target-filtered timeline. Routes
// each rule to its filer-perspective column via dl.appealRole
// instead of the legacy primary_party='both' collapse.
// (t-paliad-307 / m/paliad#136 Bug 1)
appealAware?: boolean;
// triggerEventLabel: forwarded to deadlineCardHtml — see CardOpts.
// (t-paliad-307 / m/paliad#136 Bug 3)
triggerEventLabel?: string;
}
// ColumnsRow is the per-due-date bucket the renderer consumes. Public
@@ -704,6 +838,15 @@ export interface ColumnsRow {
export interface BucketingOpts {
side?: Side;
appellant?: Side;
// appealAware: when true, rules carrying a `dl.appealRole` of
// "appellant" / "appellee" route via the appeal role + user side
// axis instead of the legacy primary_party='both' collapse. With
// `side=null` the bucketer keeps the mirror semantic (both columns
// render every appeal rule); with `side` set, "appellant" rules
// land in the user's column when the user IS the appellant, in
// the opponent's column otherwise — mirror for "appellee" rules.
// (t-paliad-307 / m/paliad#136 Bug 1)
appealAware?: boolean;
}
// bucketDeadlinesIntoColumns is the pure routing primitive that
@@ -738,6 +881,8 @@ export function bucketDeadlinesIntoColumns(
return r;
};
const appealAware = opts.appealAware === true;
deadlines.forEach((dl, idx) => {
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
const row = ensureRow(key);
@@ -760,6 +905,25 @@ export function bucketDeadlinesIntoColumns(
if (dl.appellantContext === "claimant" || dl.appellantContext === "defendant") {
const perCardCol = dl.appellantContext === "claimant" ? claimantColumn : defendantColumn;
row[perCardCol].push(dl);
} else if (
appealAware &&
(dl.appealRole === "appellant" || dl.appealRole === "appellee")
) {
// Appeal-aware routing (t-paliad-307 / m/paliad#136 Bug 1).
// With no side picked, mirror to both columns so every rule
// is visible regardless of which side the user is on. With
// a side picked, route by (filer matches user) → ours
// column, else opponent column. side=claimant maps the
// user to "appellant" (Berufungskläger); side=defendant
// maps the user to "appellee" (Berufungsbeklagter).
if (userSide === null) {
row.ours.push(dl);
row.opponent.push(dl);
} else {
const userIsAppellant = userSide === "claimant";
const filerIsAppellant = dl.appealRole === "appellant";
row[filerIsAppellant === userIsAppellant ? "ours" : "opponent"].push(dl);
}
} else if (appellantColumn !== null) {
// Role-swap collapse: appellant initiated → both → one row
// in appellant's column. Mirror suppressed.
@@ -798,7 +962,11 @@ export function bucketDeadlinesIntoColumns(
export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
const userSide: Side = opts.side ?? null;
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
const rows = bucketDeadlinesIntoColumns(data.deadlines, {
side: userSide,
appellant: opts.appellant,
appealAware: opts.appealAware,
});
const appellantPinned = opts.appellant === "claimant" || opts.appellant === "defendant";
const cardOpts: CardOpts = {
@@ -806,6 +974,7 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
editable: opts.editable,
showNotes: opts.showNotes,
showDurations: opts.showDurations,
triggerEventLabel: opts.triggerEventLabel ?? pickTriggerEventLabel(data),
};
// Collapsed "both" rows lose their mirror tag — there's no longer

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); },
};
}

View File

@@ -0,0 +1,13 @@
-- 145_scenarios — DOWN
--
-- Reverses mig 145. Drops the FK on paliad.projects, the table, the
-- trigger function, and the RLS policies (CASCADE on table drop kills
-- policies). Any data in paliad.scenarios is lost on down.
ALTER TABLE paliad.projects
DROP COLUMN IF EXISTS active_scenario_id;
DROP TRIGGER IF EXISTS scenarios_touch_updated_at_trg ON paliad.scenarios;
DROP FUNCTION IF EXISTS paliad.scenarios_touch_updated_at();
DROP TABLE IF EXISTS paliad.scenarios CASCADE;

View File

@@ -0,0 +1,170 @@
-- 145_scenarios — Slice D, m/paliad#124 §5 (revised)
--
-- Creates paliad.scenarios + paliad.projects.active_scenario_id FK.
-- A scenario is a named composition of existing proceedings + flags
-- + per-card choices + anchor dates the user can switch between for
-- a project (project_id NOT NULL) OR save as an abstract template on
-- /tools/verfahrensablauf (project_id IS NULL).
--
-- m's 2026-05-26 picks (AskUserQuestion round, doc commit 6e58595):
-- Q1: composition shape → primary+spawned (v1); multi-proceeding
-- peer compose is the v2 goal. spec.jsonb
-- architected for N entries from day 1.
-- Q2: scope → per-project + abstract.
-- Q3: trigger dates → per-anchor overrides over one base date.
-- Q4: storage → NEW paliad.scenarios table with jsonb
-- spec (NOT a project_event_choices column
-- extension).
--
-- "users should not add their own rules" (m, t-paliad-301) — scenarios
-- compose existing rules, never author new ones. spec.proceedings[*].code
-- must resolve to an existing active paliad.proceeding_types row;
-- spec.proceedings[*].anchor_overrides keys must resolve to existing
-- submission_codes. Validation happens at the application layer
-- (ScenarioService.validateSpec) — not in DB CHECK constraints (too
-- expensive to express in pure SQL).
--
-- Migration number: 145. Coordination check 2026-05-26 17:38: curie's
-- B.2-B.6 migrations land in the 139-143 range. 144 reserved as buffer.
-- 145 is the next safe claim.
--
-- ADDITIVE ONLY: CREATE TABLE, ALTER ADD COLUMN, indexes, RLS policies.
-- Down drops everything. No backfill (zero existing scenarios on day 1).
--
-- See docs/design-litigation-planner-2026-05-26.md §5 + §18.4 for the
-- design.
-- ---------------------------------------------------------------
-- 1. The scenarios table
-- ---------------------------------------------------------------
CREATE TABLE paliad.scenarios (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
-- project_id NULL = abstract scenario (saved Verfahrensablauf
-- template, no Akte). project_id NOT NULL = scenario attached to
-- a real Akte.
project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
name text NOT NULL,
description text NULL,
-- spec carries the full composition. Shape documented in the
-- design doc §5; the application validates structure before write.
spec jsonb NOT NULL,
created_by uuid NULL REFERENCES paliad.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
-- Within a single project, scenario names are unique. Abstract
-- scenarios are unique per (created_by, name) so two users can
-- each keep a "with_ccr" template without colliding. NULLS NOT
-- DISTINCT means a single user can have one "name" per
-- (project_id, created_by) tuple, where NULL project_id +
-- NULL created_by is a single global namespace (used only by
-- seed / system scenarios — none today).
CONSTRAINT scenarios_unique_per_scope
UNIQUE NULLS NOT DISTINCT (project_id, created_by, name),
-- Non-empty name.
CONSTRAINT scenarios_name_nonempty CHECK (char_length(name) > 0),
-- Non-empty spec — at least an object. The application checks
-- structure (version, proceedings[], base_trigger_date format).
CONSTRAINT scenarios_spec_object CHECK (jsonb_typeof(spec) = 'object')
);
CREATE INDEX scenarios_project_id_idx
ON paliad.scenarios(project_id) WHERE project_id IS NOT NULL;
CREATE INDEX scenarios_abstract_user_idx
ON paliad.scenarios(created_by) WHERE project_id IS NULL;
COMMENT ON TABLE paliad.scenarios IS
'Named compositions of existing proceedings + flags + per-card '
'choices + anchor dates. project_id NULL = abstract template; '
'project_id NOT NULL = attached to an Akte. Design: '
'docs/design-litigation-planner-2026-05-26.md §5. (Slice D)';
COMMENT ON COLUMN paliad.scenarios.spec IS
'jsonb composition spec. Shape: {version: int, base_trigger_date: '
'ISO date, proceedings: [{code, role, flags[], per_card_choices, '
'anchor_overrides, skip_rules[]}, ...]}. Validated at write-time '
'by ScenarioService.validateSpec.';
-- ---------------------------------------------------------------
-- 2. paliad.projects.active_scenario_id FK
--
-- NULL = use today's ad-hoc per-card choice state from
-- paliad.project_event_choices (pre-scenario behaviour preserved).
-- Non-NULL = the project's current SmartTimeline / Akte-Fristenrechner
-- render reads from this scenario's spec instead.
-- ---------------------------------------------------------------
ALTER TABLE paliad.projects
ADD COLUMN active_scenario_id uuid NULL
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
COMMENT ON COLUMN paliad.projects.active_scenario_id IS
'FK to paliad.scenarios. NULL = read choices from '
'paliad.project_event_choices (legacy). Non-NULL = read from the '
'pointed scenario.spec.';
-- ---------------------------------------------------------------
-- 3. RLS — mirror paliad.project_event_choices's pattern (mig 129).
--
-- Project-scoped scenarios (project_id NOT NULL) inherit team visibility
-- via paliad.can_see_project. Abstract scenarios (project_id IS NULL)
-- are private to created_by — only the author can read / write them.
-- ---------------------------------------------------------------
ALTER TABLE paliad.scenarios ENABLE ROW LEVEL SECURITY;
-- Project-scoped: team visibility.
DROP POLICY IF EXISTS scenarios_project_select ON paliad.scenarios;
CREATE POLICY scenarios_project_select ON paliad.scenarios
FOR SELECT
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id));
DROP POLICY IF EXISTS scenarios_project_mutate ON paliad.scenarios;
CREATE POLICY scenarios_project_mutate ON paliad.scenarios
FOR ALL
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id))
WITH CHECK (project_id IS NOT NULL AND paliad.can_see_project(project_id));
-- Abstract: owner-only.
DROP POLICY IF EXISTS scenarios_abstract_select ON paliad.scenarios;
CREATE POLICY scenarios_abstract_select ON paliad.scenarios
FOR SELECT
USING (project_id IS NULL AND created_by = auth.uid());
DROP POLICY IF EXISTS scenarios_abstract_mutate ON paliad.scenarios;
CREATE POLICY scenarios_abstract_mutate ON paliad.scenarios
FOR ALL
USING (project_id IS NULL AND created_by = auth.uid())
WITH CHECK (project_id IS NULL AND created_by = auth.uid());
-- ---------------------------------------------------------------
-- 4. updated_at trigger (mirrors other paliad tables that carry
-- updated_at — keep it in lockstep with row mutations).
-- ---------------------------------------------------------------
CREATE OR REPLACE FUNCTION paliad.scenarios_touch_updated_at()
RETURNS trigger AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER scenarios_touch_updated_at_trg
BEFORE UPDATE ON paliad.scenarios
FOR EACH ROW
EXECUTE FUNCTION paliad.scenarios_touch_updated_at();
-- ---------------------------------------------------------------
-- 5. Informational NOTICE — schema-only migration, zero rows added.
-- ---------------------------------------------------------------
DO $$
BEGIN
RAISE NOTICE '[mig 145] paliad.scenarios created (0 rows; awaits API usage)';
RAISE NOTICE '[mig 145] paliad.projects.active_scenario_id added (all rows NULL initially)';
END $$;

View File

@@ -120,6 +120,11 @@ type Services struct {
// the Verfahrensablauf timeline.
EventChoice *services.EventChoiceService
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions
// per project or as abstract templates. Nil when DATABASE_URL is
// unset; the /api/scenarios routes return 503 in that case.
Scenario *services.ScenarioService
// Paliadin is wired when DATABASE_URL is set. The concrete backend
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
// (remote → mRiver via SSH) or local tmux availability. Stays nil
@@ -184,6 +189,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
backup: svc.Backup,
submissionDraft: svc.SubmissionDraft,
eventChoice: svc.EventChoice,
scenario: svc.Scenario,
}
}
@@ -446,6 +452,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("PUT /api/projects/{id}/event-choices", handlePutProjectEventChoice)
protected.HandleFunc("DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}", handleDeleteProjectEventChoice)
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions
// per project or as abstract templates on /tools/verfahrensablauf.
protected.HandleFunc("GET /api/scenarios", handleScenariosList)
protected.HandleFunc("GET /api/scenarios/{id}", handleScenarioGet)
protected.HandleFunc("POST /api/scenarios", handleScenarioCreate)
protected.HandleFunc("PATCH /api/scenarios/{id}", handleScenarioPatch)
protected.HandleFunc("DELETE /api/scenarios/{id}", handleScenarioDelete)
protected.HandleFunc("PUT /api/projects/{id}/active-scenario", handleSetActiveScenario)
// Partner units (structural partner-led units; legacy "Dezernate").
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit)

View File

@@ -71,6 +71,9 @@ type dbServices struct {
// t-paliad-265 — per-event-card optional choices.
eventChoice *services.EventChoiceService
// Slice D — named scenario compositions (m/paliad#124 §5).
scenario *services.ScenarioService
}
var dbSvc *dbServices

View File

@@ -0,0 +1,216 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// Slice D (m/paliad#124 §5, mig 145) — REST endpoints for paliad.scenarios.
//
// Routes (registered in handlers.go):
//
// GET /api/scenarios?project=<id> — list project's scenarios
// GET /api/scenarios?abstract=true — list caller's abstract scenarios
// GET /api/scenarios/{id} — fetch one
// POST /api/scenarios — create
// PATCH /api/scenarios/{id} — partial update
// PUT /api/projects/{id}/active-scenario — set/clear active scenario
// DELETE /api/scenarios/{id} — remove
//
// All endpoints require auth; visibility is enforced by
// ScenarioService.requireProjectVisible / requireVisible.
func requireScenarioService(w http.ResponseWriter) bool {
if dbSvc == nil || dbSvc.scenario == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Szenarien sind vorübergehend nicht verfügbar (keine Datenbank).",
})
return false
}
return true
}
// scenarioErrorToStatus maps service errors to HTTP statuses. Mirrors
// the patterns in projects.go and event_choices.go.
func scenarioErrorToStatus(err error) (int, string) {
switch {
case errors.Is(err, lp.ErrUnknownScenario), errors.Is(err, services.ErrScenarioNotVisible):
return http.StatusNotFound, "Szenario nicht gefunden"
case errors.Is(err, services.ErrInvalidInput), errors.Is(err, lp.ErrInvalidScenario), errors.Is(err, lp.ErrScenarioNoPrimary):
return http.StatusBadRequest, err.Error()
}
return http.StatusInternalServerError, err.Error()
}
// handleScenariosList — GET /api/scenarios?project=<uuid> OR ?abstract=true.
func handleScenariosList(w http.ResponseWriter, r *http.Request) {
if !requireScenarioService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
abstract := r.URL.Query().Get("abstract") == "true"
projectStr := r.URL.Query().Get("project")
switch {
case abstract:
out, err := dbSvc.scenario.ListAbstractForUser(r.Context(), uid)
if err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusOK, out)
case projectStr != "":
pid, err := uuid.Parse(projectStr)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige project ID"})
return
}
out, err := dbSvc.scenario.ListForProject(r.Context(), uid, pid)
if err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusOK, out)
default:
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "?project=<uuid> oder ?abstract=true erforderlich",
})
}
}
// handleScenarioGet — GET /api/scenarios/{id}.
func handleScenarioGet(w http.ResponseWriter, r *http.Request) {
if !requireScenarioService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
out, err := dbSvc.scenario.Get(r.Context(), uid, id)
if err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusOK, out)
}
// handleScenarioCreate — POST /api/scenarios.
func handleScenarioCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.CreateScenarioInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenario.Create(r.Context(), uid, input)
if err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleScenarioPatch — PATCH /api/scenarios/{id}.
func handleScenarioPatch(w http.ResponseWriter, r *http.Request) {
if !requireScenarioService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
var input services.PatchScenarioInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenario.Patch(r.Context(), uid, id, input)
if err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
writeJSON(w, http.StatusOK, out)
}
// handleScenarioDelete — DELETE /api/scenarios/{id}.
func handleScenarioDelete(w http.ResponseWriter, r *http.Request) {
if !requireScenarioService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
if err := dbSvc.scenario.Delete(r.Context(), uid, id); err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleSetActiveScenario — PUT /api/projects/{id}/active-scenario.
// Body: {"scenario_id": "<uuid>"} or {"scenario_id": null} to clear.
func handleSetActiveScenario(w http.ResponseWriter, r *http.Request) {
if !requireScenarioService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
pid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige project ID"})
return
}
var body struct {
ScenarioID *uuid.UUID `json:"scenario_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
if err := dbSvc.scenario.SetActive(r.Context(), uid, pid, body.ScenarioID); err != nil {
status, msg := scenarioErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -516,6 +516,61 @@ func computeDepths(
return depths
}
// LoadScenarios lists scenarios visible to the caller (Slice D,
// m/paliad#124 §5, mig 145). RLS on paliad.scenarios enforces:
// project-scoped rows require paliad.can_see_project(project_id);
// abstract rows require created_by = auth.uid(). The filter narrows
// the SELECT (project_id-bound, abstract-for-user, or all).
func (c *paliadCatalog) LoadScenarios(ctx context.Context, filter lp.ScenarioFilter) ([]lp.Scenario, error) {
where := []string{}
args := []any{}
add := func(clause string, val any) {
args = append(args, val)
where = append(where, fmt.Sprintf(clause, len(args)))
}
if filter.ProjectID != nil {
add("project_id = $%d", *filter.ProjectID)
}
if filter.AbstractForUser != nil {
where = append(where, "project_id IS NULL")
add("created_by = $%d", *filter.AbstractForUser)
}
query := `SELECT id, project_id, name, description, spec,
created_by, created_at, updated_at
FROM paliad.scenarios`
if len(where) > 0 {
query += " WHERE " + strings.Join(where, " AND ")
}
query += " ORDER BY created_at DESC"
var rows []lp.Scenario
if err := c.rules.db.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, fmt.Errorf("load scenarios: %w", err)
}
return rows, nil
}
// MatchScenario returns the scenario with the given id, or
// lp.ErrUnknownScenario if not visible / not found. RLS gates
// visibility; a not-found result could mean "doesn't exist" OR
// "exists but you can't see it" — either way the caller treats it
// as unknown.
func (c *paliadCatalog) MatchScenario(ctx context.Context, id uuid.UUID) (*lp.Scenario, error) {
var s lp.Scenario
err := c.rules.db.GetContext(ctx, &s,
`SELECT id, project_id, name, description, spec,
created_by, created_at, updated_at
FROM paliad.scenarios
WHERE id = $1`, id)
if errors.Is(err, sql.ErrNoRows) {
return nil, lp.ErrUnknownScenario
}
if err != nil {
return nil, fmt.Errorf("match scenario %q: %w", id, err)
}
return &s, nil
}
// _ proves paliadCatalog satisfies lp.Catalog at compile time.
var _ lp.Catalog = (*paliadCatalog)(nil)

View File

@@ -0,0 +1,347 @@
package services
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// ScenarioService reads + writes paliad.scenarios — named compositions
// of existing proceedings + flags + per-card choices + anchor dates,
// switchable per project or saved as abstract templates on
// /tools/verfahrensablauf. Slice D, m/paliad#124 §5, mig 145.
//
// Visibility:
// - Project-scoped scenarios (project_id NOT NULL): require
// can_see_project on the bound project (mirrors
// EventChoiceService.requireProjectVisible).
// - Abstract scenarios (project_id IS NULL): owner-only. Only
// created_by can read / mutate.
//
// The service applies these checks in application code; paliad.scenarios
// also has RLS policies (mig 145) as defense-in-depth for callers that
// connect through Supabase Auth's auth.uid() session.
type ScenarioService struct {
db *sqlx.DB
projects *ProjectService
rules *DeadlineRuleService
}
// NewScenarioService wires the service to its dependencies.
func NewScenarioService(db *sqlx.DB, projects *ProjectService, rules *DeadlineRuleService) *ScenarioService {
return &ScenarioService{db: db, projects: projects, rules: rules}
}
// Sentinel errors. Mirrors EventChoiceService + the lp package errors
// so handlers can map cleanly to HTTP statuses.
var (
ErrScenarioNotVisible = errors.New("scenario not visible to caller")
)
// CreateScenarioInput is the payload for POST /api/scenarios. project_id
// nil = abstract scenario (saved Verfahrensablauf template).
type CreateScenarioInput struct {
ProjectID *uuid.UUID `json:"project_id,omitempty"`
Name string `json:"name"`
Description *string `json:"description,omitempty"`
Spec json.RawMessage `json:"spec"`
}
// Create inserts a new scenario after validating the spec.
func (s *ScenarioService) Create(ctx context.Context, userID uuid.UUID, input CreateScenarioInput) (*lp.Scenario, error) {
if input.Name == "" {
return nil, fmt.Errorf("%w: name required", ErrInvalidInput)
}
if err := s.validateSpec(ctx, input.Spec); err != nil {
return nil, err
}
if input.ProjectID != nil {
if err := s.requireProjectVisible(ctx, userID, *input.ProjectID); err != nil {
return nil, err
}
}
var out lp.Scenario
err := s.db.GetContext(ctx, &out,
`INSERT INTO paliad.scenarios (project_id, name, description, spec, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, project_id, name, description, spec, created_by,
created_at, updated_at`,
input.ProjectID, input.Name, input.Description,
[]byte(input.Spec), userID)
if err != nil {
return nil, fmt.Errorf("create scenario: %w", err)
}
return &out, nil
}
// Get returns one scenario by id after a visibility check.
func (s *ScenarioService) Get(ctx context.Context, userID, scenarioID uuid.UUID) (*lp.Scenario, error) {
var sc lp.Scenario
err := s.db.GetContext(ctx, &sc,
`SELECT id, project_id, name, description, spec, created_by,
created_at, updated_at
FROM paliad.scenarios
WHERE id = $1`, scenarioID)
if errors.Is(err, sql.ErrNoRows) {
return nil, lp.ErrUnknownScenario
}
if err != nil {
return nil, fmt.Errorf("get scenario: %w", err)
}
if err := s.requireVisible(ctx, userID, &sc); err != nil {
return nil, err
}
return &sc, nil
}
// ListForProject returns scenarios attached to one project, ordered by
// created_at desc.
func (s *ScenarioService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]lp.Scenario, error) {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return nil, err
}
out := []lp.Scenario{}
err := s.db.SelectContext(ctx, &out,
`SELECT id, project_id, name, description, spec, created_by,
created_at, updated_at
FROM paliad.scenarios
WHERE project_id = $1
ORDER BY created_at DESC`, projectID)
if err != nil {
return nil, fmt.Errorf("list scenarios for project: %w", err)
}
return out, nil
}
// ListAbstractForUser returns the calling user's abstract scenarios.
func (s *ScenarioService) ListAbstractForUser(ctx context.Context, userID uuid.UUID) ([]lp.Scenario, error) {
out := []lp.Scenario{}
err := s.db.SelectContext(ctx, &out,
`SELECT id, project_id, name, description, spec, created_by,
created_at, updated_at
FROM paliad.scenarios
WHERE project_id IS NULL AND created_by = $1
ORDER BY created_at DESC`, userID)
if err != nil {
return nil, fmt.Errorf("list abstract scenarios: %w", err)
}
return out, nil
}
// PatchScenarioInput is the payload for PATCH /api/scenarios/{id}. Any
// field nil means "don't change". Spec replacement re-runs validation.
type PatchScenarioInput struct {
Name *string `json:"name,omitempty"`
Description *string `json:"description,omitempty"`
Spec json.RawMessage `json:"spec,omitempty"`
}
// Patch updates one or more scenario fields. Visibility check fires
// first (the caller must already see the scenario to mutate it).
func (s *ScenarioService) Patch(ctx context.Context, userID, scenarioID uuid.UUID, input PatchScenarioInput) (*lp.Scenario, error) {
current, err := s.Get(ctx, userID, scenarioID)
if err != nil {
return nil, err
}
if len(input.Spec) > 0 {
if err := s.validateSpec(ctx, input.Spec); err != nil {
return nil, err
}
}
sets := []string{}
args := []any{}
add := func(clause string, val any) {
args = append(args, val)
sets = append(sets, fmt.Sprintf(clause, len(args)))
}
if input.Name != nil {
add("name = $%d", *input.Name)
}
if input.Description != nil {
add("description = $%d", *input.Description)
}
if len(input.Spec) > 0 {
add("spec = $%d", []byte(input.Spec))
}
if len(sets) == 0 {
return current, nil
}
args = append(args, scenarioID)
query := fmt.Sprintf(`UPDATE paliad.scenarios SET %s
WHERE id = $%d
RETURNING id, project_id, name, description, spec, created_by,
created_at, updated_at`, joinSets(sets), len(args))
var out lp.Scenario
if err := s.db.GetContext(ctx, &out, query, args...); err != nil {
return nil, fmt.Errorf("patch scenario: %w", err)
}
return &out, nil
}
// SetActive points a project at one of its scenarios. Pass nil to
// clear (revert to ad-hoc per-card choice state).
func (s *ScenarioService) SetActive(ctx context.Context, userID, projectID uuid.UUID, scenarioID *uuid.UUID) error {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return err
}
if scenarioID != nil {
// Ensure scenario exists + belongs to this project. A scenario
// from a different project (or an abstract one) can't be the
// active scenario on this project.
sc, err := s.Get(ctx, userID, *scenarioID)
if err != nil {
return err
}
if sc.ProjectID == nil || *sc.ProjectID != projectID {
return fmt.Errorf("%w: scenario %s is not attached to project %s",
ErrInvalidInput, *scenarioID, projectID)
}
}
_, err := s.db.ExecContext(ctx,
`UPDATE paliad.projects SET active_scenario_id = $1 WHERE id = $2`,
scenarioID, projectID)
if err != nil {
return fmt.Errorf("set active scenario: %w", err)
}
return nil
}
// Delete removes a scenario. Project's active_scenario_id is cleared
// automatically via the FK's ON DELETE SET NULL.
func (s *ScenarioService) Delete(ctx context.Context, userID, scenarioID uuid.UUID) error {
// Visibility check via Get — also resolves the existence question.
if _, err := s.Get(ctx, userID, scenarioID); err != nil {
return err
}
if _, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.scenarios WHERE id = $1`, scenarioID); err != nil {
return fmt.Errorf("delete scenario: %w", err)
}
return nil
}
// requireVisible enforces the per-row visibility rule:
// - project_id NOT NULL → caller must see the project
// - project_id IS NULL → caller must be the row's created_by
func (s *ScenarioService) requireVisible(ctx context.Context, userID uuid.UUID, sc *lp.Scenario) error {
if sc.ProjectID != nil {
return s.requireProjectVisible(ctx, userID, *sc.ProjectID)
}
if sc.CreatedBy == nil || *sc.CreatedBy != userID {
return ErrScenarioNotVisible
}
return nil
}
// requireProjectVisible mirrors EventChoiceService.requireProjectVisible
// (visibility via can_see_project). Cheap re-implementation — keeps the
// call-graph small + avoids a cross-service dep.
func (s *ScenarioService) requireProjectVisible(ctx context.Context, userID, projectID uuid.UUID) error {
var visible bool
err := s.db.GetContext(ctx, &visible,
`SELECT EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = $1 AND u.global_role = 'global_admin'
) OR EXISTS (
SELECT 1 FROM paliad.projects p
JOIN paliad.project_teams pt ON pt.project_id = ANY(
string_to_array(p.path, '.')::uuid[]
)
WHERE p.id = $2 AND pt.user_id = $1
)`, userID, projectID)
if err != nil {
return fmt.Errorf("check project visibility: %w", err)
}
if !visible {
return ErrScenarioNotVisible
}
return nil
}
// validateSpec checks the jsonb spec is well-formed, has the right
// version, and that every referenced proceeding code + submission code
// resolves to an active row in the live catalog. Surfaces friendly
// errors wrapping ErrInvalidInput so the handler can map to a 400.
func (s *ScenarioService) validateSpec(ctx context.Context, raw json.RawMessage) error {
if len(raw) == 0 {
return fmt.Errorf("%w: spec is required", ErrInvalidInput)
}
parsed, err := lp.ParseSpec(lp.NullableJSON(raw))
if err != nil {
return fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
if _, err := parsed.PrimaryProceeding(); err != nil {
return fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
if parsed.BaseTriggerDate != "" {
if _, err := time.Parse("2006-01-02", parsed.BaseTriggerDate); err != nil {
return fmt.Errorf("%w: base_trigger_date %q is not YYYY-MM-DD", ErrInvalidInput, parsed.BaseTriggerDate)
}
}
for i, p := range parsed.Proceedings {
if p.Code == "" {
return fmt.Errorf("%w: proceedings[%d].code is empty", ErrInvalidInput, i)
}
if p.Role != lp.ScenarioRolePrimary && p.Role != lp.ScenarioRolePeer {
return fmt.Errorf("%w: proceedings[%d].role=%q must be 'primary' or 'peer'",
ErrInvalidInput, i, p.Role)
}
if p.AppealTarget != "" && !lp.IsValidAppealTarget(p.AppealTarget) {
return fmt.Errorf("%w: proceedings[%d].appeal_target=%q not in %v",
ErrInvalidInput, i, p.AppealTarget, lp.AppealTargets)
}
if p.TriggerDateOverride != "" {
if _, err := time.Parse("2006-01-02", p.TriggerDateOverride); err != nil {
return fmt.Errorf("%w: proceedings[%d].trigger_date_override %q is not YYYY-MM-DD",
ErrInvalidInput, i, p.TriggerDateOverride)
}
}
for code, dateStr := range p.AnchorOverrides {
if _, err := time.Parse("2006-01-02", dateStr); err != nil {
return fmt.Errorf("%w: proceedings[%d].anchor_overrides[%q]=%q is not YYYY-MM-DD",
ErrInvalidInput, i, code, dateStr)
}
}
// Resolve code against active proceedings.
var exists bool
if err := s.db.GetContext(ctx, &exists,
`SELECT EXISTS(SELECT 1 FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true)`,
p.Code); err != nil {
return fmt.Errorf("validate spec proceedings[%d]: %w", i, err)
}
if !exists {
return fmt.Errorf("%w: proceedings[%d].code=%q is not an active proceeding_type",
ErrInvalidInput, i, p.Code)
}
}
return nil
}
// joinSets joins SET clauses with ", ". Tiny utility, kept here to
// avoid cross-package strings.Join indirection.
func joinSets(sets []string) string {
out := ""
for i, s := range sets {
if i > 0 {
out += ", "
}
out += s
}
return out
}
// Suppress unused-import diagnostic when models isn't referenced
// (kept for future shape-evolution; canonical scenario row lives in lp).
var _ = models.NullableJSON(nil)

View File

@@ -0,0 +1,58 @@
package litigationplanner
// AppealRole* are the canonical filer-role slugs used by the unified
// upc.apl Berufung proceeding (t-paliad-307 / m/paliad#136 Bug 1).
//
// Every appeal filing rule carries primary_party='both' in the catalog
// (either party could be the appellant, depending on which side lost
// downstream), so the static primary_party column can't drive
// column-bucketing under a user-perspective `?side=` pick. The
// per-rule appeal role fills that gap: "appellant" rules are filed by
// the Berufungskläger (the party who lost in the lower instance and
// is now appealing); "appellee" rules are filed by the
// Berufungsbeklagter (the party defending the lower-instance
// decision). The mapping is rule-semantic, not data-driven — we know
// from R.224/235 which submission belongs to which side.
const (
AppealRoleAppellant = "appellant"
AppealRoleAppellee = "appellee"
)
// AppealFilerRole returns the appeal-filer role for a submission code
// in the unified upc.apl proceeding. Empty string for codes whose role
// is not statically known (court-issued events, unmapped codes, or
// non-appeal proceedings).
//
// The engine stamps TimelineEntry.AppealRole with this value when
// CalcOptions.AppealTarget is set so the frontend column-bucketer can
// route each "both"-party rule into the correct user-perspective
// column (Berufungskläger vs Berufungsbeklagter) once the user picks
// a side.
//
// Adding a new appeal rule? Add its submission_code to the matching
// branch below. Court-issued events (cost.decision, order.order,
// merits.oral, merits.decision) deliberately stay empty — they route
// to the court column on primary_party='court'.
func AppealFilerRole(submissionCode string) string {
switch submissionCode {
// Appellant filings — Berufungskläger initiates the appeal +
// replies to the cross-appeal.
case "upc.apl.merits.notice",
"upc.apl.merits.grounds",
"upc.apl.merits.cross_a_reply",
"upc.apl.cost.leave_app",
"upc.apl.order.with_leave",
"upc.apl.order.grounds_orders",
"upc.apl.order.discretion",
"upc.apl.order.cross_reply":
return AppealRoleAppellant
// Appellee filings — Berufungsbeklagter responds to the appeal +
// files the cross-appeal.
case "upc.apl.merits.response",
"upc.apl.merits.cross_a",
"upc.apl.order.response_orders",
"upc.apl.order.cross":
return AppealRoleAppellee
}
return ""
}

View File

@@ -0,0 +1,192 @@
package litigationplanner
import (
"context"
"testing"
"github.com/google/uuid"
)
// TestAppealFilerRole pins the rule-semantic mapping that drives
// column-bucketing on the unified upc.apl Berufung timeline
// (t-paliad-307 / m/paliad#136 Bug 1). Every appeal filing rule has
// primary_party='both' in the catalog so the bucketer can't decide
// between Berufungskläger and Berufungsbeklagter columns from
// primary_party alone — the appeal role fills that gap.
func TestAppealFilerRole(t *testing.T) {
cases := []struct {
code string
want string
}{
// Appellant filings (Berufungskläger initiates / replies to cross).
{"upc.apl.merits.notice", AppealRoleAppellant},
{"upc.apl.merits.grounds", AppealRoleAppellant},
{"upc.apl.merits.cross_a_reply", AppealRoleAppellant},
{"upc.apl.cost.leave_app", AppealRoleAppellant},
{"upc.apl.order.with_leave", AppealRoleAppellant},
{"upc.apl.order.grounds_orders", AppealRoleAppellant},
{"upc.apl.order.discretion", AppealRoleAppellant},
{"upc.apl.order.cross_reply", AppealRoleAppellant},
// Appellee filings (Berufungsbeklagter responds + cross-appeals).
{"upc.apl.merits.response", AppealRoleAppellee},
{"upc.apl.merits.cross_a", AppealRoleAppellee},
{"upc.apl.order.response_orders", AppealRoleAppellee},
{"upc.apl.order.cross", AppealRoleAppellee},
// Court-issued events stay empty — they route on party='court'.
{"upc.apl.merits.decision", ""},
{"upc.apl.merits.oral", ""},
{"upc.apl.cost.decision", ""},
{"upc.apl.order.order", ""},
// Unmapped codes are empty (defensive — never silently picks a
// side for a new appeal rule we forgot to map).
{"upc.inf.cfi.soc", ""},
{"", ""},
{"foo.bar", ""},
}
for _, c := range cases {
if got := AppealFilerRole(c.code); got != c.want {
t.Errorf("AppealFilerRole(%q) = %q, want %q", c.code, got, c.want)
}
}
}
// TestCalculate_AppealSyntheticTriggerRow exercises the synthetic root
// row the engine prepends when CalcOptions.AppealTarget is set
// (t-paliad-307 / m/paliad#136 Bug 2). The row carries the
// per-appeal-target label, the trigger date as DueDate, IsRootEvent=
// IsTriggerEvent=true, and party=court. Without the appeal_target
// filter, no synthetic row is emitted (regression guard).
func TestCalculate_AppealSyntheticTriggerRow(t *testing.T) {
ctx := context.Background()
jurisdiction := "UPC"
procID := 1
pt := ProceedingType{
ID: procID,
Code: "upc.apl.unified",
Name: "Berufung",
NameEN: "Appeal",
Jurisdiction: &jurisdiction,
IsActive: true,
}
mkID := func() uuid.UUID {
id, _ := uuid.NewRandom()
return id
}
str := func(s string) *string { return &s }
procIDPtr := &procID
noticeCode := "upc.apl.merits.notice"
groundsCode := "upc.apl.merits.grounds"
rules := []Rule{
{
ID: mkID(),
ProceedingTypeID: procIDPtr,
SubmissionCode: &noticeCode,
Name: "Berufungseinlegung",
NameEN: "Notice of Appeal",
PrimaryParty: str(PrimaryPartyBoth),
DurationValue: 2,
DurationUnit: "months",
Timing: str("after"),
SequenceOrder: 0,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
AppliesToTarget: []string{AppealTargetEndentscheidung, AppealTargetSchadensbemessung},
},
{
ID: mkID(),
ProceedingTypeID: procIDPtr,
SubmissionCode: &groundsCode,
Name: "Berufungsbegründung",
NameEN: "Statement of Grounds",
PrimaryParty: str(PrimaryPartyBoth),
DurationValue: 4,
DurationUnit: "months",
Timing: str("after"),
SequenceOrder: 1,
IsActive: true,
LifecycleState: "published",
Priority: "mandatory",
AppliesToTarget: []string{AppealTargetEndentscheidung, AppealTargetSchadensbemessung},
},
}
cat := &stubCatalog{pt: pt, rules: rules}
t.Run("with appeal_target — synthetic row prepended + appeal_role stamped", func(t *testing.T) {
opts := CalcOptions{AppealTarget: AppealTargetEndentscheidung}
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
if len(timeline.Deadlines) < 3 {
t.Fatalf("expected synthetic row + 2 rules, got %d rows", len(timeline.Deadlines))
}
// Synthetic row first.
first := timeline.Deadlines[0]
if !first.IsTriggerEvent {
t.Errorf("first row IsTriggerEvent=%v, want true", first.IsTriggerEvent)
}
if !first.IsRootEvent {
t.Errorf("first row IsRootEvent=%v, want true", first.IsRootEvent)
}
if first.Name != "Endentscheidung (R.118)" {
t.Errorf("first row Name=%q, want %q", first.Name, "Endentscheidung (R.118)")
}
if first.NameEN != "Final decision (R.118)" {
t.Errorf("first row NameEN=%q, want %q", first.NameEN, "Final decision (R.118)")
}
if first.DueDate != "2026-05-26" {
t.Errorf("first row DueDate=%q, want 2026-05-26", first.DueDate)
}
if first.Party != PrimaryPartyCourt {
t.Errorf("first row Party=%q, want court", first.Party)
}
// Real rules should carry AppealRole.
byCode := map[string]TimelineEntry{}
for _, d := range timeline.Deadlines {
byCode[d.Code] = d
}
if got := byCode[noticeCode].AppealRole; got != AppealRoleAppellant {
t.Errorf("notice AppealRole=%q, want appellant", got)
}
if got := byCode[groundsCode].AppealRole; got != AppealRoleAppellant {
t.Errorf("grounds AppealRole=%q, want appellant", got)
}
})
t.Run("without appeal_target — no synthetic row, no appeal_role", func(t *testing.T) {
opts := CalcOptions{}
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
for _, d := range timeline.Deadlines {
if d.IsTriggerEvent {
t.Errorf("unexpected synthetic trigger row when appeal_target is unset: %+v", d)
}
if d.AppealRole != "" {
t.Errorf("unexpected AppealRole=%q when appeal_target is unset (rule %q)", d.AppealRole, d.Code)
}
}
})
t.Run("unknown appeal_target — short-circuits to no-op", func(t *testing.T) {
opts := CalcOptions{AppealTarget: "bogus"}
timeline, err := Calculate(ctx, "upc.apl.unified", "2026-05-26", opts, cat, noOpHolidays{}, fixedCourts{})
if err != nil {
t.Fatalf("Calculate: %v", err)
}
// IsValidAppealTarget("bogus") = false, so the engine skips
// both the rule filter AND the synthetic trigger emission.
for _, d := range timeline.Deadlines {
if d.IsTriggerEvent {
t.Errorf("unexpected synthetic trigger row for unknown target: %+v", d)
}
}
})
}

View File

@@ -67,6 +67,12 @@ func (s *stubCatalog) LoadTriggerEventsByIDs(_ context.Context, _ []int64) (map[
func (s *stubCatalog) LookupEvents(_ context.Context, _ EventLookupAxes, _ EventLookupDepth) ([]EventMatch, error) {
return nil, nil
}
func (s *stubCatalog) LoadScenarios(_ context.Context, _ ScenarioFilter) ([]Scenario, error) {
return nil, nil
}
func (s *stubCatalog) MatchScenario(_ context.Context, _ uuid.UUID) (*Scenario, error) {
return nil, ErrUnknownScenario
}
// noOpHolidays never adjusts dates — the test fixture doesn't care about
// weekends or holidays, only about which base date the engine resolves.

View File

@@ -1,6 +1,10 @@
package litigationplanner
import "context"
import (
"context"
"github.com/google/uuid"
)
// Catalog supplies proceeding-type metadata + rules for the calculator.
//
@@ -59,4 +63,17 @@ type Catalog interface {
// (proceeding_type_id, sequence_order) so the frontend can render
// without re-sorting.
LookupEvents(ctx context.Context, axes EventLookupAxes, depth EventLookupDepth) ([]EventMatch, error)
// LoadScenarios lists scenarios visible to the caller, narrowed by
// the filter (Slice D, m/paliad#124 §5). Returns an empty slice
// (NOT an error) when no scenarios match. paliad-side impl applies
// RLS (paliad.can_see_project for project-scoped, created_by for
// abstract); snapshot-backed catalogs return an empty list.
LoadScenarios(ctx context.Context, filter ScenarioFilter) ([]Scenario, error)
// MatchScenario returns the scenario with the given id, or
// ErrUnknownScenario if not found / not visible. The engine adapter
// (CalculateFromScenario) calls this to fetch a scenario by id and
// then unpacks its spec via ParseSpec.
MatchScenario(ctx context.Context, id uuid.UUID) (*Scenario, error)
}

View File

@@ -292,6 +292,20 @@ func (c *SnapshotCatalog) LookupEvents(_ context.Context, axes lp.EventLookupAxe
return out, nil
}
// LoadScenarios returns an empty slice. The snapshot catalog has no
// scenarios — youpc.org (the consumer today) doesn't carry a project /
// user model. Future snapshot variants could ship demo scenarios, but
// v1 returns nothing.
func (c *SnapshotCatalog) LoadScenarios(_ context.Context, _ lp.ScenarioFilter) ([]lp.Scenario, error) {
return []lp.Scenario{}, nil
}
// MatchScenario always returns ErrUnknownScenario — the snapshot has
// no scenarios to match against.
func (c *SnapshotCatalog) MatchScenario(_ context.Context, _ uuid.UUID) (*lp.Scenario, error) {
return nil, lp.ErrUnknownScenario
}
// Compile-time assertion that SnapshotCatalog satisfies lp.Catalog.
var _ lp.Catalog = (*SnapshotCatalog)(nil)

View File

@@ -572,6 +572,21 @@ func Calculate(
deadlines = append(deadlines, d)
}
// Stamp AppealRole on every entry when an appeal-target filter is
// active so the frontend column-bucketer can route primary_party=
// 'both' rules into the user-perspective columns
// (Berufungskläger vs Berufungsbeklagter). Court events stay empty
// — they route on Party='court' regardless. (t-paliad-307 /
// m/paliad#136 Bug 1)
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
for i := range deadlines {
if deadlines[i].Code == "" {
continue
}
deadlines[i].AppealRole = AppealFilerRole(deadlines[i].Code)
}
}
// Restore sequence_order on the output slice. The compute walk
// re-ordered rules topologically (parent-first) so the parent-state
// checks resolved correctly; the wire shape and the linear timeline
@@ -594,6 +609,31 @@ func Calculate(
// same-group rows. Court-set / conditional rows sort LAST.
sortDeadlinesByDurationWithinTriggerGroup(deadlines, ruleByID)
// Synthetic trigger-event row for appeal timelines (t-paliad-307 /
// m/paliad#136 Bug 2). The decision being appealed (Endentscheidung
// R.118, Kostenentscheidung, Anordnung, …) isn't a rule in the
// upc.apl catalog — it's the anchor the user picked. Lawyers expect
// it to surface as the first row of the timeline so the chain reads
// decision → appeal filings → next decision. Emitted only when an
// appeal_target is in play and the helper returns a non-empty label.
if opts.AppealTarget != "" && IsValidAppealTarget(opts.AppealTarget) {
nameDE := TriggerEventLabelForAppealTarget(opts.AppealTarget, "de")
nameEN := TriggerEventLabelForAppealTarget(opts.AppealTarget, "en")
if nameDE != "" || nameEN != "" {
trig := TimelineEntry{
Name: nameDE,
NameEN: nameEN,
Party: PrimaryPartyCourt,
Priority: "informational",
DueDate: triggerDateStr,
OriginalDate: triggerDateStr,
IsRootEvent: true,
IsTriggerEvent: true,
}
deadlines = append([]TimelineEntry{trig}, deadlines...)
}
}
resp := &Timeline{
ProceedingType: pickedProceeding.Code,
ProceedingName: pickedProceeding.Name,

View File

@@ -0,0 +1,215 @@
package litigationplanner
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
)
// Slice D scenarios — m/paliad#124 §5 (revised), mig 145.
//
// A Scenario is a named composition of existing proceedings + flags +
// per-card choices + anchor dates. v1 ships with one primary proceeding
// per scenario; the spec.proceedings[] array is architected to absorb
// multi-peer compose (v2) without a schema migration.
//
// "users should not add their own rules" (m, t-paliad-301) — the spec
// references existing rules by submission_code; it never creates new
// ones. ValidateSpec checks every code/submission resolves against the
// current catalog before a save is accepted.
// Scenario is one row of paliad.scenarios. Wire shape doubles as the
// API request/response payload for /api/scenarios.
type Scenario struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
Name string `db:"name" json:"name"`
Description *string `db:"description" json:"description,omitempty"`
// Spec carries the jsonb composition. Stored raw so we can ship
// shape evolutions without schema churn; ParseSpec gives the
// structured view.
Spec NullableJSON `db:"spec" json:"spec"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// ScenarioSpec is the parsed view of Scenario.Spec. v1 = version 1.
// Future shape changes bump the version; ParseSpec rejects unknown
// versions so an old client doesn't silently misread a future-shape
// scenario.
type ScenarioSpec struct {
Version int `json:"version"`
BaseTriggerDate string `json:"base_trigger_date"`
Proceedings []ScenarioProceeding `json:"proceedings"`
}
// ScenarioProceeding is one entry under spec.proceedings[]. v1 honours
// exactly one with role="primary" (additional entries with role="peer"
// are reserved for v2 multi-proceeding compose and silently ignored
// by the engine today).
type ScenarioProceeding struct {
Code string `json:"code"`
Role string `json:"role"` // "primary" | "peer" (v2)
TriggerDateOverride string `json:"trigger_date_override,omitempty"`
Flags []string `json:"flags,omitempty"`
PerCardChoices map[string]ScenarioCardChoice `json:"per_card_choices,omitempty"`
AnchorOverrides map[string]string `json:"anchor_overrides,omitempty"`
SkipRules []string `json:"skip_rules,omitempty"`
AppealTarget string `json:"appeal_target,omitempty"`
}
// ScenarioCardChoice is one entry under
// spec.proceedings[*].per_card_choices. Mirrors the t-paliad-265 choice
// kinds; not every kind is populated on every card.
type ScenarioCardChoice struct {
Appellant string `json:"appellant,omitempty"`
IncludeCCR *bool `json:"include_ccr,omitempty"`
Skip *bool `json:"skip,omitempty"`
}
// Spec version constant.
const ScenarioSpecVersion = 1
// Sentinel errors for scenarios.
var (
ErrUnknownScenario = errors.New("unknown scenario")
ErrInvalidScenario = errors.New("invalid scenario spec")
ErrScenarioNoPrimary = errors.New("scenario spec has no proceeding with role='primary'")
)
// ScenarioRole* are the canonical role slugs for ScenarioProceeding.Role.
const (
ScenarioRolePrimary = "primary"
ScenarioRolePeer = "peer"
)
// ParseSpec decodes Scenario.Spec into a structured ScenarioSpec. Used
// by the engine adapter + the rule-editor preview. Surfaces a friendly
// error wrapping ErrInvalidScenario on malformed JSON / unknown version
// so the handler can map to a 400.
func ParseSpec(raw NullableJSON) (*ScenarioSpec, error) {
if len(raw) == 0 {
return nil, fmt.Errorf("%w: spec is empty", ErrInvalidScenario)
}
var s ScenarioSpec
if err := json.Unmarshal([]byte(raw), &s); err != nil {
return nil, fmt.Errorf("%w: decode spec: %v", ErrInvalidScenario, err)
}
if s.Version != ScenarioSpecVersion {
return nil, fmt.Errorf("%w: spec.version=%d, want %d",
ErrInvalidScenario, s.Version, ScenarioSpecVersion)
}
return &s, nil
}
// PrimaryProceeding returns the entry from spec.proceedings[] with
// role="primary". Returns ErrScenarioNoPrimary if absent — every spec
// must carry exactly one primary entry. (Multiple primaries are also
// rejected: the engine consumes one.)
func (s *ScenarioSpec) PrimaryProceeding() (*ScenarioProceeding, error) {
var primary *ScenarioProceeding
for i := range s.Proceedings {
if s.Proceedings[i].Role == ScenarioRolePrimary {
if primary != nil {
return nil, fmt.Errorf("%w: multiple proceedings with role='primary'", ErrInvalidScenario)
}
primary = &s.Proceedings[i]
}
}
if primary == nil {
return nil, ErrScenarioNoPrimary
}
return primary, nil
}
// CalcOptionsFromSpec builds a CalcOptions from the scenario's primary
// entry. The caller still needs the proceeding code + the trigger date,
// both returned alongside.
//
// v1: only the primary entry is honoured. v2 will iterate over peer
// entries; the multi-peer merge lives in the paliad-side
// ProjectionService (one Calculate call per entry, merged + sorted by
// date).
func (s *ScenarioSpec) CalcOptionsFromSpec() (proceedingCode, triggerDate string, opts CalcOptions, err error) {
primary, err := s.PrimaryProceeding()
if err != nil {
return "", "", CalcOptions{}, err
}
td := s.BaseTriggerDate
if primary.TriggerDateOverride != "" {
td = primary.TriggerDateOverride
}
if td == "" {
return "", "", CalcOptions{}, fmt.Errorf("%w: no base_trigger_date and no per-proceeding override", ErrInvalidScenario)
}
perCardAppellant := make(map[string]string, len(primary.PerCardChoices))
skipRules := make(map[string]struct{}, len(primary.SkipRules))
includeCCRFor := make(map[string]struct{}, len(primary.PerCardChoices))
for code, choice := range primary.PerCardChoices {
if choice.Appellant != "" {
perCardAppellant[code] = choice.Appellant
}
if choice.IncludeCCR != nil && *choice.IncludeCCR {
includeCCRFor[code] = struct{}{}
}
if choice.Skip != nil && *choice.Skip {
skipRules[code] = struct{}{}
}
}
for _, code := range primary.SkipRules {
skipRules[code] = struct{}{}
}
return primary.Code, td, CalcOptions{
Flags: primary.Flags,
AnchorOverrides: primary.AnchorOverrides,
AppealTarget: primary.AppealTarget,
PerCardAppellant: perCardAppellant,
SkipRules: skipRules,
IncludeCCRFor: includeCCRFor,
}, nil
}
// ScenarioFilter narrows Catalog.LoadScenarios. All fields optional:
//
// - ProjectID non-nil: only scenarios attached to that project
// (project_id = filter.ProjectID).
// - AbstractForUser non-nil: only abstract scenarios (project_id IS
// NULL) created by that user.
// - Both nil: list every scenario the caller can see (RLS-gated).
type ScenarioFilter struct {
ProjectID *uuid.UUID
AbstractForUser *uuid.UUID
}
// CalculateFromScenario is the high-level engine entry for scenario-
// driven rendering. Unpacks the spec, builds CalcOptions, and delegates
// to Calculate.
//
// v1: surfaces only the primary proceeding's timeline. v2 multi-peer
// expansion lives on the paliad-side ProjectionService (per-entry
// Calculate + client-side merge); the package doesn't own that
// orchestration.
func CalculateFromScenario(
ctx context.Context,
scenario *Scenario,
catalog Catalog,
holidays HolidayCalendar,
courts CourtRegistry,
) (*Timeline, error) {
spec, err := ParseSpec(scenario.Spec)
if err != nil {
return nil, err
}
code, triggerDate, opts, err := spec.CalcOptionsFromSpec()
if err != nil {
return nil, err
}
return Calculate(ctx, code, triggerDate, opts, catalog, holidays, courts)
}

View File

@@ -0,0 +1,207 @@
package litigationplanner
import (
"strings"
"testing"
)
// TestParseSpec_Roundtrip pins the spec-decoder contract: well-formed
// jsonb with version=1 parses; unknown versions and malformed JSON
// surface ErrInvalidScenario.
func TestParseSpec_Roundtrip(t *testing.T) {
cases := []struct {
name string
spec string
wantErr bool
}{
{
"v1 primary-only",
`{"version":1,"base_trigger_date":"2026-05-26","proceedings":[{"code":"upc.inf.cfi","role":"primary"}]}`,
false,
},
{
"v1 with full primary entry",
`{"version":1,"base_trigger_date":"2026-05-26","proceedings":[
{"code":"upc.inf.cfi","role":"primary","flags":["with_ccr"],
"anchor_overrides":{"inf.reply":"2026-08-15"},
"skip_rules":["inf.r30_amend"]}
]}`,
false,
},
{
"v2 spec rejected — unknown version",
`{"version":2,"proceedings":[]}`,
true,
},
{
"empty spec",
``,
true,
},
{
"malformed json",
`{"version":1,"proceedings":[}`,
true,
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
_, err := ParseSpec(NullableJSON(c.spec))
if c.wantErr && err == nil {
t.Errorf("ParseSpec(%s): want error, got nil", c.spec)
}
if !c.wantErr && err != nil {
t.Errorf("ParseSpec(%s): unexpected error %v", c.spec, err)
}
})
}
}
// TestScenarioSpec_PrimaryProceeding pins the "exactly one primary"
// invariant: zero → ErrScenarioNoPrimary; multiple → ErrInvalidScenario.
func TestScenarioSpec_PrimaryProceeding(t *testing.T) {
t.Run("zero primary → ErrScenarioNoPrimary", func(t *testing.T) {
s := &ScenarioSpec{
Version: 1,
Proceedings: []ScenarioProceeding{
{Code: "upc.inf.cfi", Role: ScenarioRolePeer},
},
}
_, err := s.PrimaryProceeding()
if err != ErrScenarioNoPrimary {
t.Errorf("want ErrScenarioNoPrimary, got %v", err)
}
})
t.Run("two primaries rejected", func(t *testing.T) {
s := &ScenarioSpec{
Version: 1,
Proceedings: []ScenarioProceeding{
{Code: "upc.inf.cfi", Role: ScenarioRolePrimary},
{Code: "upc.rev.cfi", Role: ScenarioRolePrimary},
},
}
_, err := s.PrimaryProceeding()
if err == nil || !strings.Contains(err.Error(), "multiple proceedings with role='primary'") {
t.Errorf("want multi-primary error, got %v", err)
}
})
t.Run("single primary picked", func(t *testing.T) {
s := &ScenarioSpec{
Version: 1,
Proceedings: []ScenarioProceeding{
{Code: "upc.inf.cfi", Role: ScenarioRolePeer},
{Code: "upc.rev.cfi", Role: ScenarioRolePrimary, Flags: []string{"with_amend"}},
},
}
p, err := s.PrimaryProceeding()
if err != nil {
t.Fatalf("PrimaryProceeding: %v", err)
}
if p.Code != "upc.rev.cfi" {
t.Errorf("primary code = %q, want upc.rev.cfi", p.Code)
}
if len(p.Flags) != 1 || p.Flags[0] != "with_amend" {
t.Errorf("primary.Flags = %v, want [with_amend]", p.Flags)
}
})
}
// TestScenarioSpec_CalcOptionsFromSpec covers the unpack from spec
// jsonb into the CalcOptions the engine consumes. Pins:
// - base_trigger_date used when no per-proceeding override
// - trigger_date_override wins when set
// - flags + anchor_overrides + appeal_target passed through verbatim
// - per_card_choices unpacked into PerCardAppellant / SkipRules /
// IncludeCCRFor maps
func TestScenarioSpec_CalcOptionsFromSpec(t *testing.T) {
includeTrue := true
skipTrue := true
s := &ScenarioSpec{
Version: 1,
BaseTriggerDate: "2026-05-26",
Proceedings: []ScenarioProceeding{{
Code: "upc.inf.cfi",
Role: ScenarioRolePrimary,
Flags: []string{"with_ccr"},
AnchorOverrides: map[string]string{"inf.reply": "2026-08-15"},
AppealTarget: "endentscheidung",
SkipRules: []string{"explicit_skip_code"},
PerCardChoices: map[string]ScenarioCardChoice{
"inf.r30_amend": {Appellant: "claimant"},
"inf.rejoin": {IncludeCCR: &includeTrue},
"inf.amend_other": {Skip: &skipTrue},
},
}},
}
code, td, opts, err := s.CalcOptionsFromSpec()
if err != nil {
t.Fatalf("CalcOptionsFromSpec: %v", err)
}
if code != "upc.inf.cfi" {
t.Errorf("code = %q, want upc.inf.cfi", code)
}
if td != "2026-05-26" {
t.Errorf("triggerDate = %q, want 2026-05-26", td)
}
if len(opts.Flags) != 1 || opts.Flags[0] != "with_ccr" {
t.Errorf("opts.Flags = %v, want [with_ccr]", opts.Flags)
}
if opts.AppealTarget != "endentscheidung" {
t.Errorf("opts.AppealTarget = %q, want endentscheidung", opts.AppealTarget)
}
if got := opts.AnchorOverrides["inf.reply"]; got != "2026-08-15" {
t.Errorf("opts.AnchorOverrides[inf.reply] = %q, want 2026-08-15", got)
}
if got := opts.PerCardAppellant["inf.r30_amend"]; got != "claimant" {
t.Errorf("opts.PerCardAppellant[inf.r30_amend] = %q, want claimant", got)
}
if _, ok := opts.IncludeCCRFor["inf.rejoin"]; !ok {
t.Error("opts.IncludeCCRFor missing inf.rejoin")
}
if _, ok := opts.SkipRules["inf.amend_other"]; !ok {
t.Error("opts.SkipRules missing inf.amend_other (from per_card_choices.skip)")
}
if _, ok := opts.SkipRules["explicit_skip_code"]; !ok {
t.Error("opts.SkipRules missing explicit_skip_code (from skip_rules[])")
}
}
// TestScenarioSpec_TriggerDateOverride pins the per-proceeding override
// path (v2-ready — primary entry honours trigger_date_override too).
func TestScenarioSpec_TriggerDateOverride(t *testing.T) {
s := &ScenarioSpec{
Version: 1,
BaseTriggerDate: "2026-05-26",
Proceedings: []ScenarioProceeding{{
Code: "upc.inf.cfi",
Role: ScenarioRolePrimary,
TriggerDateOverride: "2026-12-01",
}},
}
_, td, _, err := s.CalcOptionsFromSpec()
if err != nil {
t.Fatalf("CalcOptionsFromSpec: %v", err)
}
if td != "2026-12-01" {
t.Errorf("triggerDate = %q, want override 2026-12-01", td)
}
}
// TestScenarioSpec_NoBaseTrigger pins the safety check that a spec
// without base_trigger_date AND without per-proceeding override
// surfaces ErrInvalidScenario (the engine can't render without a date).
func TestScenarioSpec_NoBaseTrigger(t *testing.T) {
s := &ScenarioSpec{
Version: 1,
Proceedings: []ScenarioProceeding{{
Code: "upc.inf.cfi",
Role: ScenarioRolePrimary,
}},
}
_, _, _, err := s.CalcOptionsFromSpec()
if err == nil {
t.Fatal("want ErrInvalidScenario, got nil")
}
}

View File

@@ -441,6 +441,22 @@ type TimelineEntry struct {
DurationValue int `json:"durationValue,omitempty"`
DurationUnit string `json:"durationUnit,omitempty"`
Timing string `json:"timing,omitempty"`
// AppealRole carries the rule's appeal-filer role (t-paliad-307 /
// m/paliad#136 Bug 1) when the timeline was computed under an
// appeal_target filter. One of AppealRoleAppellant /
// AppealRoleAppellee, or empty for court events / non-appeal
// timelines. The frontend column-bucketer reads this to route
// primary_party='both' rules to Berufungskläger vs
// Berufungsbeklagter columns once the user picks a side.
AppealRole string `json:"appealRole,omitempty"`
// IsTriggerEvent marks the synthetic root row that represents the
// decision being appealed (t-paliad-307 / m/paliad#136 Bug 2).
// Distinct from IsRootEvent in that the row carries no real rule
// id — it's a UI marker dated to the trigger date with the
// per-appeal-target label from TriggerEventLabelForAppealTarget.
IsTriggerEvent bool `json:"isTriggerEvent,omitempty"`
}
// RuleCalculation is the single-rule calc response that backs the