Compare commits

...

5 Commits

Author SHA1 Message Date
mAi
87c200a47e feat(t-paliad-265): caret + popover + chip on Verfahrensablauf cards
m/paliad#96 — frontend wiring of the per-event-card choice flow on
both consumer surfaces.

Shared rendering core (verfahrensablauf-core.ts):
- CalculatedDeadline gains choicesOffered + appellantContext (mirror
  the new server fields).
- deadlineCardHtml emits a ▾ caret next to the date when a rule
  carries a non-empty choicesOffered, plus an inert chip span next to
  the title that the popover module rehydrates after every render.
- bucketDeadlinesIntoColumns prefers appellantContext over the
  page-level appellant for "both" rows when the per-card context is
  set to claimant or defendant. "both" / "none" / "" all fall back to
  the existing collapse logic. New test cases cover all three paths.
- CalcParams + calculateDeadlines pass projectId / perCardChoices
  through to the backend.

New module (client/views/event-card-choices.ts):
- attachEventCardChoices wires a delegated click handler on the
  result container; the caret opens a body-anchored popover with one
  block per choice-kind the rule offers (appellant: 4 radio-style
  buttons; include_ccr + skip: 2-way toggle).
- Active picks render as small chips on the card title; reseedChips()
  repaints them after every renderResults() innerHTML rewrite.
- Skipped rows fade to 55% opacity via the timeline-item--skipped
  class.

Page wiring:
- /tools/verfahrensablauf (unbound): commits mutate an in-memory list
  + the ?event_choices= URL param, then schedule a recalc. Shareable
  via link, no persistence — same idiom as ?side= / ?appellant=.
- /tools/fristenrechner (project-bound): commits POST/DELETE to
  /api/projects/{id}/event-choices. The next calculate() call sends
  projectId so the server folds the persisted choices in.

i18n: 17 new keys under choices.* (DE primary + EN secondary). Caret
title, appellant/include_ccr/skip block titles + value labels, chip
labels, reset action, commit error toast.

CSS: caret, popover, options, chip parts, skipped-row fade.

Tests: 3 new bucketer cases covering AppellantContext propagation
(157 frontend tests pass).
2026-05-25 16:45:39 +02:00
mAi
bf60fc1400 feat(t-paliad-265): projection engine + HTTP handlers for per-card choices
m/paliad#96 — slice A engine + slice B engine wired together (per
m's Q4 bundling decision in §11 of the design doc).

Engine (internal/services/fristenrechner.go):
- CalcOptions gains PerCardAppellant map, SkipRules set, IncludeCCRFor
  set. All three keyed by paliad.deadline_rules.submission_code (same
  key AnchorOverrides uses).
- UIDeadline gains AppellantContext (per-decision pick that propagates
  to descendants via parent_id chain) + ChoicesOffered (passes the
  jsonb through to the frontend so the caret renders).
- Calculate honours all three:
  * IncludeCCRFor non-empty → append with_ccr to flag set before gate
    evaluation (v1 simplification documented in CalcOptions comment;
    correct for single-CCR-entry-point proceedings).
  * SkipRules suppression via submission_code match AND parent_id
    cascade (descendants suppress too — one-pass walk in sequence_order).
  * AppellantContext: each rule with its own per-card pick stamps its
    UUID; descendants inherit via parent_id lookup; "" = no override.

HTTP:
- /api/projects/{id}/event-choices GET / PUT / DELETE — full CRUD
  with visibility gate, audit-logged via paliad.system_audit_log.
- POST /api/tools/fristenrechner accepts either projectId (server
  pulls choices from project_event_choices) OR inline perCardChoices
  (unbound /tools/verfahrensablauf surface). Inline wins when both.

Services wiring:
- EventChoiceService instantiated in cmd/server/main.go; threaded into
  handlers.dbServices.eventChoice.
2026-05-25 16:45:21 +02:00
mAi
dc47ea7f43 feat(t-paliad-265): migration 129 + EventChoiceService (Slice A foundation)
m/paliad#96 — per-event-card optional choices on the Verfahrensablauf
timeline. This commit lands the schema + service layer.

Migration 129:
- paliad.project_event_choices table (project_id, submission_code,
  choice_kind ∈ {appellant, include_ccr, skip}, choice_value) with
  UNIQUE(project_id, submission_code, choice_kind) for idempotent
  re-pick, RLS via paliad.can_see_project.
- paliad.deadline_rules.choices_offered jsonb — opt-in declaration of
  which choice-kinds each rule offers. Seeded for every decision rule
  (appellant), every priority='optional' rule (skip), and the two
  Klageerwiderung rules (upc.inf.cfi.sod + de.inf.lg.erwidg) with
  include_ccr.

Live verification before authoring:
- rule_code is NULL on every decision row → submission_code is the
  join key (matches AnchorOverrides plumbing in fristenrechner.go).
- upc.inf.cfi.sod is the UPC Klageerwiderung, not upc.inf.cfi.def
  (rejected the design doc's first guess; SELECT name ILIKE
  'Klageerwiderung' confirmed).

Go service:
- models.ProjectEventChoice + DeadlineRule.ChoicesOffered.
- EventChoiceService: ListForProject / Upsert (with audit-log row to
  paliad.system_audit_log) / Delete. Pure-helper ToCalcOptionsAddendum
  + per-kind value validation + unit tests.

Design: docs/design-event-card-choices-2026-05-25.md §3 + §6.
2026-05-25 16:45:07 +02:00
mAi
169ace5d26 design(t-paliad-265): fold m's decisions into the design doc
Four open questions answered via AskUserQuestion (2026-05-25):

  Q1 State location       → persisted table          (matches (R))
  Q2 Affordance           → caret + popover          (matches (R))
  Q3 Appellant layer      → per-card overrides       (matches (R))
  Q4 Slice order          → bundle A + B             (over (R) of "A first")

Q4 captured with rationale: cohesive PR, single user-visible release,
no half-shipped state where the include-CCR popover would exist
without the engine wire-through. Coder still organises commits per
slice internally; one branch, one ship.
2026-05-25 16:27:30 +02:00
mAi
ac7bc27fb7 design(t-paliad-265): per-event-card optional choices on Verfahrensablauf
Draft inventor design for m/paliad#96 — per-card affordances driving
projection state: appellant per decision, include-CCR on Klageerwiderung,
skip optional events.

Persisted choices in new paliad.project_event_choices table; opt-in
declared via choices_offered jsonb on paliad.deadline_rules. Caret +
popover affordance; chip indicators on cards with non-default picks.
Two-slice plan: A=appellant+skip (engine-stable), B=include-CCR.

m's decisions section to be filled after the AskUserQuestion round.
2026-05-25 16:27:30 +02:00
21 changed files with 2016 additions and 11 deletions

View File

@@ -218,6 +218,8 @@ func main() {
// is captured into __meta of every export and printed in the
// embedded README.
Export: services.NewExportService(pool, branding.Name),
// t-paliad-265 / m/paliad#96 — per-event-card optional choices.
EventChoice: services.NewEventChoiceService(pool, projectSvc, users),
}
// t-paliad-246 Slice A — Backup Mode runner. Wired only when

View File

@@ -0,0 +1,492 @@
# Design — Per-event-card optional choices on the Verfahrensablauf timeline
**Author:** atlas (inventor)
**Date:** 2026-05-25
**Task:** t-paliad-265 (m/paliad#96)
**Branch:** `mai/atlas/inventor-per-event-card`
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
---
## 0. TL;DR
> **m's decisions landed 2026-05-25** — see §11. Persisted table, caret+popover, per-card-overrides-page-level, and m chose to bundle Slice A + Slice B into one coder shift (over the inventor (R) of "Slice A first"). All other picks matched inventor recommendations.
The Verfahrensablauf timeline today carries **two** projection knobs at the page level — `side` (who-we-are) and `appellant` (who-initiated). Both are **global** for the whole timeline. m wants three more knobs, but **per event card**, not page-level:
1. **Appellant per decision card** — if a decision is appealable, the user picks which side appealed (Claimant / Defendant / Both / None). Different decisions in the same timeline can have different appellants.
2. **Include Nichtigkeitswiderklage on Klageerwiderung** — toggling this on a single Klageerwiderung card flips on the existing `with_ccr` flag for everything downstream of that card.
3. **Skip an optional event** — for any rule marked `priority='optional'`, a per-card "don't consider for this case" toggle hides downstream consequences.
The flow these choices drive is **already there**`condition_expr` jsonb gates (`with_ccr`, `with_amend`, `with_cci`) plus the page-level appellant selector. What's missing is (a) **per-card** scope and (b) **per-project persistence**.
Recommendation: persist choices in a new `paliad.project_event_choices` table; expose them through a popover-on-caret affordance on the relevant cards only; map them into the existing `CalcOptions.Flags` + a new per-rule `Appellants` map at projection time. Two slices: **Slice A** (appellant-per-decision + skip-optional, narrow + bounded), **Slice B** (include-CCR-on-Klageerwiderung, requires per-card flag-scoping in the projection engine — bigger).
---
## 1. Premises verified live (before designing)
CLAUDE.md / memory / issue text can drift; the live system can't. Each load-bearing premise below was probed against the live DB or live source on 2026-05-25.
### Schema
- **Migration tracker at 127** (`paliad.paliad_schema_migrations`). Next migration: 128. No new table for `project_event_choices` exists today.
- **`paliad.deadline_rules` carries `condition_expr jsonb`** already. The flag-evaluation engine (`internal/services/fristenrechner.go:208 Calculate`, `evalConditionExpr` at line ~947) walks the jsonb tree and skips rules whose gate is unsatisfied. Today's gates are `{"flag":"with_ccr"}`, `{"flag":"with_amend"}`, `{"flag":"with_cci"}`, and `{"op":"and","args":[…]}` combinations.
- **`with_ccr` is the existing Nichtigkeitswiderklage gate.** Verified live: 7 upc.inf.cfi rules gate on it (`upc.inf.cfi.reply`, `…rejoin`, `…ccr`, `…def_to_ccr`, `…reply_def_ccr`, `…rejoin_reply_ccr`, plus `upc.inf.cfi.app_to_amend` which additionally requires `with_amend`).
- **`priority` column** has 4 values: `mandatory`, `recommended`, `optional`, `informational`. Live counts (deadline_rules table-wide): 230 mandatory / 18 recommended / 6 optional / (informational not in count, must be 0 or absent). The "skip optional" affordance keys off `priority='optional'`.
- **`event_type` discriminator** exists with values `filing`, `decision`, `hearing`. The "appellant-per-decision" affordance keys off `event_type='decision'`. Live: every decision rule has `primary_party='court'`.
- **`paliad.projects.our_side`** exists (column added before mig 112; values today include `claimant|defendant|applicant|appellant|respondent|third_party|other`). It is the broad project-level side axis t-paliad-257 / #88 hooked into.
- **NO `appellant` column on `paliad.projects`** — the appellant axis lives only in the URL query (`?appellant=claimant|defendant`) in `client/verfahrensablauf.ts:73-89`.
### Frontend
- `frontend/src/client/views/verfahrensablauf-core.ts` is the **shared rendering core** for both `/tools/verfahrensablauf` and `/tools/fristenrechner`. Per-card UI affordances added here surface on both pages automatically.
- `bucketDeadlinesIntoColumns(deadlines, {side, appellant})` (line 496) is the **pure routing primitive**; column placement is computed without DOM. Unit-tested in `verfahrensablauf-core.test.ts`.
- `deadlineCardHtml(dl, {showParty, editable, showNotes})` (line 254) is the **per-card renderer**. There is no per-card props channel for "choices" yet — that's the surface this design extends.
- `client/verfahrensablauf.ts` and `client/fristenrechner.ts` both manage `currentSide` + `currentAppellant` in-memory and round-trip them through the URL (`writeSideToURL` / `writeAppellantToURL`). The pattern is mature; this design mirrors it for the new state when state stays URL-bound, and lifts it into a server-persisted store when state stays per-project.
- `APPELLANT_AXIS_PROCEEDINGS` set (verfahrensablauf.ts:52-62) gates the page-level appellant selector to appeal-flavoured proceedings only. The per-card appellant affordance MUST NOT depend on this set — any first-instance decision is a potential appeal trigger (e.g. LG-Urteil → Berufung, BPatG-Entscheidung → BGH-Rechtsbeschwerde).
### Surfaces in scope
- **`/tools/verfahrensablauf`** — abstract browse, no project context. Per-card choices here are ephemeral (URL-bound) — there's no project to persist into.
- **`/tools/fristenrechner`** — concrete projection, optionally project-bound via `?project=<id>` (`currentStep1Context.kind === "project"`). When project-bound, per-card choices persist to `paliad.project_event_choices`. When unbound, URL only.
- **`/projects/{id}` Verlauf tab (SmartTimeline)** — separate widget (per `docs/design-smart-timeline-2026-05-08.md`); does **NOT** use `renderColumnsBody`. Per-card choices are NOT in scope for the SmartTimeline in v1 — the Verfahrensablauf core is.
### What is NOT premised
- The deadline_rules → procedural_events rename (#93) is **not assumed shipped**. This design uses `deadline_rules`/`rule_code` vocabulary throughout and flags the rename touch-points in §6.
- The per-card UI does NOT require new server-side priority/event_type semantics. Both `priority='optional'` and `event_type='decision'` exist on every row.
---
## 2. Vision + scope
m's vision (verbatim 2026-05-25 15:12):
> We still have no choice to say that a specific party appealed. We may need selections within the event cards on the timeline to change it? For example for a decision we could check Appeal by... or in Klageerwiderung we can chose to include a Nichtigkeitswiderklage. Or with any optional event we can select not to consider it (because someone decided not to file it).
### What changes
- A **caret affordance** (▾) appears on the right edge of cards that have at least one applicable choice-kind. Click → small popover with the choices. Cards without an applicable choice render unchanged.
- A **`choices_offered` jsonb column** on `paliad.deadline_rules` declares which choice-kinds each rule offers. Three kinds in v1:
- `appellant` — applicable to rules with `event_type='decision'` (no static list; engine decides).
- `include_ccr` — applicable to the single Klageerwiderung rule per proceeding (today: `upc.inf.cfi.def`, `de.inf.lg.erwidg`).
- `skip` — applicable to any rule with `priority='optional'`.
- A **new persistence table** `paliad.project_event_choices(project_id, rule_code, choice_kind, choice_value)` holds the user's choices. Per-project, audit-logged via `paliad.system_audit_log`.
- A **projection-time merge** turns the persisted choices into `CalcOptions.Flags` and a new `PerCardAppellants map[ruleCode]string` field, then re-runs the existing projection engine. No new flag types; `with_ccr` is the same `with_ccr`.
### What stays
- `bucketDeadlinesIntoColumns` and `renderColumnsBody` are extended (new opts), not replaced.
- `condition_expr` jsonb gating semantics are unchanged. Per-card `include_ccr` choice simply means "set `with_ccr` in the flag set for this projection" — same engine.
- Page-level `side` / `appellant` selectors stay. The per-card appellant choice is an **override layer** on top of the page-level appellant (Q4 below).
- URL-state plumbing (`?side=…`, `?appellant=…`) stays. The page-level URL params remain the only state for unbound `/tools/verfahrensablauf`.
### Out of scope (v1)
- Per-card choices on the SmartTimeline (project Verlauf tab). Deferred to a follow-up when SmartTimeline matures.
- Versioning of choices over time ("the appellant changed mid-case", "the CCR was withdrawn"). Choices are last-write-wins.
- Cross-project propagation of choices.
- Implementing the choice flow (coder task per slice; this is design-only).
- A "what-if scenarios" mode (saved named scenarios).
---
## 3. Data model
### 3.1 The new table
```sql
-- migration 128_project_event_choices.up.sql
CREATE TABLE paliad.project_event_choices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
rule_code text NOT NULL, -- e.g. "RoP.029.a" or "de.inf.lg.urteil"
choice_kind text NOT NULL, -- 'appellant' | 'include_ccr' | 'skip'
choice_value text NOT NULL, -- value namespace per kind (see §3.3)
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
-- One choice per (project, rule_code, kind). Re-pick is an UPDATE.
UNIQUE (project_id, rule_code, choice_kind)
);
CREATE INDEX project_event_choices_project_idx
ON paliad.project_event_choices (project_id);
-- RLS: same `paliad.can_see_project(project_id)` predicate as paliad.deadlines.
ALTER TABLE paliad.project_event_choices ENABLE ROW LEVEL SECURITY;
CREATE POLICY project_event_choices_select ON paliad.project_event_choices
FOR SELECT USING (paliad.can_see_project(project_id));
CREATE POLICY project_event_choices_mutate ON paliad.project_event_choices
FOR ALL USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
```
**Why this shape:**
- Tall not wide — adding a 4th choice-kind in slice C means one more allowed `choice_kind` value, no DDL.
- `rule_code` is the join key against `paliad.deadline_rules` (which already uses `rule_code` widely — `Calculate`, `AnchorOverrides`, the projection). Stable across rule renames provided the rename keeps the same `rule_code`.
- UNIQUE per `(project, rule_code, kind)` makes the choice idempotent — re-picking the appellant overwrites, doesn't accumulate.
- ON DELETE CASCADE follows the project — when a project is hard-deleted (rare; usually soft-status), the choices go with it.
### 3.2 The opt-in column on `paliad.deadline_rules`
```sql
-- migration 128_project_event_choices.up.sql (same migration)
ALTER TABLE paliad.deadline_rules
ADD COLUMN choices_offered jsonb;
-- Example seeded values (in the same migration's data-fix block):
--
-- upc.inf.cfi.def → '{"include_ccr": [true, false]}'
-- de.inf.lg.erwidg → '{"include_ccr": [true, false]}'
-- upc.inf.cfi.decision → '{"appellant": ["claimant", "defendant", "both", "none"]}'
-- de.inf.lg.urteil → '{"appellant": ["claimant", "defendant", "both", "none"]}'
-- (every event_type='decision' rule)
-- upc.inf.cfi.ccr (priority='optional') → '{"skip": [true, false]}'
-- (every priority='optional' rule)
```
**Alternative considered + rejected:** infer offering at projection-time from `(event_type, priority, submission_code)` heuristics. Rejected because:
- The Klageerwiderung rule is identified only by its `submission_code` slug. Tying the engine to a hardcoded slug list inside the projection service is brittle (mig 124 + future Wave-1 fixes rename slugs); declaring `choices_offered` in data lets the audit ship them without a code change.
- A `skip` toggle that's automatically derived from `priority='optional'` is consistent today but may diverge tomorrow (an optional rule we DON'T want skippable, or a non-optional rule we DO want skippable). The opt-in jsonb keeps the choice axis decoupled from `priority`.
### 3.3 Value namespaces per kind
| `choice_kind` | `choice_value` valid set | Default when no row exists |
|---|---|---|
| `appellant` | `"claimant"` / `"defendant"` / `"both"` / `"none"` | inherits page-level appellant (URL `?appellant=`), else `null` (treated as "not yet picked" — render appeal-deadlines greyed) |
| `include_ccr` | `"true"` / `"false"` | `"false"` (no CCR until user opts in — matches current default flag set) |
| `skip` | `"true"` / `"false"` | `"false"` (rule renders normally) |
Values are stored as `text` not `boolean` so the same column scales to multi-valued kinds (appellant has 4 values; future kinds may have N). Coercion lives in the service layer.
### 3.4 Audit trail
Every INSERT / UPDATE / DELETE on `project_event_choices` writes a row to `paliad.system_audit_log` (the standard sink mig 102 introduced) with `event_type='project_event_choice.set'` and the changed `(rule_code, kind, value)` in `metadata jsonb`. Pattern mirrors `paliad.deadlines.status_changed` audit rows.
---
## 4. Projection flow
The existing projection engine is a single Go function: `FristenrechnerService.Calculate(ctx, proceedingCode, triggerDateStr, opts CalcOptions)`. Two changes:
### 4.1 Extending `CalcOptions`
```go
type CalcOptions struct {
// ...existing fields...
Flags []string // <-- already exists
AnchorOverrides map[string]string // <-- already exists
// NEW — per-card overrides surfaced by the per-event-card choices.
// Keyed by deadline_rules.rule_code.
//
// PerCardAppellant: when a decision rule's rule_code is in this map,
// the appellant for downstream rules whose parent is THAT decision
// is set to the value here. Overrides any global Appellant.
//
// SkipRules: when a rule's rule_code is in this set, the rule is
// suppressed AND its descendants are suppressed. Same suppression
// path as a failed condition_expr gate.
//
// IncludeCCRFor: when a rule's rule_code is in this set, the with_ccr
// flag is treated as set in the flag context FROM that rule
// onward (i.e. for that rule's descendants). On v1 with a single
// Klageerwiderung-per-proceeding, this is equivalent to a project-
// wide with_ccr — but the per-card scope leaves room for future
// proceedings with multiple CCR entry points.
PerCardAppellant map[string]string // rule_code → "claimant"|"defendant"|"both"|"none"
SkipRules map[string]struct{} // set of rule_code
IncludeCCRFor map[string]struct{} // set of rule_code
}
```
The handler reads `project_event_choices` for the project (if project-bound) and folds them into these fields before calling `Calculate`. When called unbound (URL-only, `/tools/verfahrensablauf` without project), the maps come from URL params instead (see §5.2).
### 4.2 Three engine changes
1. **SkipRules suppression**: in the post-condition_expr filter pass (`Calculate` around line 333 where the gate is evaluated), additionally drop any rule whose `rule_code ∈ opts.SkipRules`. Also drop its descendants (existing `parent_id` walk already handles cascading; just add the new predicate to the keep/drop decision).
2. **IncludeCCRFor scope**: rather than threading a per-rule flag context (expensive change to engine), implement v1 as: **if any rule_code in IncludeCCRFor exists at all, append `"with_ccr"` to `opts.Flags`** before the gate-evaluation pass. This is correct for the v1 surface (Klageerwiderung is the only CCR-entry-point per proceeding) but loses the per-card scoping for multi-CCR cases. The full per-rule scope is **Slice B** (§7).
3. **PerCardAppellant routing**: when `bucketDeadlinesIntoColumns` collapses `party=both` rows in the appellant's column, today it consults the global `opts.appellant`. Extend to consult `PerCardAppellant[ruleCode]` first — if present, that drives the collapse for descendants of that decision. Out-of-band: this changes the projection contract subtly. We surface this as **server-computed metadata** on the response (`CalculatedDeadline.AppellantContext`) so the frontend bucketer doesn't need to know about parent-chain walks — the server already does the walk.
### 4.3 Wire shape
The `CalculatedDeadline` Go struct + TS mirror grow one optional field:
```go
type CalculatedDeadline struct {
// ...existing fields...
AppellantContext string `json:"appellantContext,omitempty"`
// "claimant" | "defendant" | "both" | "none" | "" (default).
// Filled by the projection from the user's per-decision choice.
// Frontend bucketer prefers this over the page-level appellant.
}
```
This keeps the bucketer logic local — no second pass needed.
---
## 5. UI / i18n
### 5.1 Caret + popover affordance
Each rendered card gets, when `choices_offered IS NOT NULL`, a `▾` caret on the right edge of the title line. Click → popover anchored to the caret. Popover renders one block per choice-kind the rule offers (typically one, occasionally two if a rule has both `appellant` and `skip` — none today; design holds for the future).
DOM-wise: `frontend/src/client/views/verfahrensablauf-core.ts` `deadlineCardHtml` grows a `choicesCaret` segment, and a sibling module `client/views/event-card-choices.ts` (new) owns the popover open/close + commit handler. The popover commits via `POST /api/projects/{id}/event-choices` with body `{rule_code, kind, value}`; the response is the updated choice row.
**Why a popover and not inline checkboxes:**
- Inline would put a checkbox on every decision card + every optional card. ~6 decision cards + ~6 optional cards on a typical UPC.INF.CFI projection is ~12 always-on widgets per timeline. Visual noise + scan cost.
- Popover defaults to hidden; the caret is a low-noise affordance. The selected choice surfaces as a small chip on the card title line ("Berufung: Beklagter") so the choice is glanceable without re-opening.
- Mobile + touch: the caret is a 24×24 tap target; the popover is keyboard-dismissable.
**Why not card-hover-reveal:** discoverability + touch failure (no hover on iOS).
### 5.2 URL fallback (no project context)
When `/tools/verfahrensablauf` is opened without a project (the abstract-browse case), per-card choices have no persistence layer. The popover still works, but commits update an **in-memory + URL** state instead:
```
?event_choices=RoP.029.a:appellant=defendant,upc.inf.cfi.ccr:skip=true
```
Compact CSV in one URL param. Read at page load, applied to `CalcOptions` via the same `PerCardAppellant` / `SkipRules` / `IncludeCCRFor` route. Shareable, ephemeral. Matches the existing `?side=` + `?appellant=` URL idiom.
### 5.3 Chip indicators
A card with a non-default choice gets a small chip next to the title:
- Appellant chosen: `Berufung: Beklagter` / `Appeal: Defendant`
- Include CCR: `mit Nichtigkeitswiderklage` / `with CCR`
- Skipped: card itself fades to 50% opacity, body adds class `timeline-item--skipped`, chip reads `übersprungen` / `skipped` with an undo arrow.
### 5.4 i18n keys (new)
```
choices.caret.title "Optionen für dieses Ereignis" "Options for this event"
choices.appellant.title "Berufung durch ..." "Appealed by ..."
choices.appellant.claimant "Klägerseite" "Claimant side"
choices.appellant.defendant "Beklagtenseite" "Defendant side"
choices.appellant.both "beide Parteien" "both parties"
choices.appellant.none "keine Berufung" "no appeal"
choices.include_ccr.title "Nichtigkeitswiderklage einbeziehen" "Include nullity counterclaim"
choices.skip.title "Für diese Akte überspringen" "Skip for this case"
choices.skipped.chip "übersprungen" "skipped"
choices.reset "Auswahl zurücksetzen" "Reset choice"
```
### 5.5 What's removed
The page-level appellant selector (URL `?appellant=`) stays for **non-decision proceedings** (the Appeal-CoA case where the appellant axis is the whole-timeline framing, not a per-decision choice). But for first-instance proceedings (UPC.INF, DE.INF.LG, etc.), the appellant axis migrates from page-level to per-decision card. The page-level selector hides when the proceeding has decision rules with `choices_offered.appellant` declared — which is the cleaner UX (one knob, in the right place).
---
## 6. Services + handlers (new surface)
### 6.1 Go service
```go
// internal/services/event_choice_service.go (new)
type EventChoiceService struct {
db *sqlx.DB
}
func (s *EventChoiceService) ListForProject(ctx context.Context, projectID uuid.UUID) ([]ProjectEventChoice, error)
func (s *EventChoiceService) Upsert(ctx context.Context, c ProjectEventChoice) error
func (s *EventChoiceService) Delete(ctx context.Context, projectID uuid.UUID, ruleCode, kind string) error
// Used by ProjectionService to fold choices into CalcOptions.
func (s *EventChoiceService) ToCalcOptions(choices []ProjectEventChoice) CalcOptionsAddendum
```
The `CalcOptionsAddendum` type wraps the three new map/set fields so the merge into the parent `CalcOptions` is one call from the projection handler.
### 6.2 HTTP routes
```
GET /api/projects/{id}/event-choices → []ProjectEventChoice
PUT /api/projects/{id}/event-choices → upsert one (body: {rule_code, kind, value})
DELETE /api/projects/{id}/event-choices/{rule_code}/{kind} → remove
```
All gated by `gateOnboarded` + visibilityPredicate (project-team membership).
### 6.3 Projection handler
The existing `POST /api/tools/fristenrechner` handler accepts `flags`, `anchorOverrides`, `priorityDate`, `courtId`. Extend the request shape:
```json
{
"proceedingType": "upc.inf.cfi",
"triggerDate": "2026-01-15",
"flags": ["with_ccr"],
"perCardChoices": [
{"rule_code": "RoP.029.a", "kind": "appellant", "value": "defendant"},
{"rule_code": "upc.inf.cfi.ccr", "kind": "skip", "value": "true"}
]
}
```
Or, when project-bound:
```json
{
"proceedingType": "upc.inf.cfi",
"triggerDate": "2026-01-15",
"projectId": "abc-123"
// server pulls perCardChoices from paliad.project_event_choices
}
```
The handler merges either source into `CalcOptions` and runs `Calculate`.
### 6.4 Touch points — files coder will edit
- **DB**: new migration `128_project_event_choices.up.sql` + `.down.sql`. Add `choices_offered` column + seed data.
- **Go**: `internal/services/event_choice_service.go` (new), `internal/services/fristenrechner.go` (extend `CalcOptions`, projection logic), `internal/handlers/event_choices.go` (new HTTP routes), `internal/handlers/fristenrechner.go` (request shape extension).
- **Models**: `internal/models/models.go``ProjectEventChoice` struct, `CalculatedDeadline.AppellantContext` field.
- **Frontend**: `frontend/src/client/views/verfahrensablauf-core.ts` (caret + chip in deadlineCardHtml), `frontend/src/client/views/event-card-choices.ts` (new popover module), `frontend/src/client/verfahrensablauf.ts` + `frontend/src/client/fristenrechner.ts` (URL-state plumbing for the unbound case; load project choices for the bound case).
- **i18n**: `frontend/src/client/i18n.ts` + `frontend/src/i18n-keys.ts` — new keys per §5.4.
- **Tests**: `internal/services/event_choice_service_test.go` (new), `internal/services/fristenrechner_test.go` (extend with PerCardAppellant + SkipRules cases), `frontend/src/client/views/verfahrensablauf-core.test.ts` (extend bucketing with `perCardAppellant` opt).
### 6.5 Coordination with #93 procedural-events rename
When #93 lands (and the rename ships), this design's `rule_code` references become `procedural_event.code` — same string namespace, cleaner name. Join points:
- `project_event_choices.rule_code``project_event_choices.procedural_event_code` (or stays as a generic string column if #93 keeps `rule_code` as the join key).
- `deadline_rules.choices_offered``procedural_events.choices_offered`.
If #93 ships first, this design's migration applies to `procedural_events` instead. The data shape (jsonb + new join table) is unaffected. If THIS ships first, #93 absorbs the column in its rename.
---
## 7. Slice plan
### Slice A — Appellant per decision + Skip optional event
Two choice-kinds, narrow + bounded, do not change the gate-evaluation engine.
- **DB**: migration 128 adds `project_event_choices` + `choices_offered`. Seed `choices_offered` on all `event_type='decision'` rules and all `priority='optional'` rules.
- **Service**: `EventChoiceService` CRUD; `CalcOptions.PerCardAppellant` + `CalcOptions.SkipRules`; `Calculate` extension to honour SkipRules suppression + AppellantContext metadata.
- **HTTP**: 3 new routes (GET / PUT / DELETE on project_event_choices); fristenrechner request extension.
- **Frontend**: caret + popover on decision cards + optional cards; chip indicators; URL-state for the unbound case; load-on-mount for the bound case.
- **Tests**: bucketing with PerCardAppellant; service CRUD; gate-suppression with SkipRules.
Ship this slice first. It validates the popover affordance + the persistence layer end-to-end without touching the flag-evaluation engine.
### Slice B — Include Nichtigkeitswiderklage on Klageerwiderung
Wires `IncludeCCRFor` through the flag-evaluation engine. v1 simplification (§4.2 #2) makes this **almost** a no-op for the engine — but the per-card scope semantics need a separate inventor pass to nail down whether the simplification holds for de.inf.lg's CCR analogue (Widerklage auf Nichtigkeit) and for any future proceedings with multiple CCR entry points.
- **DB**: add `include_ccr` to allowed `choice_kind` values + seed `choices_offered = '{"include_ccr": [true, false]}'` on the Klageerwiderung rows (`upc.inf.cfi.def`, `de.inf.lg.erwidg`).
- **Service**: `CalcOptions.IncludeCCRFor`; the "if non-empty, append with_ccr to Flags" simplification.
- **Frontend**: the include_ccr popover block (already designed; just enabling the row).
- **Cross-flow audit**: confirm that the existing 7 upc.inf.cfi cross-flow rules + de.inf.lg analogues fire correctly when with_ccr is set via the per-card path vs. the existing page-level flag checkbox. Existing checkbox stays in v1; deprecation is a Slice C decision.
### Bundling note (per m's Q4 decision 2026-05-25)
A + B ship together. The slice headings above remain as a logical breakdown for the coder to follow when sequencing commits inside the single shift; they are not separate PRs. See §11 Q4 for rationale.
### Slice C — Future choice-kinds
Open-ended; not designed here. Examples surfaced by the t-paliad-067 audit:
- "Bilateral hearing requested" toggle on hearing rules.
- "Cost orders requested" toggle on cost-related rules.
- "Stay applied" toggle on procedural events.
Each new kind = one new allowed `choice_kind` value + one seed row + one popover block. Schema-stable.
---
## 8. Risk assessment
- **Migration risk**: new table + new column, both additive. Down-migration drops table + column + reverts seed. No data loss path. Low risk.
- **Projection correctness**: PerCardAppellant changes the bucket routing for "both" rows in chains downstream of a decision card. The unit-tested `bucketDeadlinesIntoColumns` carries the existing appellant semantics; extending it without breaking the existing test suite means new tests, not changes to existing ones. Coder MUST add the new tests before changing the bucketer.
- **Flag-context vs per-rule-flag aliasing**: §4.2 #2 (Slice B) trades per-card precision for engine simplicity. Acceptable in v1 (Klageerwiderung is the only entry point per proceeding) but a known limitation. Document it in `internal/services/fristenrechner.go` doc comment so the next Wave-2 inventor doesn't think it's bug-free.
- **Page-level vs per-card appellant interaction**: when both are set, per-card wins for descendants of the decision the per-card was set on; page-level still drives descendants of decisions without a per-card pick. Could confuse a user. Mitigation: the page-level appellant selector hides for first-instance proceedings (per §5.5). For appeal proceedings, the selector stays — but those proceedings have a single root decision so the conflict surface is small.
- **Cross-proceeding consistency** (where #93's rename lives) — coordinate with the inventor on #93 if both ship in parallel.
---
## 9. Out of scope (recap)
- SmartTimeline (project Verlauf tab) per-card choices.
- Versioning / time-machine of choices.
- Cross-project propagation.
- Coder implementation (separate task per slice).
- A "saved scenarios" feature.
- Removal of the page-level `?appellant=` URL param for appeal proceedings.
---
## 10. Open questions for m
The following 4 questions need m's pick. Inventor recommendations marked **(R)**. After m answers via AskUserQuestion, the picks land in §11 below as the historical record.
### Q1 — State location
Where do per-card choices live?
- **(R) A. `paliad.project_event_choices` persisted (with URL override for what-if).** Per-case choices are real, not exploratory. Persist by default; what-if exploration handled later as a URL-override layer.
- B. URL query state only. Ephemeral, shareable, no persistence.
- C. Both from day one. Persisted default + URL-overridable for what-if scenarios.
### Q2 — Affordance
How do the choices surface on a card?
- **(R) A. Caret (▾) + popover on click.** Off-by-default visual, on-tap reveal. Selected choice surfaces as a chip on the card title.
- B. Inline checkbox/radio on every relevant card. Higher discoverability, more visual noise.
- C. Card-hover reveals the choices. Discoverability + touch issues.
### Q3 — Page-level appellant interaction
When a per-card appellant is set on a decision, what happens to the page-level `?appellant=` selector?
- **(R) A. Per-card overrides page-level for descendants of THAT decision.** Decisions without a per-card pick still use page-level. Most expressive.
- B. Per-card inherits page-level unless explicitly set. Less surprising default but loses the per-decision expressiveness.
### Q4 — Slice order
Which slice ships first?
- **(R) A. Slice A first (appellant per decision + skip optional).** Bounded, validates the popover + persistence layer without touching the flag-evaluation engine. Slice B (include-CCR) follows.
- B. Slice B first. Higher-impact user feature but requires the engine change.
- C. Bundle A + B in one coder shift. Slower to ship, lower per-coder load, but one less round trip.
---
## 11. m's decisions (2026-05-25)
- **Q1 (State location):** Persisted table — `paliad.project_event_choices` per §3.1. Matches inventor (R).
- **Q2 (Affordance):** Caret + popover with chip indicator on chosen cards per §5.1, §5.3. Matches inventor (R).
- **Q3 (Appellant layer):** Per-card overrides page-level for descendants of that decision. Page-level still drives decisions without a per-card pick. Matches inventor (R). Implementation: `CalculatedDeadline.AppellantContext` (§4.3) carries the per-decision pick down the parent chain so the bucketer reads one field.
- **Q4 (Slice order):** **Bundle Slice A + Slice B in one coder shift** (m picked over inventor (R) of "A first"). Reasoning: keeps the popover, persistence layer, AND the engine extension for `IncludeCCRFor` in one cohesive PR — coder + reviewer hold the full mental model once; one user-visible release; no half-shipped state where the caret exists on Klageerwiderung cards but the include-CCR pick doesn't yet wire through. Trade-off: larger PR. Mitigation: coder still organises commits per slice internally (separate test files, separate handler additions) so review can read them sequentially. See §7 slice plan — both slices implemented; ship as one.
### Coder-shift implications of Q4 bundling
- Migration 128 carries ALL three choice-kinds (`appellant`, `skip`, `include_ccr`) in the seed of `choices_offered`, plus the Klageerwiderung rows seeded with `{"include_ccr": [true, false]}`.
- `CalcOptions` gains all three new fields (`PerCardAppellant`, `SkipRules`, `IncludeCCRFor`) in the same Go change.
- The `IncludeCCRFor` v1 simplification (§4.2 #2 — "any non-empty set means append `with_ccr` to Flags") documents the per-card-scope limitation up front. Multi-CCR proceedings are a future expansion, not a v1 ship blocker.
- Frontend popover renders all three blocks the rule offers in one render path; coder cannot half-ship by leaving include_ccr's popover branch as a TODO.
- Tests cover the full matrix on the same branch.
---
## 12. Hard rules for the coder shift
- Migration is 128, not anything else. Verify against `paliad.paliad_schema_migrations` MAX before authoring.
- Tests added BEFORE projection-engine changes in fristenrechner.go (bucketer, gate, AppellantContext).
- `go build ./... && go test ./internal/... && cd frontend && bun run build` clean.
- No regression on `?side=` + `?appellant=` URL state.
- DE primary, EN secondary for all new i18n keys.
- Branch per slice: `mai/<coder>/event-card-choices-slice-a` etc.
---
## 13. Reporting
When ready, the coder reports completion with the URL of the test project that exercises the feature, a screenshot of the popover, and the deadline-rules SQL UPDATE counts for the seeded `choices_offered` rows. Standard slice-completion shape.

View File

@@ -24,6 +24,12 @@ import {
renderTimelineBody,
wireDateEditClicks,
} from "./views/verfahrensablauf-core";
import {
attachEventCardChoices,
reseedChips,
type EventChoice,
type ChoiceKind,
} from "./views/event-card-choices";
let lastResponse: DeadlineResponse | null = null;
@@ -162,6 +168,13 @@ async function calculate() {
? courtPicker.value
: "";
// t-paliad-265 — when project-bound, the server pulls per-card
// choices from paliad.project_event_choices. The frontend has
// already pre-fetched them into perCardChoicesCache so chip
// indicators repaint in step with the calc; sending projectId here
// is the persistence path.
const projectIdForCalc = currentStep1Context.kind === "project" ? currentStep1Context.projectId : "";
const data = await calculateDeadlines({
proceedingType: selectedType,
triggerDate,
@@ -169,6 +182,7 @@ async function calculate() {
flags,
anchorOverrides: overrides,
courtId,
projectId: projectIdForCalc || undefined,
});
if (seq !== procCalcSeq) return;
if (!data) return;
@@ -439,6 +453,10 @@ function renderProcedureResults(data: DeadlineResponse) {
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
container.innerHTML = headerHtml + bodyHtml;
// t-paliad-265: rehydrate per-event-card chip indicators after the
// innerHTML rewrite. Safe to call before attachEventCardChoices() —
// it no-ops when no state was attached yet.
reseedChips(container);
printBtn.style.display = "block";
if (saveBtn) {
// Ad-hoc explore-mode has no project to save against — show the
@@ -461,6 +479,49 @@ function renderProcedureResults(data: DeadlineResponse) {
applyPendingFocus();
}
// initEventCardChoicesForFristenrechner attaches the per-event-card
// popover to the timeline container. The fristenrechner page is the
// project-bound surface: commits POST/DELETE to the persistence
// endpoint; the next calculate() pulls the fresh state from the
// server. (t-paliad-265)
async function initEventCardChoicesForFristenrechner(container: HTMLElement): Promise<void> {
// Load the current persisted state for the project context, if any.
const initial: EventChoice[] = [];
if (currentStep1Context.kind === "project" && currentStep1Context.projectId) {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(currentStep1Context.projectId)}/event-choices`);
if (resp.ok) {
const rows = (await resp.json()) as EventChoice[];
for (const r of rows) initial.push(r);
}
} catch (e) {
console.error("event-choices: initial load failed", e);
}
}
attachEventCardChoices({
container,
initial,
commit: async (choice) => {
if (currentStep1Context.kind !== "project" || !currentStep1Context.projectId) return;
const resp = await fetch(`/api/projects/${encodeURIComponent(currentStep1Context.projectId)}/event-choices`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(choice),
});
if (!resp.ok) throw new Error(`event-choices PUT ${resp.status}`);
scheduleProcCalc(0);
},
remove: async (submissionCode, kind) => {
if (currentStep1Context.kind !== "project" || !currentStep1Context.projectId) return;
const url = `/api/projects/${encodeURIComponent(currentStep1Context.projectId)}/event-choices/${encodeURIComponent(submissionCode)}/${encodeURIComponent(kind)}`;
const resp = await fetch(url, { method: "DELETE" });
if (!resp.ok && resp.status !== 404) throw new Error(`event-choices DELETE ${resp.status}`);
scheduleProcCalc(0);
},
});
}
// onDateEditCommit is the click-to-edit callback handed to the shared
// wireDateEditClicks() helper: persist the per-rule override (empty value
// clears it) then recompute so downstream rules re-anchor.
@@ -648,6 +709,15 @@ document.addEventListener("DOMContentLoaded", () => {
const timelineContainer = document.getElementById("timeline-container");
if (timelineContainer) wireDateEditClicks(timelineContainer, onDateEditCommit);
// t-paliad-265 — per-event-card choices. Project-bound surface, so
// commits POST to /api/projects/{id}/event-choices. The popover
// module owns the popover; this page owns the recalc trigger. When
// there's no project context yet (Step 1 not picked), the popover
// still works but commits silently no-op (project_id missing).
if (timelineContainer) {
void initEventCardChoicesForFristenrechner(timelineContainer);
}
// Reset button
document.getElementById("reset-btn")!.addEventListener("click", reset);

View File

@@ -306,6 +306,24 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.col.court": "Gericht",
"deadlines.col.opponent": "Gegnerseite",
"deadlines.col.both": "Beide Parteien",
// t-paliad-265 — per-event-card choice popover (Verfahrensablauf timeline)
"choices.caret.title": "Optionen für dieses Ereignis",
"choices.appellant.title": "Berufung durch …",
"choices.appellant.claimant": "Klägerseite",
"choices.appellant.defendant": "Beklagtenseite",
"choices.appellant.both": "beide Parteien",
"choices.appellant.none": "keine Berufung",
"choices.include_ccr.title": "Nichtigkeitswiderklage einbeziehen",
"choices.include_ccr.true": "Ja",
"choices.include_ccr.false": "Nein",
"choices.skip.title": "Für diese Akte überspringen",
"choices.skip.true": "Überspringen",
"choices.skip.false": "Einbeziehen",
"choices.skipped.chip": "übersprungen",
"choices.appellant.chip": "Berufung:",
"choices.include_ccr.chip": "mit Nichtigkeitswiderklage",
"choices.reset": "Auswahl zurücksetzen",
"choices.commit.error": "Konnte Auswahl nicht speichern",
// Trigger-event mode (PR-2 \u2014 youpc-parity)
"deadlines.mode.procedure": "Verfahrensablauf",
"deadlines.mode.event": "Was kommt nach\u2026",
@@ -3373,6 +3391,24 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.col.court": "Court",
"deadlines.col.opponent": "Opponent Side",
"deadlines.col.both": "Both parties",
// t-paliad-265 — per-event-card choice popover (Verfahrensablauf timeline)
"choices.caret.title": "Options for this event",
"choices.appellant.title": "Appeal by …",
"choices.appellant.claimant": "Claimant side",
"choices.appellant.defendant": "Defendant side",
"choices.appellant.both": "both parties",
"choices.appellant.none": "no appeal",
"choices.include_ccr.title": "Include nullity counterclaim",
"choices.include_ccr.true": "Yes",
"choices.include_ccr.false": "No",
"choices.skip.title": "Skip for this case",
"choices.skip.true": "Skip",
"choices.skip.false": "Include",
"choices.skipped.chip": "skipped",
"choices.appellant.chip": "Appeal:",
"choices.include_ccr.chip": "with nullity counterclaim",
"choices.reset": "Reset choice",
"choices.commit.error": "Could not save selection",
"deadlines.adjusted": "Adjusted",
"deadlines.adjusted.reason": "weekend/holiday",
"deadlines.adjusted.weekend": "weekend",

View File

@@ -21,6 +21,13 @@ import {
renderTimelineBody,
wireDateEditClicks,
} from "./views/verfahrensablauf-core";
import {
attachEventCardChoices,
reseedChips,
currentChoices,
type EventChoice,
type ChoiceKind,
} from "./views/event-card-choices";
let selectedType = "";
let lastResponse: DeadlineResponse | null = null;
@@ -98,6 +105,37 @@ function writeAppellantToURL(a: Side) {
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[] = [];
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);
}
type ProcedureView = "timeline" | "columns";
let procedureView: ProcedureView = "columns";
@@ -210,6 +248,7 @@ async function doCalc() {
flags: readFlags(),
anchorOverrides: overrides,
courtId,
perCardChoices,
});
if (seq !== calcSeq) return;
if (!data) return;
@@ -302,6 +341,11 @@ function renderResults(data: DeadlineResponse) {
if (toggle) toggle.style.display = "";
syncTriggerEventLabel();
// t-paliad-265: rehydrate per-event-card chip indicators after every
// re-render so the popover-driven active state survives the
// innerHTML rewrite the timeline body just did.
reseedChips(container);
}
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
@@ -529,6 +573,34 @@ 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();
const timelineEl = document.getElementById("timeline-container");
if (timelineEl) {
attachEventCardChoices({
container: timelineEl,
initial: perCardChoices,
commit: (choice) => {
perCardChoices = perCardChoices.filter(
(c) => !(c.submission_code === choice.submission_code && c.choice_kind === choice.choice_kind),
);
perCardChoices.push(choice);
writeChoicesToURL(perCardChoices);
scheduleCalc(0);
},
remove: (submissionCode, kind) => {
perCardChoices = perCardChoices.filter(
(c) => !(c.submission_code === submissionCode && c.choice_kind === kind),
);
writeChoicesToURL(perCardChoices);
scheduleCalc(0);
},
});
}
onLangChange(() => {
// Active-button name updates with language change (the data-i18n
// pass swaps the inner <strong>'s text). Re-collapse the summary

View File

@@ -0,0 +1,292 @@
// Per-event-card choice popover + chip indicator (t-paliad-265 /
// m/paliad#96).
//
// The shared rendering core (verfahrensablauf-core.ts) emits a caret
// button on cards that carry a non-empty `choices_offered` declaration
// and an inert chip span next to the title. This module:
//
// 1. Wires a delegated click handler on the result container so the
// caret opens a popover with the offered choice-kinds.
// 2. Commits the user's pick — either by POSTing to the project-
// bound endpoint or by mutating the in-memory state for the
// unbound (no-project) case.
// 3. Rehydrates the chip on every render + after every commit so the
// glanceable indicator matches the active state.
//
// Two consumer pages — /tools/verfahrensablauf (unbound) and
// /tools/fristenrechner (project-bound) — both wire this module
// once at boot via attachEventCardChoices().
import { escAttr, escHtml } from "./verfahrensablauf-core";
import { t } from "../i18n";
export type ChoiceKind = "appellant" | "include_ccr" | "skip";
export interface EventChoice {
submission_code: string;
choice_kind: ChoiceKind;
choice_value: string;
}
// State surface — the page passes in callbacks that own persistence.
// commit / remove must trigger a recalc on the page side (the popover
// only owns its own visual state).
export interface EventCardChoicesOpts {
container: HTMLElement;
// Initial state: a list of choices. The page seeds this from the
// server response (project-bound) or from URL params (unbound).
initial: EventChoice[];
// commit gets called for an UPSERT. The page POSTs to the API (or
// mutates URL state) AND triggers a recalc.
commit: (choice: EventChoice) => Promise<void> | void;
// remove gets called when the user resets a choice.
remove: (submissionCode: string, kind: ChoiceKind) => Promise<void> | void;
}
// One mutable bag per attach() call. The current implementation is a
// single-page singleton — paginated views (admin tables) are not in
// scope. Last-write-wins on the in-memory state.
interface AttachedState {
opts: EventCardChoicesOpts;
// active: submission_code → kind → value. Rebuilt from `initial`
// on every reseed() call.
active: Map<string, Map<ChoiceKind, string>>;
popover: HTMLDivElement | null;
}
const states = new WeakMap<HTMLElement, AttachedState>();
// attachEventCardChoices wires the delegated click + popover lifecycle
// to the given container. Call once per page after mount; safe to call
// again with a fresh container.
export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
const state: AttachedState = {
opts,
active: new Map(),
popover: null,
};
for (const c of opts.initial) {
if (!state.active.has(c.submission_code)) {
state.active.set(c.submission_code, new Map());
}
state.active.get(c.submission_code)!.set(c.choice_kind, c.choice_value);
}
states.set(opts.container, state);
opts.container.addEventListener("click", (e) => {
const target = (e.target as HTMLElement | null)?.closest<HTMLElement>(".event-card-choices-caret");
if (target) {
e.stopPropagation();
openPopover(state, target);
return;
}
// Outside-click closes the popover.
if (state.popover && !state.popover.contains(e.target as Node)) {
closePopover(state);
}
});
// ESC also closes.
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && state.popover) {
closePopover(state);
}
});
// Repaint chips on every renderResults() call. The page is
// responsible for calling reseedChips() after re-render so the chip
// dom node (re-created by the renderer) picks the active state up.
reseedChips(opts.container);
}
// reseedChips walks every chip span in the container and re-renders
// its content from the active state map. Idempotent.
export function reseedChips(container: HTMLElement): void {
const state = states.get(container);
if (!state) return;
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
const code = chip.dataset.submissionCode || "";
const kinds = state.active.get(code);
if (!kinds || kinds.size === 0) {
chip.innerHTML = "";
chip.dataset.empty = "true";
return;
}
chip.dataset.empty = "false";
chip.innerHTML = renderChip(kinds);
});
// Skipped rows fade out via a class on the card-item ancestor.
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
const code = chip.dataset.submissionCode || "";
const skipped = state.active.get(code)?.get("skip") === "true";
const itemEl = chip.closest<HTMLElement>(".timeline-item, .fr-col-item");
if (itemEl) itemEl.classList.toggle("timeline-item--skipped", skipped);
});
}
function renderChip(kinds: Map<ChoiceKind, string>): string {
const parts: string[] = [];
if (kinds.get("skip") === "true") {
parts.push(`<span class="event-card-choices-chip-part event-card-choices-chip-part--skipped">${escHtml(t("choices.skipped.chip"))}</span>`);
}
const ap = kinds.get("appellant");
if (ap && ap !== "" ) {
let label = "";
switch (ap) {
case "claimant": label = t("choices.appellant.claimant"); break;
case "defendant": label = t("choices.appellant.defendant"); break;
case "both": label = t("choices.appellant.both"); break;
case "none": label = t("choices.appellant.none"); break;
}
if (label) {
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.appellant.chip"))} ${escHtml(label)}</span>`);
}
}
if (kinds.get("include_ccr") === "true") {
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.include_ccr.chip"))}</span>`);
}
return parts.join(" ");
}
function openPopover(state: AttachedState, caret: HTMLElement): void {
closePopover(state);
const code = caret.dataset.submissionCode || "";
if (!code) return;
let offered: Record<string, unknown> = {};
try {
offered = JSON.parse(caret.dataset.choicesOffered || "{}");
} catch {
return;
}
const pop = document.createElement("div");
pop.className = "event-card-choices-popover";
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-label", t("choices.caret.title"));
const blocks: string[] = [];
if (Array.isArray(offered.appellant)) {
blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[]));
}
if (Array.isArray(offered.include_ccr)) {
blocks.push(renderToggleBlock(state, code, "include_ccr"));
}
if (Array.isArray(offered.skip)) {
blocks.push(renderToggleBlock(state, code, "skip"));
}
pop.innerHTML = blocks.join("");
document.body.appendChild(pop);
state.popover = pop;
positionPopover(pop, caret);
pop.addEventListener("click", async (e) => {
const btn = (e.target as HTMLElement | null)?.closest<HTMLButtonElement>("button[data-choice-action]");
if (!btn) return;
e.stopPropagation();
const kind = btn.dataset.choiceKind as ChoiceKind | undefined;
const value = btn.dataset.choiceValue || "";
const action = btn.dataset.choiceAction;
if (!kind) return;
try {
if (action === "set") {
await state.opts.commit({ submission_code: code, choice_kind: kind, choice_value: value });
if (!state.active.has(code)) state.active.set(code, new Map());
state.active.get(code)!.set(kind, value);
} else if (action === "clear") {
await state.opts.remove(code, kind);
state.active.get(code)?.delete(kind);
}
reseedChips(state.opts.container);
closePopover(state);
} catch (err) {
console.error("event card choice commit failed", err);
// Surface a soft inline error inside the popover; do NOT close.
const errEl = document.createElement("div");
errEl.className = "event-card-choices-error";
errEl.textContent = t("choices.commit.error");
pop.appendChild(errEl);
}
});
}
function renderAppellantBlock(state: AttachedState, code: string, values: unknown[]): string {
const current = state.active.get(code)?.get("appellant") || "";
const buttons = values
.filter((v): v is string => typeof v === "string")
.map((v) => {
const labelKey = `choices.appellant.${v}` as const;
const isActive = v === current;
return `<button type="button"
data-choice-action="set"
data-choice-kind="appellant"
data-choice-value="${escAttr(v)}"
class="event-card-choices-option${isActive ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
})
.join("");
const reset = current
? `<button type="button" data-choice-action="clear" data-choice-kind="appellant"
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
: "";
return `<div class="event-card-choices-block">
<div class="event-card-choices-title">${escHtml(t("choices.appellant.title"))}</div>
<div class="event-card-choices-options">${buttons}</div>
${reset}
</div>`;
}
function renderToggleBlock(state: AttachedState, code: string, kind: "include_ccr" | "skip"): string {
const current = state.active.get(code)?.get(kind) || "false";
const titleKey = kind === "include_ccr" ? "choices.include_ccr.title" : "choices.skip.title";
const trueKey = kind === "include_ccr" ? "choices.include_ccr.true" : "choices.skip.true";
const falseKey = kind === "include_ccr" ? "choices.include_ccr.false" : "choices.skip.false";
const opt = (v: "true" | "false", labelKey: string) => `<button type="button"
data-choice-action="set"
data-choice-kind="${kind}"
data-choice-value="${v}"
class="event-card-choices-option${v === current ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
const reset = state.active.get(code)?.has(kind)
? `<button type="button" data-choice-action="clear" data-choice-kind="${kind}"
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
: "";
return `<div class="event-card-choices-block">
<div class="event-card-choices-title">${escHtml(t(titleKey as any))}</div>
<div class="event-card-choices-options">
${opt("true", trueKey)}
${opt("false", falseKey)}
</div>
${reset}
</div>`;
}
function closePopover(state: AttachedState): void {
if (state.popover) {
state.popover.remove();
state.popover = null;
}
}
function positionPopover(pop: HTMLDivElement, caret: HTMLElement): void {
const rect = caret.getBoundingClientRect();
const scrollY = window.scrollY || document.documentElement.scrollTop;
const scrollX = window.scrollX || document.documentElement.scrollLeft;
pop.style.position = "absolute";
pop.style.top = `${rect.bottom + scrollY + 4}px`;
pop.style.left = `${Math.max(8, rect.right + scrollX - 240)}px`;
pop.style.zIndex = "1000";
}
// Returns the current in-memory choice list for the given container —
// used by the unbound /tools/verfahrensablauf page to keep the URL
// param in sync.
export function currentChoices(container: HTMLElement): EventChoice[] {
const state = states.get(container);
if (!state) return [];
const out: EventChoice[] = [];
state.active.forEach((kinds, code) => {
kinds.forEach((value, kind) => {
out.push({ submission_code: code, choice_kind: kind, choice_value: value });
});
});
return out;
}

View File

@@ -191,6 +191,48 @@ describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad
expect(rows[0].court.map((d) => d.name)).toEqual(["C"]);
});
test("appellantContext overrides the page-level appellant for descendants (t-paliad-265)", () => {
// A per-decision pick stamps AppellantContext on descendants of
// that decision. The bucketer prefers it over the page-level
// appellant: if a "both" row carries appellantContext='defendant',
// it collapses to defendant's column regardless of the global
// appellant opt.
const dl: CalculatedDeadline = {
...both("Notice of Appeal", "2026-07-23"),
appellantContext: "defendant",
};
const rows = bucketDeadlinesIntoColumns([dl], { appellant: "claimant" });
expect(rows[0].ours).toHaveLength(0);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
});
test("appellantContext='claimant' + side='defendant' lands the row in opponent (claimant ≠ us)", () => {
// The user is on the defendant side; per-card pick says the
// claimant appealed. The "both" row collapses to the claimant's
// column, which after the side-swap is opponent (right).
const dl: CalculatedDeadline = {
...both("Notice of Appeal", "2026-07-23"),
appellantContext: "claimant",
};
const rows = bucketDeadlinesIntoColumns([dl], { side: "defendant", appellant: "defendant" });
expect(rows[0].ours).toHaveLength(0);
expect(rows[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
});
test("appellantContext='both' or 'none' falls back to page-level mirror (t-paliad-265)", () => {
// 'both' and 'none' aren't side-collapse values — they're
// statements about who appealed but don't pick a column. The
// bucketer treats them as no override, so the page-level
// appellant (or default mirror) applies.
const both1: CalculatedDeadline = {
...both("Notice of Appeal", "2026-07-23"),
appellantContext: "both",
};
const rowsBoth = bucketDeadlinesIntoColumns([both1]);
expect(rowsBoth[0].ours.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rowsBoth[0].opponent.map((d) => d.name)).toEqual(["Notice of Appeal"]);
});
test("unscheduled rows (no dueDate) trail dated rows, preserving declaration order", () => {
const rows = bucketDeadlinesIntoColumns([
partySpecific("court", "Oral Hearing", ""),

View File

@@ -61,6 +61,17 @@ export interface CalculatedDeadline {
// Frontend save-modal logic doesn't read this; the rule editor
// (Slice 11) is the consumer. Unknown shape on this side — pass-through.
conditionExpr?: unknown;
// choicesOffered (t-paliad-265): declares which per-card choice-kinds
// this rule offers on the Verfahrensablauf timeline. Object shape:
// { appellant?: string[], include_ccr?: [true,false], skip?: [true,false] }.
// null/undefined = no caret affordance.
choicesOffered?: Record<string, unknown>;
// appellantContext (t-paliad-265): the per-decision appellant pick
// that applies to descendants of the closest ancestor decision card
// with a per-card appellant set. Empty = no per-card override (the
// page-level appellant axis still applies in that case). The bucketer
// reads this in preference to the page-level appellant.
appellantContext?: string;
}
// priorityRendering returns the per-priority UX hints the save-modal
@@ -139,6 +150,16 @@ export interface CalcParams {
flags?: string[];
anchorOverrides?: Record<string, string>;
courtId?: string;
// t-paliad-265: per-event-card choices. Either pass `projectId` for
// server-side lookup against paliad.project_event_choices, OR pass
// an inline list (for the unbound /tools/verfahrensablauf surface).
// When both are supplied the inline list wins server-side.
projectId?: string;
perCardChoices?: Array<{
submission_code: string;
choice_kind: string;
choice_value: string;
}>;
}
const PARTY_CLASS: Record<string, string> = {
@@ -272,6 +293,18 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
? '<span class="optional-badge">optional</span>'
: "";
// t-paliad-265 — caret affordance + chip indicator when this rule
// offers per-card choices and the user has made a pick. The popover
// open/commit lifecycle lives in client/views/event-card-choices.ts;
// the data-* attributes here are the wire contract between the two.
const choicesHtml = dl.code !== "" && dl.choicesOffered && Object.keys(dl.choicesOffered).length > 0
? `<button type="button" class="event-card-choices-caret"
data-submission-code="${escAttr(dl.code)}"
data-choices-offered="${escAttr(JSON.stringify(dl.choicesOffered))}"
aria-label="${escAttr(t("choices.caret.title"))}"
title="${escAttr(t("choices.caret.title"))}">▾</button>`
: "";
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
const adjustedNote = dl.wasAdjusted
@@ -310,12 +343,22 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
</div>`
: "";
// Chip indicator surfaces the active per-card pick (t-paliad-265).
// The popover module rehydrates this on commit so it stays in sync.
const chipHtml = dl.code !== ""
? `<span class="event-card-choices-chip"
data-submission-code="${escAttr(dl.code)}"
data-empty="true"></span>`
: "";
return `<div class="timeline-item-header">
<span class="timeline-name">
${dlName}
${mandatoryBadge}
${chipHtml}
</span>
${dateStr}
${choicesHtml}
</div>
${meta}
${adjustedNote}
@@ -532,7 +575,15 @@ export function bucketDeadlinesIntoColumns(
row.court.push(dl);
break;
case "both":
if (appellantColumn !== null) {
// t-paliad-265: a per-card appellant set on a decision
// ancestor propagates as appellantContext on this rule. When
// present, it overrides the page-level appellant for the
// collapse decision on THIS row. Falls through to page-level
// when empty.
if (dl.appellantContext === "claimant" || dl.appellantContext === "defendant") {
const perCardCol = dl.appellantContext === "claimant" ? claimantColumn : defendantColumn;
row[perCardCol].push(dl);
} else if (appellantColumn !== null) {
// Role-swap collapse: appellant initiated → both → one row
// in appellant's column. Mirror suppressed.
row[appellantColumn].push(dl);
@@ -625,6 +676,10 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
? params.anchorOverrides
: undefined,
courtId: params.courtId || undefined,
projectId: params.projectId || undefined,
perCardChoices: params.perCardChoices && params.perCardChoices.length > 0
? params.perCardChoices
: undefined,
}),
});
if (!resp.ok) {

View File

@@ -1008,6 +1008,23 @@ export type I18nKey =
| "checklisten.tab.mine"
| "checklisten.tab.templates"
| "checklisten.title"
| "choices.appellant.both"
| "choices.appellant.chip"
| "choices.appellant.claimant"
| "choices.appellant.defendant"
| "choices.appellant.none"
| "choices.appellant.title"
| "choices.caret.title"
| "choices.commit.error"
| "choices.include_ccr.chip"
| "choices.include_ccr.false"
| "choices.include_ccr.title"
| "choices.include_ccr.true"
| "choices.reset"
| "choices.skip.false"
| "choices.skip.title"
| "choices.skip.true"
| "choices.skipped.chip"
| "common.cancel"
| "common.close"
| "common.forbidden"

View File

@@ -3476,6 +3476,133 @@ input[type="range"]::-moz-range-thumb {
color: var(--status-amber-fg);
}
/* t-paliad-265 — per-event-card optional choices. The caret sits in
* the card header next to the date; the chip surfaces the active pick
* inline with the title; the popover is body-attached and positioned
* by the JS module. Skipped rows fade to 50% opacity. */
.event-card-choices-caret {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
margin-left: 0.4rem;
border: 1px solid var(--color-border, #d4d4d4);
border-radius: 4px;
background: transparent;
color: var(--color-text-muted);
font-size: 0.85rem;
cursor: pointer;
line-height: 1;
}
.event-card-choices-caret:hover,
.event-card-choices-caret:focus-visible {
background: var(--color-accent-bg, rgba(198, 244, 28, 0.18));
color: var(--color-text);
}
.event-card-choices-chip {
display: inline-flex;
gap: 0.3rem;
margin-left: 0.4rem;
}
.event-card-choices-chip[data-empty="true"] {
display: none;
}
.event-card-choices-chip-part {
font-size: 0.7rem;
font-weight: 500;
padding: 0.05rem 0.4rem;
border-radius: 99px;
background: var(--color-accent-bg, rgba(198, 244, 28, 0.22));
color: var(--color-text);
}
.event-card-choices-chip-part--skipped {
background: var(--color-bg-soft, #f1f1f1);
color: var(--color-text-muted);
text-decoration: line-through;
}
.timeline-item--skipped {
opacity: 0.55;
}
.event-card-choices-popover {
background: var(--color-bg, #fff);
border: 1px solid var(--color-border, #d4d4d4);
border-radius: 6px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
padding: 0.6rem 0.7rem;
min-width: 240px;
max-width: 320px;
}
.event-card-choices-block + .event-card-choices-block {
margin-top: 0.7rem;
padding-top: 0.6rem;
border-top: 1px solid var(--color-border-soft, #ececec);
}
.event-card-choices-title {
font-size: 0.78rem;
font-weight: 600;
color: var(--color-text);
margin-bottom: 0.4rem;
}
.event-card-choices-options {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.event-card-choices-option {
font-size: 0.78rem;
padding: 0.25rem 0.55rem;
border-radius: 4px;
border: 1px solid var(--color-border, #d4d4d4);
background: var(--color-bg, #fff);
color: var(--color-text);
cursor: pointer;
}
.event-card-choices-option:hover,
.event-card-choices-option:focus-visible {
background: var(--color-bg-soft, #f1f1f1);
}
.event-card-choices-option--active {
background: var(--color-accent, #c6f41c);
border-color: var(--color-accent, #c6f41c);
color: var(--color-text);
font-weight: 600;
}
.event-card-choices-reset {
margin-top: 0.5rem;
font-size: 0.72rem;
background: transparent;
border: none;
color: var(--color-text-muted);
text-decoration: underline;
cursor: pointer;
padding: 0;
}
.event-card-choices-reset:hover {
color: var(--color-text);
}
.event-card-choices-error {
margin-top: 0.5rem;
font-size: 0.74rem;
color: var(--status-red-fg, #b00020);
}
.timeline-rule {
font-family: var(--font-mono);
font-size: 0.72rem;

View File

@@ -0,0 +1,11 @@
-- t-paliad-265 — drop per-event-card choices schema.
SELECT set_config(
'paliad.audit_reason',
'mig 129 down: drop project_event_choices + deadline_rules.choices_offered',
true);
DROP TABLE IF EXISTS paliad.project_event_choices;
ALTER TABLE paliad.deadline_rules
DROP COLUMN IF EXISTS choices_offered;

View File

@@ -0,0 +1,116 @@
-- t-paliad-265 / m/paliad#96 — per-event-card optional choices on the
-- Verfahrensablauf timeline.
--
-- Design: docs/design-event-card-choices-2026-05-25.md
-- Decisions: see §11 of the design doc.
--
-- Two schema changes:
--
-- 1. paliad.project_event_choices — new persistence table holding the
-- user's per-card picks scoped to a project. One row per
-- (project, submission_code, choice_kind). Re-picking is an UPDATE
-- (UNIQUE constraint enforces idempotence).
--
-- 2. paliad.deadline_rules.choices_offered jsonb — opt-in declaration
-- of which choice-kinds each rule offers. The projection engine
-- reads this to decide whether to render the caret affordance on
-- a card. Seeded for every event_type='decision' rule (appellant),
-- every priority='optional' rule (skip), and the two Klageerwiderung
-- rows (include_ccr).
--
-- NOTE on join key: the design doc named the join column "rule_code".
-- Live verification (2026-05-25 SELECT against paliad.deadline_rules)
-- showed `rule_code` is NULL on every decision row — it's the legal-
-- source citation column, not a stable identifier. The
-- AnchorOverrides plumbing in internal/services/fristenrechner.go
-- already keys on `submission_code` (UIDeadline.Code populates from
-- submission_code, lines 351-352), so we mirror that decision here:
-- the join column is `submission_code`. Same intent, correct field.
--
-- Idempotent: CREATE TABLE IF NOT EXISTS + ADD COLUMN IF NOT EXISTS +
-- UPDATEs guarded by WHERE choices_offered IS NULL so re-applying
-- against an already-seeded DB no-ops.
SELECT set_config(
'paliad.audit_reason',
'mig 129: add paliad.project_event_choices + deadline_rules.choices_offered for per-event-card optional choices (t-paliad-265 / m/paliad#96)',
true);
-- 1. The choice-storage table ----------------------------------------------
CREATE TABLE IF NOT EXISTS paliad.project_event_choices (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
submission_code text NOT NULL,
choice_kind text NOT NULL CHECK (choice_kind IN ('appellant', 'include_ccr', 'skip')),
choice_value text NOT NULL,
created_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
updated_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (project_id, submission_code, choice_kind)
);
CREATE INDEX IF NOT EXISTS project_event_choices_project_idx
ON paliad.project_event_choices (project_id);
ALTER TABLE paliad.project_event_choices ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS project_event_choices_select ON paliad.project_event_choices;
CREATE POLICY project_event_choices_select ON paliad.project_event_choices
FOR SELECT USING (paliad.can_see_project(project_id));
DROP POLICY IF EXISTS project_event_choices_mutate ON paliad.project_event_choices;
CREATE POLICY project_event_choices_mutate ON paliad.project_event_choices
FOR ALL
USING (paliad.can_see_project(project_id))
WITH CHECK (paliad.can_see_project(project_id));
COMMENT ON TABLE paliad.project_event_choices IS
'Per-event-card user picks scoped to a project. choice_kind ∈ {appellant, include_ccr, skip}. '
'choice_value namespace per kind: appellant=claimant|defendant|both|none; include_ccr=true|false; '
'skip=true|false. Join key submission_code matches paliad.deadline_rules.submission_code (the same key '
'AnchorOverrides uses). UNIQUE(project,submission_code,kind) keeps re-picks idempotent. '
'Audit-logged via paliad.system_audit_log (event_type=project_event_choice.set).';
-- 2. The choices_offered opt-in column ------------------------------------
ALTER TABLE paliad.deadline_rules
ADD COLUMN IF NOT EXISTS choices_offered jsonb;
COMMENT ON COLUMN paliad.deadline_rules.choices_offered IS
'Declares which per-card choice-kinds this rule offers on the Verfahrensablauf timeline. '
'NULL = no caret affordance (default). Example shapes: '
'{"appellant": ["claimant","defendant","both","none"]} on decision rules, '
'{"skip": [true, false]} on optional rules, '
'{"include_ccr": [true, false]} on Klageerwiderung rules. '
'Engine and frontend read it; storing per-kind value lists keeps the contract self-describing.';
-- 3. Seed -----------------------------------------------------------------
-- 3a. Every published decision rule offers the appellant choice.
UPDATE paliad.deadline_rules
SET choices_offered = '{"appellant": ["claimant", "defendant", "both", "none"]}'::jsonb
WHERE event_type = 'decision'
AND lifecycle_state = 'published'
AND choices_offered IS NULL;
-- 3b. Every published optional rule offers the skip choice.
UPDATE paliad.deadline_rules
SET choices_offered = '{"skip": [true, false]}'::jsonb
WHERE priority = 'optional'
AND lifecycle_state = 'published'
AND choices_offered IS NULL;
-- 3c. Klageerwiderung rules offer the include_ccr choice. Two rows
-- today (upc.inf.cfi.sod + de.inf.lg.erwidg) — verified live
-- (2026-05-25 SELECT FROM paliad.deadline_rules WHERE name ILIKE
-- 'Klageerwiderung'); the UPC INF Klageerwiderung is `sod` (Statement
-- of Defence, R.24 RoP), not `def`. Slice B (Q4 bundle) is the
-- user-visible feature.
UPDATE paliad.deadline_rules
SET choices_offered = '{"include_ccr": [true, false]}'::jsonb
WHERE submission_code IN ('upc.inf.cfi.sod', 'de.inf.lg.erwidg')
AND lifecycle_state = 'published'
AND choices_offered IS NULL;

View File

@@ -0,0 +1,113 @@
package handlers
// HTTP handlers for paliad.project_event_choices (t-paliad-265 / m/paliad#96).
//
// Three endpoints:
// GET /api/projects/{id}/event-choices → list
// PUT /api/projects/{id}/event-choices → upsert one
// DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}
//
// All three gated by visibility on the project (paliad.can_see_project)
// via EventChoiceService.
import (
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/projects/{id}/event-choices
func handleListProjectEventChoices(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.eventChoice == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "event-choice service not configured"})
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
return
}
rows, err := dbSvc.eventChoice.ListForProject(r.Context(), uid, projectID)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
}
// PUT /api/projects/{id}/event-choices — upsert one row.
func handlePutProjectEventChoice(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.eventChoice == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "event-choice service not configured"})
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
return
}
var input services.UpsertEventChoiceInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
row, err := dbSvc.eventChoice.Upsert(r.Context(), uid, projectID, input)
if err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
}
// DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}
func handleDeleteProjectEventChoice(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.eventChoice == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "event-choice service not configured"})
return
}
projectID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
return
}
submissionCode := r.PathValue("submission_code")
choiceKind := r.PathValue("choice_kind")
if err := dbSvc.eventChoice.Delete(r.Context(), uid, projectID, submissionCode, choiceKind); err != nil {
if errors.Is(err, services.ErrInvalidInput) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}

View File

@@ -5,6 +5,9 @@ import (
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
)
@@ -51,6 +54,15 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
Flags []string `json:"flags,omitempty"`
AnchorOverrides map[string]string `json:"anchorOverrides,omitempty"`
CourtID string `json:"courtId,omitempty"`
// t-paliad-265: per-event-card choices. Two parallel inputs:
// - ProjectID lets the server pull persisted choices from
// paliad.project_event_choices (project-bound /tools/fristenrechner).
// - PerCardChoices lets the unbound /tools/verfahrensablauf
// send an inline-CSV-decoded list straight off the URL
// without persisting. When both are present the inline list
// wins (what-if exploration overrides the saved state).
ProjectID string `json:"projectId,omitempty"`
PerCardChoices []services.UpsertEventChoiceInput `json:"perCardChoices,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
@@ -61,11 +73,42 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
return
}
// Fold per-card choices into the CalcOptions addendum. The inline
// PerCardChoices wins over the persisted ProjectID lookup when both
// are non-empty.
var addendum services.CalcOptionsAddendum
if len(req.PerCardChoices) > 0 {
choices := make([]models.ProjectEventChoice, 0, len(req.PerCardChoices))
for _, c := range req.PerCardChoices {
choices = append(choices, models.ProjectEventChoice{
SubmissionCode: c.SubmissionCode,
ChoiceKind: c.ChoiceKind,
ChoiceValue: c.ChoiceValue,
})
}
addendum = services.ToCalcOptionsAddendum(choices)
} else if req.ProjectID != "" && dbSvc.eventChoice != nil {
if pid, err := uuid.Parse(req.ProjectID); err == nil {
if uid, ok := requireUser(w, r); ok {
if choices, err := dbSvc.eventChoice.ListForProject(r.Context(), uid, pid); err == nil {
addendum = services.ToCalcOptionsAddendum(choices)
}
// Visibility-filtered lookup: a non-visible project
// returns ErrNotVisible from ListForProject; in that
// case we project without per-card overlays rather
// than 404 — the timeline itself is non-PII data.
}
}
}
resp, err := dbSvc.fristenrechner.Calculate(r.Context(), req.ProceedingType, req.TriggerDate, services.CalcOptions{
PriorityDateStr: req.PriorityDate,
Flags: req.Flags,
AnchorOverrides: req.AnchorOverrides,
CourtID: req.CourtID,
PriorityDateStr: req.PriorityDate,
Flags: req.Flags,
AnchorOverrides: req.AnchorOverrides,
CourtID: req.CourtID,
PerCardAppellant: addendum.PerCardAppellant,
SkipRules: addendum.SkipRules,
IncludeCCRFor: addendum.IncludeCCRFor,
})
if err != nil {
if errors.Is(err, services.ErrUnknownProceedingType) {

View File

@@ -106,6 +106,10 @@ type Services struct {
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
SubmissionDraft *services.SubmissionDraftService
// t-paliad-265 / m/paliad#96 — per-event-card optional choices on
// the Verfahrensablauf timeline.
EventChoice *services.EventChoiceService
// 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
@@ -169,6 +173,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
export: svc.Export,
backup: svc.Backup,
submissionDraft: svc.SubmissionDraft,
eventChoice: svc.EventChoice,
}
}
@@ -390,6 +395,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/projects/{id}/partner-units", handleAttachPartnerUnit)
protected.HandleFunc("DELETE /api/projects/{id}/partner-units/{unit_id}", handleDetachPartnerUnit)
// t-paliad-265 — per-event-card choices on the Verfahrensablauf timeline.
protected.HandleFunc("GET /api/projects/{id}/event-choices", handleListProjectEventChoices)
protected.HandleFunc("PUT /api/projects/{id}/event-choices", handlePutProjectEventChoice)
protected.HandleFunc("DELETE /api/projects/{id}/event-choices/{submission_code}/{choice_kind}", handleDeleteProjectEventChoice)
// Partner units (structural partner-led units; legacy "Dezernate").
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit)

View File

@@ -68,6 +68,9 @@ type dbServices struct {
// t-paliad-238 — submission draft editor.
submissionDraft *services.SubmissionDraftService
// t-paliad-265 — per-event-card optional choices.
eventChoice *services.EventChoiceService
}
var dbSvc *dbServices

View File

@@ -682,6 +682,13 @@ type DeadlineRule struct {
// NULL while draft, set on publish, retained through archive.
// Distinct from UpdatedAt (moves on every edit).
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
// ChoicesOffered declares which per-event-card choice-kinds this
// rule offers on the Verfahrensablauf timeline (mig 129,
// t-paliad-265). NULL = no caret affordance (default). See the
// COMMENT on paliad.deadline_rules.choices_offered for the value
// shape. The engine and the frontend both read this column.
ChoicesOffered NullableJSON `db:"choices_offered" json:"choices_offered,omitempty"`
}
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
@@ -946,3 +953,24 @@ type ApprovalRequest struct {
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
// ProjectEventChoice is one per-event-card pick scoped to a project
// (t-paliad-265 / m/paliad#96). The join key SubmissionCode matches
// paliad.deadline_rules.submission_code — the same identifier the
// AnchorOverrides plumbing in fristenrechner.go already uses.
//
// ChoiceKind ∈ {appellant, include_ccr, skip}. ChoiceValue namespace
// per kind: appellant=claimant|defendant|both|none; include_ccr=true|false;
// skip=true|false. UNIQUE(project_id, submission_code, choice_kind)
// makes re-picks idempotent (Upsert path).
type ProjectEventChoice struct {
ID uuid.UUID `db:"id" json:"id"`
ProjectID uuid.UUID `db:"project_id" json:"project_id"`
SubmissionCode string `db:"submission_code" json:"submission_code"`
ChoiceKind string `db:"choice_kind" json:"choice_kind"`
ChoiceValue string `db:"choice_value" json:"choice_value"`
CreatedBy *uuid.UUID `db:"created_by" json:"created_by,omitempty"`
CreatedAt time.Time `db:"created_at" json:"created_at"`
UpdatedBy *uuid.UUID `db:"updated_by" json:"updated_by,omitempty"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}

View File

@@ -34,7 +34,8 @@ const ruleColumns = `id, proceeding_type_id, parent_id, submission_code, name, n
anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_active,
created_at, updated_at,
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
priority, is_court_set, lifecycle_state, draft_of, published_at`
priority, is_court_set, lifecycle_state, draft_of, published_at,
choices_offered`
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
category, default_color, sort_order, is_active`

View File

@@ -0,0 +1,272 @@
package services
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"mgit.msbls.de/m/paliad/internal/models"
)
// EventChoiceService reads and writes paliad.project_event_choices —
// per-event-card user picks scoped to a project (t-paliad-265 /
// m/paliad#96). Three choice kinds today:
//
// appellant — claimant | defendant | both | none
// include_ccr — true | false
// skip — true | false
//
// Visibility follows paliad.can_see_project (via ProjectService.CanSee).
// Audits via paliad.system_audit_log with event_type=project_event_choice.set
// (insert/update) or .deleted (delete).
//
// The CRUD surface is intentionally tight: List for a project (one read),
// Upsert one (idempotent re-pick), Delete one (kind-scoped). The
// projection engine receives the choices via ToCalcOptionsAddendum,
// which folds them into CalcOptions before Calculate runs.
type EventChoiceService struct {
db *sqlx.DB
projects *ProjectService
users *UserService
}
func NewEventChoiceService(db *sqlx.DB, projects *ProjectService, users *UserService) *EventChoiceService {
return &EventChoiceService{db: db, projects: projects, users: users}
}
// Allowed choice kinds + per-kind value namespaces. Validated server-side
// before any write; the DB CHECK constraint catches the same shape but
// the early validation gives a friendlier error and short-circuits the
// transaction.
var (
allowedChoiceKinds = map[string]map[string]struct{}{
"appellant": {"claimant": {}, "defendant": {}, "both": {}, "none": {}},
"include_ccr": {"true": {}, "false": {}},
"skip": {"true": {}, "false": {}},
}
)
func validateChoice(kind, value string) error {
values, ok := allowedChoiceKinds[kind]
if !ok {
return fmt.Errorf("%w: unknown choice_kind %q", ErrInvalidInput, kind)
}
if _, ok := values[value]; !ok {
return fmt.Errorf("%w: invalid choice_value %q for kind %q", ErrInvalidInput, value, kind)
}
return nil
}
// ListForProject returns every choice row for the given project. Caller
// must hold visibility on the project.
func (s *EventChoiceService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.ProjectEventChoice, error) {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return nil, err
}
out := []models.ProjectEventChoice{}
err := s.db.SelectContext(ctx, &out,
`SELECT id, project_id, submission_code, choice_kind, choice_value,
created_by, created_at, updated_by, updated_at
FROM paliad.project_event_choices
WHERE project_id = $1
ORDER BY submission_code, choice_kind`, projectID)
if err != nil {
return nil, fmt.Errorf("list event choices: %w", err)
}
return out, nil
}
// UpsertInput is the body shape for an upsert.
type UpsertEventChoiceInput struct {
SubmissionCode string `json:"submission_code"`
ChoiceKind string `json:"choice_kind"`
ChoiceValue string `json:"choice_value"`
}
// Upsert inserts or updates one (project, submission_code, choice_kind)
// row. Audit-log row written in the same tx.
func (s *EventChoiceService) Upsert(ctx context.Context, userID, projectID uuid.UUID, input UpsertEventChoiceInput) (*models.ProjectEventChoice, error) {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return nil, err
}
if input.SubmissionCode == "" {
return nil, fmt.Errorf("%w: submission_code required", ErrInvalidInput)
}
if err := validateChoice(input.ChoiceKind, input.ChoiceValue); err != nil {
return nil, err
}
actorEmail, err := s.actorEmail(ctx, userID)
if err != nil {
return nil, err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`SELECT set_config('paliad.audit_reason',
'project_event_choice.set ('||$1||','||$2||','||$3||')', true)`,
input.SubmissionCode, input.ChoiceKind, input.ChoiceValue); err != nil {
return nil, fmt.Errorf("set audit reason: %w", err)
}
var row models.ProjectEventChoice
err = tx.GetContext(ctx, &row,
`INSERT INTO paliad.project_event_choices
(project_id, submission_code, choice_kind, choice_value, created_by, updated_by)
VALUES ($1, $2, $3, $4, $5, $5)
ON CONFLICT (project_id, submission_code, choice_kind)
DO UPDATE SET choice_value = EXCLUDED.choice_value,
updated_by = EXCLUDED.updated_by,
updated_at = now()
RETURNING id, project_id, submission_code, choice_kind, choice_value,
created_by, created_at, updated_by, updated_at`,
projectID, input.SubmissionCode, input.ChoiceKind, input.ChoiceValue, userID)
if err != nil {
return nil, fmt.Errorf("upsert event choice: %w", err)
}
if err := writeChoiceAudit(ctx, tx, "project_event_choice.set", userID, actorEmail, projectID, input.SubmissionCode, input.ChoiceKind, input.ChoiceValue); err != nil {
return nil, err
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit upsert: %w", err)
}
return &row, nil
}
// Delete removes the (project, submission_code, choice_kind) row.
// Returns ErrNotVisible if the project isn't visible OR the row didn't
// exist (no leak between the two).
func (s *EventChoiceService) Delete(ctx context.Context, userID, projectID uuid.UUID, submissionCode, choiceKind string) error {
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
return err
}
if submissionCode == "" || choiceKind == "" {
return fmt.Errorf("%w: submission_code + choice_kind required", ErrInvalidInput)
}
if _, ok := allowedChoiceKinds[choiceKind]; !ok {
return fmt.Errorf("%w: unknown choice_kind %q", ErrInvalidInput, choiceKind)
}
actorEmail, err := s.actorEmail(ctx, userID)
if err != nil {
return err
}
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer func() { _ = tx.Rollback() }()
if _, err := tx.ExecContext(ctx,
`SELECT set_config('paliad.audit_reason',
'project_event_choice.deleted ('||$1||','||$2||')', true)`,
submissionCode, choiceKind); err != nil {
return fmt.Errorf("set audit reason: %w", err)
}
res, err := tx.ExecContext(ctx,
`DELETE FROM paliad.project_event_choices
WHERE project_id = $1 AND submission_code = $2 AND choice_kind = $3`,
projectID, submissionCode, choiceKind)
if err != nil {
return fmt.Errorf("delete event choice: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrNotVisible
}
if err := writeChoiceAudit(ctx, tx, "project_event_choice.deleted", userID, actorEmail, projectID, submissionCode, choiceKind, ""); err != nil {
return err
}
return tx.Commit()
}
// CalcOptionsAddendum is the per-card slice of CalcOptions, built from
// the persisted choices. ProjectionService folds these into the parent
// CalcOptions before Calculate runs.
type CalcOptionsAddendum struct {
PerCardAppellant map[string]string // submission_code → appellant value
SkipRules map[string]struct{} // set of submission_code
IncludeCCRFor map[string]struct{} // set of submission_code
}
// ToCalcOptionsAddendum converts a list of choices into the calc-options
// shape. Empty input yields an addendum whose maps are non-nil but empty
// so callers can use map indexing without nil checks.
func ToCalcOptionsAddendum(choices []models.ProjectEventChoice) CalcOptionsAddendum {
out := CalcOptionsAddendum{
PerCardAppellant: map[string]string{},
SkipRules: map[string]struct{}{},
IncludeCCRFor: map[string]struct{}{},
}
for _, c := range choices {
switch c.ChoiceKind {
case "appellant":
out.PerCardAppellant[c.SubmissionCode] = c.ChoiceValue
case "skip":
if c.ChoiceValue == "true" {
out.SkipRules[c.SubmissionCode] = struct{}{}
}
case "include_ccr":
if c.ChoiceValue == "true" {
out.IncludeCCRFor[c.SubmissionCode] = struct{}{}
}
}
}
return out
}
// writeChoiceAudit inserts a project-scoped row into paliad.system_audit_log
// with the choice details in metadata. Same shape as the data-export +
// checklist audit writers.
func writeChoiceAudit(ctx context.Context, tx *sqlx.Tx, eventType string, actorID uuid.UUID, actorEmail string, projectID uuid.UUID, submissionCode, choiceKind, choiceValue string) error {
if _, err := tx.ExecContext(ctx,
`INSERT INTO paliad.system_audit_log
(event_type, actor_id, actor_email, scope, scope_root, metadata)
VALUES ($1, $2, $3, 'project', $4,
jsonb_build_object(
'submission_code', $5::text,
'choice_kind', $6::text,
'choice_value', $7::text
))`,
eventType, actorID, actorEmail, projectID, submissionCode, choiceKind, choiceValue); err != nil {
return fmt.Errorf("audit insert: %w", err)
}
return nil
}
func (s *EventChoiceService) actorEmail(ctx context.Context, userID uuid.UUID) (string, error) {
var email string
err := s.db.GetContext(ctx, &email,
`SELECT email FROM paliad.users WHERE id = $1`, userID)
if errors.Is(err, sql.ErrNoRows) {
return "", ErrNotVisible
}
if err != nil {
return "", fmt.Errorf("lookup actor: %w", err)
}
return email, nil
}
func (s *EventChoiceService) requireProjectVisible(ctx context.Context, userID, projectID uuid.UUID) error {
visible, err := s.projects.CanSee(ctx, userID, projectID)
if err != nil {
return err
}
if !visible {
return ErrNotVisible
}
return nil
}

View File

@@ -0,0 +1,108 @@
package services
import (
"testing"
"mgit.msbls.de/m/paliad/internal/models"
)
// Unit tests for the pure helpers in event_choice_service.go. The CRUD
// path needs a live DB and lives in the integration suite.
func TestValidateChoice_Appellant(t *testing.T) {
for _, value := range []string{"claimant", "defendant", "both", "none"} {
if err := validateChoice("appellant", value); err != nil {
t.Errorf("appellant=%q should pass, got %v", value, err)
}
}
for _, bad := range []string{"", "applicant", "true", "claimaant"} {
if err := validateChoice("appellant", bad); err == nil {
t.Errorf("appellant=%q should fail validation", bad)
}
}
}
func TestValidateChoice_IncludeCCR(t *testing.T) {
for _, value := range []string{"true", "false"} {
if err := validateChoice("include_ccr", value); err != nil {
t.Errorf("include_ccr=%q should pass, got %v", value, err)
}
}
for _, bad := range []string{"", "yes", "1", "True"} {
if err := validateChoice("include_ccr", bad); err == nil {
t.Errorf("include_ccr=%q should fail validation", bad)
}
}
}
func TestValidateChoice_Skip(t *testing.T) {
for _, value := range []string{"true", "false"} {
if err := validateChoice("skip", value); err != nil {
t.Errorf("skip=%q should pass, got %v", value, err)
}
}
if err := validateChoice("skip", "maybe"); err == nil {
t.Errorf("skip=maybe should fail")
}
}
func TestValidateChoice_UnknownKind(t *testing.T) {
if err := validateChoice("not_a_kind", "true"); err == nil {
t.Errorf("unknown choice_kind should fail")
}
}
func TestToCalcOptionsAddendum_PerCardAppellant(t *testing.T) {
choices := []models.ProjectEventChoice{
{SubmissionCode: "upc.inf.cfi.decision", ChoiceKind: "appellant", ChoiceValue: "defendant"},
{SubmissionCode: "de.inf.lg.urteil", ChoiceKind: "appellant", ChoiceValue: "both"},
}
out := ToCalcOptionsAddendum(choices)
if out.PerCardAppellant["upc.inf.cfi.decision"] != "defendant" {
t.Errorf("appellant pick for upc.inf.cfi.decision = %q, want defendant", out.PerCardAppellant["upc.inf.cfi.decision"])
}
if out.PerCardAppellant["de.inf.lg.urteil"] != "both" {
t.Errorf("appellant pick for de.inf.lg.urteil = %q, want both", out.PerCardAppellant["de.inf.lg.urteil"])
}
if len(out.SkipRules) != 0 || len(out.IncludeCCRFor) != 0 {
t.Errorf("appellant-only input should not populate skip/include_ccr maps")
}
}
func TestToCalcOptionsAddendum_SkipRules(t *testing.T) {
choices := []models.ProjectEventChoice{
{SubmissionCode: "upc.inf.cfi.ccr", ChoiceKind: "skip", ChoiceValue: "true"},
{SubmissionCode: "upc.inf.cfi.prelim", ChoiceKind: "skip", ChoiceValue: "false"},
}
out := ToCalcOptionsAddendum(choices)
if _, ok := out.SkipRules["upc.inf.cfi.ccr"]; !ok {
t.Errorf("skip=true should populate SkipRules")
}
if _, ok := out.SkipRules["upc.inf.cfi.prelim"]; ok {
t.Errorf("skip=false should NOT populate SkipRules")
}
}
func TestToCalcOptionsAddendum_IncludeCCRFor(t *testing.T) {
choices := []models.ProjectEventChoice{
{SubmissionCode: "upc.inf.cfi.sod", ChoiceKind: "include_ccr", ChoiceValue: "true"},
{SubmissionCode: "de.inf.lg.erwidg", ChoiceKind: "include_ccr", ChoiceValue: "false"},
}
out := ToCalcOptionsAddendum(choices)
if _, ok := out.IncludeCCRFor["upc.inf.cfi.sod"]; !ok {
t.Errorf("include_ccr=true should populate IncludeCCRFor")
}
if _, ok := out.IncludeCCRFor["de.inf.lg.erwidg"]; ok {
t.Errorf("include_ccr=false should NOT populate IncludeCCRFor")
}
}
func TestToCalcOptionsAddendum_EmptyInput(t *testing.T) {
out := ToCalcOptionsAddendum(nil)
if out.PerCardAppellant == nil || out.SkipRules == nil || out.IncludeCCRFor == nil {
t.Errorf("empty input should still produce non-nil maps for safe indexing")
}
if len(out.PerCardAppellant) != 0 || len(out.SkipRules) != 0 || len(out.IncludeCCRFor) != 0 {
t.Errorf("empty input should produce empty maps")
}
}

View File

@@ -90,6 +90,18 @@ type UIDeadline struct {
// court itself.
IsCourtSetIndirect bool `json:"isCourtSetIndirect,omitempty"`
IsOverridden bool `json:"isOverridden,omitempty"`
// ChoicesOffered surfaces paliad.deadline_rules.choices_offered for
// the rule so the frontend knows whether to render the per-event-card
// caret affordance, and which choice-kinds to populate the popover
// with. NULL / empty for rules with no choices. (t-paliad-265)
ChoicesOffered json.RawMessage `json:"choicesOffered,omitempty"`
// AppellantContext is the per-decision appellant pick that applies
// to descendants of the closest ancestor decision card with a
// PerCardAppellant set. Empty when no per-card override is in
// effect (page-level ?appellant= still applies in that case).
// Frontend bucketer prefers this over the page-level appellant when
// non-empty. (t-paliad-265)
AppellantContext string `json:"appellantContext,omitempty"`
}
// UIResponse matches the frontend's DeadlineResponse TypeScript interface.
@@ -179,6 +191,29 @@ type CalcOptions struct {
// Empty / nil = no override (default). Overrides apply equally to
// the proceeding-tree and trigger-event branches.
RuleOverrides []models.DeadlineRule
// Per-event-card choice overlays (t-paliad-265 / m/paliad#96).
// Keyed by paliad.deadline_rules.submission_code — same key
// AnchorOverrides uses.
//
// - PerCardAppellant: maps a decision-card's submission_code to the
// user-picked appellant ("claimant"|"defendant"|"both"|"none").
// The engine walks the parent chain of each rule and stamps the
// resulting UIDeadline.AppellantContext from the closest ancestor
// decision with a pick. The frontend bucketer then prefers the
// per-rule context over the page-level appellant.
// - SkipRules: set of submission_code values whose rules (and any
// descendants) the user has opted out of for this projection.
// Same suppression path as a failed condition_expr gate.
// - IncludeCCRFor: set of submission_code values for rules where
// the user opted in to the include-CCR choice (Klageerwiderung
// cards). v1 simplification (design §4.2 #2): if non-empty,
// "with_ccr" is appended to the flag set before gate
// evaluation. Correct for single-CCR-entry-point proceedings
// (UPC INF + DE LG today). Multi-CCR scope is a future expansion.
PerCardAppellant map[string]string
SkipRules map[string]struct{}
IncludeCCRFor map[string]struct{}
}
// Calculate renders the full UI timeline for a proceeding type + trigger date.
@@ -233,6 +268,14 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
for _, f := range opts.Flags {
flagSet[f] = struct{}{}
}
// v1 simplification (design §4.2 #2, t-paliad-265): when any
// IncludeCCRFor entry exists, we treat with_ccr as set in the flag
// context. Correct for single-CCR-entry-point proceedings (UPC INF +
// DE LG today). Multi-CCR scope is a future expansion that would
// thread the include set through the gate evaluator per-rule.
if len(opts.IncludeCCRFor) > 0 {
flagSet["with_ccr"] = struct{}{}
}
// Parse anchor overrides up-front so a malformed date errors out
// before we start walking rules.
@@ -329,6 +372,21 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
courtSet := make(map[uuid.UUID]bool, len(rules))
deadlines := make([]UIDeadline, 0, len(rules))
// Per-event-card overlays (t-paliad-265). Empty/nil maps are safe
// for membership tests; the engine reads them but doesn't mutate.
skipRules := opts.SkipRules
perCardAppellant := opts.PerCardAppellant
// skippedIDs accumulates the set of rule UUIDs whose timeline entry
// the user has opted out of. Walking in sequence_order means a
// child rule's parent has already been classified — so descendant
// suppression is a one-pass parent_id lookup.
skippedIDs := make(map[uuid.UUID]struct{}, len(skipRules))
// appellantContext maps a rule UUID to the appellant value that
// applies to its descendants. A rule that has its own PerCardAppellant
// pick stamps itself with that value; a rule whose parent has a
// context inherits it.
appellantContext := make(map[uuid.UUID]string, len(rules))
for _, r := range rules {
// Phase-3 unified gate: evaluate condition_expr (jsonb).
// Suppression semantic preserved: when the gate fires false AND
@@ -341,12 +399,49 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
continue
}
// SkipRules suppression (t-paliad-265): the user has marked
// this rule (or one of its ancestors) as "don't consider for
// this case". Drop the row entirely AND record the rule ID so
// descendants suppress too.
if r.SubmissionCode != nil {
if _, skipped := skipRules[*r.SubmissionCode]; skipped {
skippedIDs[r.ID] = struct{}{}
continue
}
}
if r.ParentID != nil {
if _, parentSkipped := skippedIDs[*r.ParentID]; parentSkipped {
skippedIDs[r.ID] = struct{}{}
continue
}
}
// AppellantContext propagation. A rule with its own PerCardAppellant
// pick stamps its UUID with that value. Otherwise inherit from
// parent if the parent had a context.
var ctxVal string
if r.SubmissionCode != nil {
if v, ok := perCardAppellant[*r.SubmissionCode]; ok {
ctxVal = v
}
}
if ctxVal == "" && r.ParentID != nil {
if v, ok := appellantContext[*r.ParentID]; ok {
ctxVal = v
}
}
if ctxVal != "" {
appellantContext[r.ID] = ctxVal
}
d := UIDeadline{
RuleID: r.ID.String(),
Name: r.Name,
NameEN: r.NameEN,
Priority: r.Priority,
ConditionExpr: json.RawMessage(r.ConditionExpr),
RuleID: r.ID.String(),
Name: r.Name,
NameEN: r.NameEN,
Priority: r.Priority,
ConditionExpr: json.RawMessage(r.ConditionExpr),
AppellantContext: ctxVal,
ChoicesOffered: json.RawMessage(r.ChoicesOffered),
}
if r.SubmissionCode != nil {
d.Code = *r.SubmissionCode