Compare commits

..

34 Commits

Author SHA1 Message Date
mAi
76d38c4c84 fix(fristenrechner): dark-mode token migration for overhaul CSS (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
The Fristenrechner overhaul CSS shipped in S2/S3/S4 (commits 9ab8dd8,
2a2c5b8, 70985d8) used hardcoded hex literals across the result view,
Mode A search, and Mode B wizard surfaces. The `:root[data-theme="dark"]`
flip had nothing to override, so toggling the theme left the whole
Fristenrechner pane stuck in light-mode colors.

Migrate every hex literal in those sections to the design-token system
that the rest of paliad already uses (PWAHead.tsx flips
`data-theme` from localStorage):

- Surfaces: `#fff`/`#fafaf6`/`#f4f4f0` → `--color-surface` /
  `--color-surface-2` / `--color-bg-subtle`.
- Borders: `#d8d8cf`/`#e0e0d4`/`#ececde` → `--color-border`;
  `#c8c8be`/`#d4d4c9`/`#d4d4cc` → `--color-border-strong`.
- Text: `#1f1f1f`/`#2a2a2a` → `--color-text`; `#444`/`#555`/`#666` →
  `--color-text-muted`; `#777`/`#888`/`#999` → `--color-text-subtle`.
- Status palette: error → `--status-red-*`; spawn/cond badges +
  court-set hint → `--status-amber-*`; ok-msg → `--status-green-*`;
  claimant party + filter-row badge → `--status-blue-*`; recommended
  group stripe → new `--status-blue-border`; conditional stripe →
  `--status-amber-border`.
- Defendant/court party stances → `--status-red-*` /
  new `--status-purple-*` bucket.
- Brand-lime cues (mandatory group stripe, mode-tab active underline,
  wizard row-number circle) → `--color-accent` / `--color-accent-dark`.
- Lime soft tints (nudge, footer, hover bgs, success message, "from
  Akte" wizard row, edit-button hover) → new
  `--color-accent-soft-{bg,fg,border}` tokens.
- Saturated lime pills (active chip, jurisdiction badge, wizard
  active-row outline) → new `--color-accent-strong-{bg,fg,border}`
  tokens.
- Lime accent links (rule-source, edit-date, result-cta, wizard-edit)
  → existing `--color-accent-fg` (midnight in light, lime in dark).
- Wizard active-row glow `rgba(198, 244, 28, 0.15)` → token-driven
  `rgb(var(--hlc-lime-rgb) / 0.15)`.
- Trigger card box-shadow → `var(--shadow)` (auto-deepens in dark).

Ten new tokens introduced in `:root` + mirrored in
`:root[data-theme="dark"]`: 6 accent-soft/-strong, 1 status-blue
border, 3 status-purple bucket.

Verified by mounting `frontend/dist/assets/global.css` against a static
representative DOM (all four group stripes, every party stance, mode-A
filter + result list, mode-B wizard with filter/qualifier badges,
trigger card, write-back footer, kontextfrei nudge, ok/error
messages). Toggled `data-theme="dark"` via JS — every surface, border,
chip, badge, and status pill flipped to its dark counterpart.
`bun run build` + `go vet ./...` clean. Layout / spacing / sizing
untouched (colours, borders, shadows only).

NO CHANGES IN FUNCTIONALITY. PoC pane only flips visuals when the
theme is toggled now.

t-paliad-326.
2026-05-27 10:41:29 +02:00
mAi
233547297c Merge: t-paliad-323 Slice S6 — Fristenrechner cleanup (m/paliad#146 SHIPPED)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
knuth shipped S6, the final slice of the Fristenrechner overhaul:

- frontend/src/client/fristenrechner.ts shrinks by 137 LoC (legacy Pathway-B neutralised; row-stack subtree wired off behind ?legacy=1).
- internal/handlers/fristenrechner_event_categories.go dropped — the /api/tools/fristenrechner/event-categories endpoint is gone (route deregistered in handlers.go).
- paliad.event_categories table stays for future tools (the hidden 'Ich möchte einreichen' forward-workflow), per design §7-S6.
- Deferred follow-ups (knuth's scope discipline): drop the legacy concept-card response shape from /search + lift the dead-code row-stack subtree out of fristenrechner.ts in a separate cleanup PR. Filed as scope note on m/paliad#146 (issuecomment-10414).

S1-S6 complete:
- S1 7ea4151 — backend (search ?kind=events + /follow-ups)
- S2 9ab8dd8 — result view under ?overhaul=1
- S3 2a2c5b8 — Mode A direct search
- S4 70985d8 — Mode B 5-row wizard
- S5 4571bd4 — flip overhaul default
- S6 ba3e079 — cascade endpoint drop + legacy neutralise

Procedure-mode (upper half of fristenrechner.tsx) untouched per design. paliad.event_categories table retained for future tools.
2026-05-27 10:25:26 +02:00
mAi
ba3e0795f8 feat(fristenrechner): Slice S6 — drop cascade endpoint, neutralize legacy Pathway B (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Cleanup pass per design §7 / S6, executed as a measured first cut
that drops the cascade endpoint + neutralizes the legacy Pathway B
row-stack / cascade init without lifting the entire ~1500 LoC
subtree out of `fristenrechner.ts`. The dead helpers stay for one
follow-up that can lift them safely.

Backend:
  * Deleted `internal/handlers/fristenrechner_event_categories.go`.
  * Dropped the `GET /api/tools/fristenrechner/event-categories`
    route from `handlers.go`. The `EventCategoryService` itself
    stays — it still backs the legacy concept-card search's
    `?event_category_slug=` filter, which dies in the same
    follow-up that removes the concept-card response shape.
  * `paliad.event_categories` TABLE is untouched per design §7
    (kept for future tools).

Frontend:
  * `loadEventCategoryTree()` reduced to a stub returning `[]` — the
    endpoint it fetched no longer exists, and no overhaul surface
    calls it.
  * `initB1Cascade()`, `initForumFilter()`, `initInboxFilter()`
    early-return. Their `DOMContentLoaded` registrations stay so
    the bundle exports are stable, but no Pathway B cascade /
    chip-strip / inbox-channel wiring fires in `?legacy=1` mode.
  * The Pathway B markup in `fristenrechner.tsx` stays in place; it
    renders inert when a user hits `?legacy=1&path=b`.
  * `buildRowStack`, `renderRowStack`, `runB1Search`, and the row-
    stack helper functions remain as unreachable code. Removing
    them mechanically requires retiring the entire upper-half
    Pathway B B2 search wiring (`runSearch` + `renderConceptCard`
    + `renderSearchResults` + `SearchResponse` types) which is
    tangled with the legacy concept-card response shape — deferred
    to a follow-up that lands together with the backend
    concept-card removal.

Verified — bun build clean (2971 i18n keys unchanged), 256
frontend tests pass, go build + vet clean, live-DB tests
(TestListProceedings, TestSearchEvents, TestLookupFollowUps)
still green.

Follow-up scope tracked in design §7 S6 — pending the helper-tree
lift and the legacy concept-card response-shape removal from
/search.
2026-05-27 10:24:16 +02:00
mAi
8dfdd77079 Merge: t-paliad-323 Slice S5 — flip overhaul default; legacy under ?legacy=1 (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
knuth flipped the overhaul flag per design §7-S5:

- isOverhaulMode() inverted: true unless ?legacy=1.
- /tools/fristenrechner now lands on the new dual-mode (Direkt suchen + Geführt) by default.
- Legacy row stack still reachable via ?legacy=1 for the 2-week deprecation window.
- Existing ?overhaul=1 deep links continue to work (no-op pass-through).
- Sidebar / header / outbound URLs unchanged — they point at bare /tools/fristenrechner so they pick up the new default automatically.

S6 (drop buildRowStack + cascade reads) next on the same branch.
2026-05-27 10:16:31 +02:00
mAi
4571bd4980 feat(fristenrechner): Slice S5 — flip overhaul default; legacy under ?legacy=1 (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
`isOverhaulMode()` now returns true unless the URL carries
`?legacy=1`. The overhaul UI from S2-S4 (mode tabs + Mode A
search + Mode B wizard + shared result view) becomes the default
landing for /tools/fristenrechner; the legacy three-step wizard +
Pathway A/B + cascade is reachable only via the explicit
`?legacy=1` opt-out for the two-week deprecation window before
S6 drops the legacy code paths entirely.

The pre-existing `?overhaul=1` deep links from S2-S4 still
resolve — the detector treats *absence* of `?legacy=1` as
overhaul, so bookmarks stay valid. No sidebar / header / outbound
link change needed: those all point at the bare
`/tools/fristenrechner` URL, which now boots overhaul.

Verified — bun build clean (2971 i18n keys unchanged), 256
frontend tests pass, go build + vet clean.
2026-05-27 10:16:07 +02:00
mAi
7584b4f428 Merge: t-paliad-323 Slice S4 — Fristenrechner Mode B wizard (m/paliad#146)
Some checks failed
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
knuth shipped S4 of the Fristenrechner overhaul (design §3.2, §7-S4):

- New frontend/src/client/fristenrechner-wizard.ts (711 LoC) — 5-row 'Geführt' wizard:
  - R1 event_kind (always asked, ~6 chips)
  - R2 forum (skipped when R1 narrows to a single forum)
  - R3 proceeding_type (auto-skipped when narrowed to a single candidate; EventKind EXISTS filter on the catalog)
  - R4 procedural_event (the landing question)
  - R5 perspective (async-probed after R4; only fires when the trigger event's follow-ups actually differ by primary_party)
- Row Filter/Qualifier badges per §11.Q3 (R1/R2 = Filter, R3/R5 = Qualifier).
- R5 has no 'Beide' option per §11.Q8 (qualifier mode in the file path).
- Pre-fill+collapse from project: proceeding_type → R3+R2 and our_side → R5 with 'aus Akte' tag.
- Backend ProceedingListOptions.EventKind added so R3's catalog query respects the chosen event_kind.
- 6 live-DB tests pass — including the kind=proceeding regression check (upc.cfi.interim filtered out as a phase row). 256 frontend tests pass + 7 new for followUpsDifferByParty.

Branch rebased on main (post-mig-153 + S3). S5 (flip ?overhaul=1 to default) next.
2026-05-27 10:15:14 +02:00
mAi
70985d88b0 feat(fristenrechner): Slice S4 — Mode B wizard (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Mode B "🧭 Geführt" — the guided 3-5 row wizard defined in
docs/design-fristenrechner-overhaul-2026-05-26.md §3.2. Lands the
user on a single procedural_event (the trigger), then transitions
to the shared §4 result view.

Frontend:
  * `fristenrechner-wizard.ts` — row stack with R1..R5:
      R1 Was ist passiert?           (event_kind, always asked)
      R2 Vor welchem Gericht?        (jurisdiction, skip if R1 narrows)
      R3 In welchem Verfahren?       (proceeding_type, auto-skip when
                                      narrowed pool has 1 option)
      R4 Welches Schriftstück?       (procedural_event, landing)
      R5 Welche Seite vertreten Sie? (party, only when follow-ups
                                      differ by primary_party)
    Row badges per §11.Q3: R1+R2 = Filter, R3+R4+R5 = Qualifier.
    R5 has NO "Beide" option per §11.Q8 — Mode B is the file-mode
    where perspective is a qualifier.
  * Project prefill — derives R3 + R2 jurisdiction from
    project.proceeding_type, R5 from project.our_side. Annotates
    pre-filled rows with "aus Akte" tag and implicit rows with
    "implizit" tag per §11.Q10 ("erhalten" annotation when a pick is
    carried across an upstream change).
  * R4-to-result transition — after R4 the wizard fetches /follow-
    ups (no dates) to inspect primary_party variance. If both
    claimant and defendant rules exist AND R5 isn't already set,
    swaps the loading row for the R5 chip picker. Otherwise jumps
    straight to mountResultView.
  * URL state — `?mode=wizard&kind=…&forum=…&pt=…&r4=…&party=…`
    keeps deep-link / back-nav consistent (the launchResult step
    sets `event=` so the result view picks up).
  * `fristenrechner-result.ts` mountModeShell now dispatches the
    "wizard" tab to the wizard module (was a coming-soon
    placeholder).
  * 18 i18n keys added (DE + EN parity), 145-line CSS block for the
    wizard row stack with Filter / Qualifier badge styling and
    "aus Akte" annotation chip.

Backend:
  * `ProceedingListOptions.EventKind` adds an EXISTS subquery
    filter on `paliad.sequencing_rules` ⨯ `paliad.procedural_events`
    so Mode B R3 chips only show proceedings whose event roster
    contains at least one event of the requested kind (design
    §6.3). Endpoint param: `event_kind=` on
    /api/tools/proceeding-types.

Test updates:
  * `TestListProceedings` switched from SKIP-when-column-missing to
    asserting the live filter — mig 153 has landed, `kind` column
    is in place. New subtests: kind=proceeding includes
    upc.inf.cfi and excludes the phase row upc.cfi.interim;
    event_kind=filing narrows to proceedings with filing events.
  * `fristenrechner-wizard.test.ts` covers
    `followUpsDifferByParty` — the R5 trigger predicate. 7 cases:
    asymmetric → true; uniform / both / court / empty → false.

Verified — bun build clean (2971 i18n keys), 256 frontend tests
pass (incl. 7 new), go build + vet clean, live-DB
TestListProceedings passes all 6 subtests against mig 153 data.
2026-05-27 10:14:37 +02:00
mAi
06d6c7540e Merge: t-paliad-323 Slice S3 — Fristenrechner Mode A direct search (m/paliad#146)
Some checks failed
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
knuth shipped S3 of the Fristenrechner overhaul (design §3.1, §7-S3):

- New frontend/src/client/fristenrechner-mode-a.ts (507 LoC) — 'Direkt suchen' UI per design §3.1: Filter strip (Forum · Verfahren · Was passierte · Partei) with section-split visual hierarchy per m §11.Q3, free-text search box, ranked result list of procedural_events with click-to-lock-as-trigger.
- Inbox channel as secondary 'Erweitert' chip per §3.3 with CMS→UPC / beA→DE forum nudge.
- Mode tabs pair (Direkt suchen / Geführt) under Step-0 per §11.Q2; wizard tab placeholder until S4.
- Backend ListProceedings(jurisdiction, kind) — kind='proceeding' filter targets mig 153's column (just merged in 3e55ff8). 4 tests pass + 1 SKIP that probes for column existence (graceful fallback prior to mig 153).
- 310 LoC CSS, 88 i18n keys for the new surface.
- bun build clean; 249 existing frontend tests + new pass; go vet clean.

Mode A live under ?overhaul=1. Mode B (S4 wizard) next on the same branch.
2026-05-27 10:10:57 +02:00
mAi
3e55ff8294 Merge: t-paliad-325 — mig 153 proceeding_types kind discriminator + ProjectService hardening (m/paliad#147)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
ritchie shipped atlas's design (docs/design-proceeding-types-taxonomy-2026-05-26.md):

- mig 153 additive: ADD COLUMN kind text NOT NULL DEFAULT 'proceeding' CHECK in {proceeding,phase,side_action,meta}; UPDATE 4 phase + 10 side_action + 9 meta; per m's Q9 flips is_active=false on the same 23 rows in the same TX. CHECK trigger projects_proceeding_type_kind_check blocks projects.proceeding_type_id from pointing at non-proceeding kinds. Snapshot to paliad.proceeding_types_pre_153 in the same TX. set_config('paliad.audit_reason', ...) defensively.
- ProjectService.SetProceedingType hardened: new ErrInvalidProceedingTypeKind, single-SELECT validator checks category + kind + is_active before assigning.
- 4-angle test (TestProjectService_ProceedingTypeKindGuard) covers happy-path proceeding, rejected phase, rejected inactive, rejected wrong category.
- cmd/gen-upc-snapshot/main.go gains the AND kind='proceeding' filter; embedded snapshot JSON regen flagged as follow-up (needs DATABASE_URL at runtime).

Mode B R3 query now becomes WHERE is_active=true AND kind='proceeding' for a 23-row clean primary list. Phase/side_action/meta rows survive in the table for taxonomic reference but never surface in pickers.
2026-05-27 10:10:39 +02:00
mAi
9d688459e3 feat(db): mig 153 — proceeding_types kind discriminator + ProjectService hardening
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Adds a `kind` column to paliad.proceeding_types (proceeding / phase /
side_action / meta) so the Mode B R3 Fristenrechner wizard, the
projects.proceeding_type_id binding, and the pkg/litigationplanner
snapshot can filter to primary proceedings only.

Implements the ratified design from docs/design-proceeding-types-
taxonomy-2026-05-26.md (m greenlit 2026-05-27 09:57 after the 11-question
AskUserQuestion round-trip).

Mig 153 is purely additive — ADD COLUMN with a safe DEFAULT, UPDATEs
reclassify 23 non-primary rows (4 phase + 10 side_action + 9 meta), and
a BEFORE INSERT/UPDATE trigger on paliad.projects backstops the new
invariant. Pre-mig audit (Supabase MCP, 2026-05-27) confirmed zero
downstream pressure on the 23 reclassified rows.

- internal/db/migrations/153_proceeding_types_kind.up.sql + .down.sql
  - snapshot to paliad.proceeding_types_pre_153 in the same TX
  - set_config('paliad.audit_reason', …) defensively
  - DO-block asserts 23 reclassified rows before the trigger ships
  - Q9 carve-out: is_active=false on every phase/side_action/meta row
  - new trigger paliad.projects_proceeding_type_kind_check on
    paliad.projects.proceeding_type_id

- internal/services/project_service.go
  - extend validateProceedingTypeCategory to also enforce
    kind='proceeding' AND is_active=true; new typed error
    ErrInvalidProceedingTypeKind
  - single SELECT picks up category + kind + is_active

- internal/services/project_service_test.go
  - TestProjectService_ProceedingTypeKindGuard covers service-layer
    rejection, the active-but-non-proceeding edge, mig 153 trigger
    backstop, and the kind='proceeding' happy path

- cmd/gen-upc-snapshot/main.go
  - filter proceeding_types query to kind='proceeding' for forward-
    compat (the embedded UPC snapshot JSON regen requires DATABASE_URL
    access and will land in a follow-up; the current placeholder is
    already empty of non-primary rows)

t-paliad-325 / m/paliad#147
2026-05-27 10:09:33 +02:00
mAi
2a2c5b8033 feat(fristenrechner): Slice S3 — Mode A direct search (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Mode A " Direkt suchen" — the power-user entry path defined in
docs/design-fristenrechner-overhaul-2026-05-26.md §3.1. Renders
above the §4 result view; clicking a result row locks the trigger
event and transitions to the shared result surface from S2.

Frontend:
  * `fristenrechner-mode-a.ts` — filter strip (Forum / Verfahren /
    Was passierte / Partei) + free-text search input + result list.
    Section-split visual hierarchy per m §11.Q3: filter chips in a
    bordered "Filter (eingrenzen)" strip on top, result list below.
    Inbox channel chip lives behind an "Erweitert" details summary
    per §3.3; picking CMS / beA auto-nudges the Forum chip. Party
    chip retains a "Beide" option (Mode A is filter mode per §11.Q8;
    Mode B drops it in S4).
  * `fristenrechner-result.ts` — new `mountModeShell(activeTab)`
    renders the two mode tabs per §11.Q2 and lazy-imports Mode A.
    Mode B tab is a placeholder until S4 lands.
  * `fristenrechner.ts` boot — when `?overhaul=1` is set and `?event`
    is empty, mountModeShell takes over (default tab = search; `?mode=
    wizard` opens the wizard tab when S4 ships). With `?event=` the
    flow still jumps straight to the result view. URL state syncs
    forum / pt / kind / party / q on every chip click.
  * 28 i18n keys added (DE + EN parity), 310-line CSS block for the
    mode tabs + Mode A surface.

Backend:
  * New `ProceedingListOptions { Jurisdiction, Kind }` + service
    method `ListProceedings(ctx, opts)`. Legacy
    `ListFristenrechnerTypes` keeps the no-filter signature for
    existing callers. Handler `/api/tools/proceeding-types` accepts
    `?jurisdiction=` and `?kind=` query params.
  * `kind=proceeding` filter targets the taxonomy column landed in
    mig 153 (parallel branch t-paliad-325, m/paliad#147). Sequenced
    per the taxonomy doc §7 option (c): mig 153 merges before S3
    ships to main, so the filter is never false-positive (no phase
    / side_action / meta rows leak into the chip strip).

Verified — bun build clean (2955 i18n keys, data-i18n attributes
clean), 249 frontend tests pass, go build + vet clean. New
TestListProceedings — 4 PASS (no-filter, jurisdiction=UPC,
jurisdiction=DE, ListFristenrechnerTypes alias) + 1 SKIP for the
kind=proceeding case that probes the column and skips when mig 153
hasn't landed yet. S1 + S2 live tests still green.
2026-05-27 10:07:27 +02:00
mAi
058a36976b Merge: t-paliad-324 — proceeding_types taxonomy design doc (docs only) (m/paliad#147)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
atlas shipped the 580-line design ratifying Model 1 (kind discriminator) for the proceeding_types cleanup. All 11 PRDs answered by m in §10.

Final categorisation (46 active rows):
- 23 kind='proceeding' (18 with corpus + 5 unloaded primaries incl. upc.costs.cfi per m's Q2 carve-out)
- 4 phase (upc.cfi.interim/oral/decision + upc.default.cfi)
- 10 side_action (evidence/experiments/security/intervention/parties/optout/inspection/freezing/withdrawal/rehearing)
- 9 meta (case.mgmt, general.rop, service, language, representation, fees, legalaid, special, reestablishment)

Mig 153 sketch (per §3): ADD COLUMN kind text NOT NULL DEFAULT 'proceeding' CHECK in {proceeding,phase,side_action,meta}; 4 UPDATEs setting kind for the non-primary IDs; optional CHECK trigger blocking projects.proceeding_type_id from referencing non-proceeding kinds. No row moves, no FK churn — 0 downstream rules / projects / spawn FKs / concepts point at non-primary rows today (verified live, §0.1).

Sequencing (m's Q10): parallel-land with knuth's S3 of the Fristenrechner overhaul. The kind column makes Mode B R3's WHERE filter trivial; no need to serialize.

Coder gate held — atlas parks; head dispatches a fresh Sonnet coder for mig 153 + ProjectService.SetProceedingType hardening + youpc-go snapshot regen.
2026-05-27 09:55:52 +02:00
mAi
3219bff4d4 design(taxonomy): proceeding_types kind discriminator + 11 m's decisions (t-paliad-324)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Live audit established that 28 of 46 active proceeding_types have zero
downstream pressure (0 rules, 0 projects, 0 spawn FKs, 0 concepts). Mig
plan is purely additive: ADD COLUMN kind text CHECK (...), four UPDATE
statements to tag phase/side_action/meta rows, deactivate them, and add
a BEFORE INSERT/UPDATE trigger on projects.proceeding_type_id to enforce
kind='proceeding'.

m's call on the 11 AskUserQuestion decisions:
- Model 1 (kind discriminator)
- Phases implicit via procedural_events.event_kind, EXCEPT upc.costs.cfi
  stays kind='proceeding' (standalone R.151 application)
- Side-actions: kind='side_action', rules anchor on parent primary
- Schutzschrift kind='proceeding' (own RoP filing)
- DE inf + DE null + DE-vs-upc.apl unification: all keep discrete
- upc.ccr.cfi: keep status quo per t-paliad-204 S1
- DB trigger on projects only (admin-only writes on sequencing_rules)
- Deactivate non-primary rows (23 active post-mig, all kind='proceeding')
- Parallel-land vs m/paliad#146 — knuth's S3 picks up the filter

Final categorisation: 23 proceeding / 4 phase / 10 side_action / 9 meta.

No code yet — coder gate held per inventor SKILL. Design only.

Closes the inventor pass on m/paliad#147.
2026-05-27 09:54:18 +02:00
mAi
081b66ebc8 Merge: t-paliad-323 Slice S2 — Fristenrechner result view under ?overhaul=1 (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
knuth shipped S2 of the Fristenrechner overhaul (design §4, §7-S2):

- New frontend/src/client/fristenrechner-result.ts (611 LoC) — renders the shared result view: trigger card (sticky header, inline date editor), 4 priority groups (Mandatory / Recommended / Optional / Conditional) with SPAWNED badge per §4.2, per-rule rows with checkbox + inline date override + party/citation badges, write-back footer conditional on project!=null (§11.Q7 — kontextfrei mode shows informational nudge instead).
- 72-LoC test suite covers groupFollowUps + defaultChecked semantics.
- Page wiring: ?overhaul=1 query param mounts the result view in place of the legacy renderProcedureResults; both coexist this slice. Deep-link shape: ?overhaul=1&event=<code>&trigger_date=…&project=… per §5.
- audit_reason wording in the bulk write-back call: 'Aus Fristenrechner — Trigger: {name} ({date})' per §11.Q12.
- 340 LoC of new CSS (entity-table extensions, group dividers, badge tokens).
- bun build clean; 249 existing frontend tests + 9 new pass; go build + vet clean; S1 live-DB tests still green.

PAUSED AT SEAM — knuth parked persistent. S3+ (Mode A/B wizard chips) waits for the proceeding_types taxonomy redesign (m/paliad#147, atlas in flight) to ratify the qualifier set that R3 picks from.
2026-05-26 22:09:59 +02:00
mAi
9ab8dd8e0f feat(fristenrechner): Slice S2 — result view under ?overhaul=1 (m/paliad#146)
Some checks failed
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
New `frontend/src/client/fristenrechner-result.ts` module renders the
shared result surface defined in
docs/design-fristenrechner-overhaul-2026-05-26.md §4:

  * Sticky trigger card — event icon + name, proceeding/jurisdiction
    chips, inline trigger-date input that re-fetches on change.
  * Four follow-up groups — Mandatory / Recommended / Optional /
    Conditional. SPAWNED rules fold into their priority bucket with
    a `⇲ neues Verfahren` badge (§11.Q5). Conditional bucket holds
    every rule with sr.condition_expr IS NOT NULL.
  * Per-rule rows — title, duration phrase, party chip, legal-source
    citation (with youpc.org link when available), pre-checked
    checkbox driven by `defaultChecked(r)` (mandatory + recommended
    on; conditional + court-set + optional off), inline ✏ Datum
    override that re-renders.
  * Write-back footer — conditional on `?project=<uuid>` per §11.Q7;
    in kontextfrei mode the footer is hidden and an inline nudge
    invites the user to pick an Akte. CTA submits to the existing
    POST /api/projects/{id}/deadlines/bulk endpoint, stamping each
    row with `audit_reason: "Aus Fristenrechner — Trigger: {name}
    ({date})"` per §11.Q12.

Mount + URL contract — when `?overhaul=1` is set in the URL,
`fristenrechner.ts` hides every legacy panel (`fristen-step1`,
`fristen-step2`, `fristen-pathway-a`, `fristen-pathway-b`,
`fristen-step3a`, the step-1 summary) and shows the overhaul root
instead. With `?overhaul=1&event=<code>&trigger_date=…` the surface
is deep-linkable end-to-end. Without `?event=` the empty-shell
nudge renders — S3+S4 will mount the entry-mode UIs onto this same
root.

Verified — bun build clean, 249 frontend tests pass (incl. 9 new
helper tests for groupFollowUps + defaultChecked), go build + vet
clean, S1 live-DB tests still green.
2026-05-26 22:09:27 +02:00
mAi
4218d9cb52 Merge: t-paliad-323 Slice S1 — Fristenrechner backend endpoints (m/paliad#146)
Some checks failed
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
knuth shipped S1 of the Fristenrechner overhaul (docs/design-fristenrechner-overhaul-2026-05-26.md §6, §7-S1):

- GET /api/tools/fristenrechner/search?kind=events — returns procedural_events tuples with trigram ranking + follow-up counts (alongside the existing concept-card response). New service: services/fristenrechner_search_events.go (257 LoC).
- GET /api/tools/fristenrechner/follow-ups — given trigger event + date + optional party qualifier, returns sequencing_rules anchored on the event with computed due dates via pkg/litigationplanner.CalculateRule. New service: services/fristenrechner_followups.go (404 LoC).
- 6 live-DB integration tests (services/fristenrechner_followups_test.go, 205 LoC): SoC follow-ups, party narrowing, jurisdiction filters, event_kind filters, unknown-event sentinel.

No schema changes — the unified sequencing_rules model already has every column needed.

Knuth proceeds to S2 (result view under ?overhaul=1).
2026-05-26 22:01:41 +02:00
mAi
7ea415145f feat(fristenrechner): Slice S1 — backend ?kind=events + /follow-ups (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Two additive endpoints behind the Fristenrechner overhaul (design
§6.1 + §6.2 in docs/design-fristenrechner-overhaul-2026-05-26.md):

1. GET /api/tools/fristenrechner/search?kind=events — returns
   procedural_events rows directly (not aggregated concept-cards),
   one hit per (event × proceeding_type) tuple. Trigram-ranked
   against name / name_en / code. Filters: jurisdiction, proc,
   event_kind, party. Powers Mode A's result list and Mode B's R4
   landing chips. Default search shape unchanged.

2. GET /api/tools/fristenrechner/follow-ups?event=...&trigger_date=...
   — given a trigger event (by code or uuid) + date, returns the
   immediate follow-up sequencing rules with computed due dates
   via litigationplanner.CalculateRule. Each row carries priority /
   primary_party / is_court_set / is_spawn / has_condition / legal
   source / spawn target so the result view can group into
   Mandatory / Recommended / Optional / Conditional with the
   SPAWNED badge. party=claimant|defendant filters keep "both"
   rules visible.

No schema changes — unified sequencing_rules already has every
column needed. Live-DB tests cover the SoC follow-up shape, party
narrowing, jurisdiction + event_kind filters, and the unknown-
event sentinel.
2026-05-26 22:01:10 +02:00
mAi
109946edff Merge: t-paliad-322 — Fristenrechner overhaul design doc (docs only) (m/paliad#146)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
553-line design doc documenting the complete Fristenrechner UX overhaul. Coder shift gated on m's go/no-go.

Two complementary entry paths into a shared result view:
- Mode A 'Direkt suchen' — search + filter chips (Forum · Proceeding · Event-Kind · Partei), result list of procedural_events, click locks a trigger.
- Mode B 'Geführt' — 3-5 row wizard (R1 event_kind → R2 forum → R3 proceeding_type → R4 procedural_event → R5 perspective), pre-filling + auto-skip from project context, row badges marking Filter vs Qualifier.

Shared result view groups follow-up sequencing_rules by Mandatory / Recommended / Optional / Conditional (SPAWNED folded with a 'neues Verfahren' badge). Trigger card sticks with inline-editable trigger date. Write-back via POST /api/projects/{id}/deadlines/bulk through a confirm-and-edit-dates modal. Kontextfrei mode hides the CTA entirely (m §11.Q7).

Filter vs Qualifier axis taxonomy ratified:
- forum, event_kind: filters
- proceeding_type, perspective (in file mode), procedural_event: qualifiers
- inbox channel: dropped from primary surface, kept as Mode A secondary chip

Backend deltas: extend /search to return events; new /follow-ups endpoint. No schema changes — the unified sequencing_rules model already has every column needed.

6-slice migration: S1 backend handlers → S2 result view (?overhaul=1) → S3 Mode A → S4 Mode B → S5 flip flag default → S6 drop buildRowStack + cascade reads. Procedure-mode (upper half of fristenrechner.tsx) untouched.

All 12 PRD questions ratified by m on 2026-05-26 via AskUserQuestion. 10/12 matched inventor recommendation; 2 diverged (Q3 section-split UX, Q7 hide kontextfrei CTA). Per-pick reasoning + design impact in §11.

Cronus parked on mai/cronus/inventor-fristenrechner. Coder shift held pending m's go.
2026-05-26 21:47:38 +02:00
mAi
528fe35540 design(fristen): fold m's 12 decisions into Fristenrechner overhaul doc
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
All 12 questions answered via AskUserQuestion. 10/12 = inventor recommendation.
2 diverged:

  Q3 (Filter-vs-qualifier UX): m picked section-split (Filter strip above,
      result/qualifier strip below) instead of '(Pflichtangabe)' tag.
      §3.1 Mode A layout rewritten with Filter strip header; §3.2 wizard
      rows now carry Filter/Qualifier badges next to the row number.

  Q7 (No-project mode): m picked 'Hide CTA entirely' instead of disabled-
      with-hint. §4.4 footer renders only when project != null; an inline
      'Tipp: Wähle oben eine Akte' nudge replaces the missing footer.

New §11 'm's decisions (2026-05-26)' anchors each pick with reasoning where
it diverges from the recommendation. §11.1 captures the two follow-on edits
to §3.1 and §4.4. Migration plan and backend contracts unchanged.

DESIGN READY FOR REVIEW pending head's coder gate.
2026-05-26 21:45:41 +02:00
mAi
9c2788ed8c design: Fristenrechner complete UX overhaul (t-paliad-322)
Inventor shift-1 design pass for m/paliad#146.

- Mode taxonomy (Direct-search A + Wizard B → shared result view)
- Filter-vs-qualifier table ratified (forum/event_kind/inbox as filters;
  proceeding_type/perspective as qualifiers)
- Wizard branching: R1 event_kind → R2 forum → R3 proceeding_type →
  R4 procedural_event → R5 perspective; rows prefill+collapse from project
- Result view: 4 priority groups (mandatory/recommended/optional/conditional)
  with SPAWNED folded into priority + cross-proceeding badge
- Project write-back via existing POST /api/projects/{id}/deadlines/bulk
  with confirm-and-edit-dates modal and audit_reason wording
- Backend deltas: extend /api/tools/fristenrechner/search to return
  procedural_events; new /api/tools/fristenrechner/follow-ups
- No schema changes — pure UX + handler shape
- 6-slice migration plan from current buildRowStack to overhaul under
  ?overhaul=1 flag, then flip + cleanup
- One worked example (LG Düsseldorf Hinweisbeschluss)
- 12 open questions for m (3 batches of 4 via AskUserQuestion)
2026-05-26 21:30:26 +02:00
mAi
c56859058d Merge: t-paliad-321 — mig 152 dedupe identical sequencing_rule clones + Proceeding column on admin list (m/paliad#144 follow-up)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
mig 151 archived 5 of 6 duplicate procedural_events for 'Mängelbeseitigung / Zahlung' and reparented their sequencing_rules. The 6 sequencing_rules themselves were byte-for-byte clones (NULL proceeding/rule_code, 14d duration) — admin showed 6 indistinguishable rows for one legal concept.

Mig 152: full-signature partition over sequencing_rules, lowest UUID per group as canonical, archive the rest. Audit-first RAISE NOTICE pre-block surfaces every clone-group in deploy logs. Snapshot to paliad.sequencing_rules_pre_152. Reparents deadlines.sequencing_rule_id (renamed from rule_id in mig 140). Defensive set_config('paliad.audit_reason') even though sequencing_rules has no audit trigger live.

Expected outcome: 5 archived (just Mängelbeseitigung / Zahlung). Other name-groups (Antrag auf Patentänderung×4, Beginn des Hauptsacheverfahrens×2, Berufungs*-R.220.1×2) have distinct (proceeding_type_id, rule_code, duration, primary_party) signatures — legitimately different rules per proceeding, left alone.

UI: admin-rules-list gains a Proceeding column (proceeding_type.code, server-side join). Replaces the legacy Verfahrenstyp column which was broken for non-fristenrechner categories. One column for proceeding info instead of two; works for every category.

Build + vet clean. NoDuplicateSlot passes.
2026-05-26 21:28:26 +02:00
mAi
6acb1167dd feat(admin): add proceeding-type column to /admin/procedural-events list (t-paliad-321 / m/paliad#144)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Surfaces the 3-segment proceeding code (e.g. upc.inf.cfi) on the admin
rules list so the 4 legitimately-distinct same-named groups are
visually disambiguated without opening each row's edit page.

Specifically helps with:
- "Antrag auf Patentänderung" × 4 (distinct proceeding_type_ids)
- "Beginn des Hauptsacheverfahrens" × 2
- "Berufungsbegründung-R.220.1" × 2
- "Berufungsschrift-R.220.1" × 2

(The 6× "Mängelbeseitigung / Zahlung" identical clones are dedup'd by
mig 152 in the sibling commit; this column lets m verify the dedupe
landed and confirms the remaining same-named groups are intentional.)

* internal/services/rule_editor_service.go —
  - LoadProceedingTypeCodes(ctx, rows) — batch SELECT id, code FROM
    paliad.proceeding_types WHERE id = ANY(...) for every distinct
    non-NULL proceeding_type_id in rows. Returns id → code map.
    Single round-trip, firm-wide reference data (no RLS / visibility
    gate). Used only by the LIST endpoint; GetByID etc. don't need it.

* internal/handlers/admin_rules.go —
  - adminRuleResponse gains ProceedingTypeCode *string field
    (json:"proceeding_type_code,omitempty"). Populated by
    wrapRuleListResponse from the id → code map.
  - handleAdminListRules calls LoadProceedingTypeCodes after fetching
    rows, passes the map to wrapRuleListResponse.

* frontend/src/admin-rules-list.tsx —
  - Adds Proceeding column header in position 2 (between Submission
    Code and Legal Citation) per paliadin's "Place between submission-
    code and the existing columns" spec. Binds to canonical i18n
    key admin.procedural_events.col.proceeding (added below).
  - Drops the legacy Verfahrenstyp column at position 4 — the new
    code-only column at position 2 replaces it; the old column
    showed `code · name` which duplicates the new content.

* frontend/src/client/admin-rules-list.ts —
  - Rule type gains proceeding_type_code?: string | null.
  - New proceedingCodeCell(r) helper: prefers server-side
    proceeding_type_code, falls back to dropdown-lookup
    proceedingLabel for defense-in-depth on older API responses
    (the old behaviour broke for rules whose proceeding_type_id
    pointed at non-fristenrechner category proceedings; the new
    column never has that bug because the join is server-side).
  - Row rendering: new <td class="admin-rules-col-proceeding"><code>
    proceedingCodeCell(r) </code></td> in column 2.

* frontend/src/client/i18n.ts —
  - admin.procedural_events.col.proceeding alias added for DE +
    EN ("Verfahren" / "Proceeding"). Mirror style of the other
    canonical aliases from Slice A.

* frontend/src/i18n-keys.ts —
  - Generated key union extended with
    "admin.procedural_events.col.proceeding".

Build + vet clean. No new SQL — proceeding_types is firm-wide
reference data and the join uses an existing primary key.
2026-05-26 21:27:00 +02:00
mAi
4cd28bc896 feat(db): mig 152 — dedupe identical sequencing_rule clones (5 archived) (t-paliad-321 / m/paliad#144 follow-up)
Mig 151 (t-paliad-319) archived 5 of 6 duplicate procedural_events for
"Mängelbeseitigung / Zahlung" and reparented their sequencing_rules
onto the canonical PE. The 6 sequencing_rules themselves were left
active — and they are byte-for-byte clones (proceeding_type_id=NULL,
rule_code=NULL, duration 14d, primary_party=NULL, condition_expr=NULL,
…). The admin shows six indistinguishable rows for one legal concept.

This migration archives 5 of 6, keeping the row with the
lexicographically lowest UUID as canonical.

Pre-write verification (Supabase MCP, 2026-05-26):
- Exactly 1 clone-group surfaces under the full-signature query
  (procedural_event_id, proceeding_type_id, rule_code, duration_*,
  primary_party, condition_expr::text, trigger_event_id, alt_*,
  anchor_alt, combine_op, parent_id, is_spawn, spawn_*):
  6 "Mängelbeseitigung / Zahlung" rows.
- 0 paliad.deadlines reference any of the 5 to-be-archived rows
  (verified via deadlines.sequencing_rule_id JOIN; rule_id column
  was dropped in mig 140 / Slice B.4).
- Other name-duplicates (Antrag auf Patentänderung×4, Beginn des
  Hauptsacheverfahrens×2, Berufungsbegründung-R.220.1×2,
  Berufungsschrift-R.220.1×2) do NOT collapse under this signature —
  their proceeding_type_id / rule_code / duration / primary_party
  differ. Legitimately distinct rules per proceeding. This mig
  leaves them alone.

Migration shape (mirrors mig 151):
1. Build dedupe mapping (duplicate_id → canonical_id) into a
   ROW_NUMBER() OVER (PARTITION BY full-signature ORDER BY
   created_at, id::text) TEMP table.
2. PRE NOTICE: surface every clone-group with its canonical + dups
   so the deploy log shows what's about to be touched (m may want
   to spot-check).
3. Snapshot the duplicates into paliad.sequencing_rules_pre_152
   (precedent pre_091/093/095/098/140/151).
4. Reparent paliad.deadlines.sequencing_rule_id duplicate → canonical
   BEFORE archiving (defensive no-op today).
5. set_config('paliad.audit_reason', …) — defensive; sequencing_rules
   has no audit trigger yet (mig 151 §scope verified), but a future
   trigger would inherit the reason automatically.
6. UPDATE sequencing_rules SET is_active=false,
   lifecycle_state='archived' WHERE id IN dups.
7. POST assertions: expected archive count met, zero clone groups
   remaining in active+published, zero live deadlines pointing at
   an archived sequencing_rule. RAISE EXCEPTION on any mismatch.

Down: best-effort revert (flips archived → published from snapshot).
Doesn't undo the deadlines reparent (live data didn't need one;
snapshot doesn't carry pre-state of deadlines).

Build + vet clean. TestMigrations_NoDuplicateSlot passes.
2026-05-26 21:21:38 +02:00
mAi
568eac0aff Merge: t-paliad-320 — editorial seed cmd for 5 orphan deadline_concept drafts (4 concepts) (m/paliad#193)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
darwin (researcher + /mai-lexy) staged 5 lifecycle_state='draft' sequencing_rules via services.RuleEditorService.Create() for the 4 remaining orphan deadline_concepts:

  - counterclaim-for-revocation → upc.ccr.cfi, RoP.025, 3 months (32aafb64)
  - versaeumnisurteil-einspruch  → de.inf.lg, § 339 ZPO, 2 weeks Notfrist (eda1756a)
  - schriftsatznachreichung      → de.inf.lg, § 283 ZPO, 3 weeks court-set (08b1682a)
  - weiterbehandlung (EPC)       → epa.grant.exa, Art. 121 EPÜ + R. 135(1), 2 months (73674564)
  - weiterbehandlung (DPatG)     → event-rooted (NULL proc), § 123a PatG, 1 month (16e262d2)

Deliverable: cmd/seed-orphan-concept-drafts/main.go — runs against
RuleEditorService in-process; idempotent; audit-reason flag.

Editorial follow-up flagged in DPatG rule's deadline_notes: no
dpma.grant.* proceeding_type exists yet; create dpma.grant.dpma and
reassign rule 16e262d2 once added.

Drafts ready for m's editorial review at /admin/procedural-events.
2026-05-26 21:07:52 +02:00
mAi
733d21c930 feat(seed): editorial cmd to stage drafts for orphan deadline_concepts (t-paliad-320)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Stages five lifecycle_state='draft' sequencing_rules — one per orphan
deadline_concept — via services.RuleEditorService.Create(), the same
service the POST /admin/api/procedural-events handler hits internally
(audit trigger + INSTEAD-OF view trigger fan-out into procedural_events
+ sequencing_rules + legal_sources). No HTTP/auth shell, no raw SQL
writes.

Drafts (slug → proceeding):
- counterclaim-for-revocation → upc.ccr.cfi, 3 months, RoP.025
- versaeumnisurteil-einspruch → de.inf.lg, 2 weeks Notfrist, § 339 ZPO
- schriftsatznachreichung → de.inf.lg, 3 weeks court-set, § 283 ZPO
- weiterbehandlung (EPC) → epa.grant.exa, 2 months, Art. 121 EPÜ + R. 135(1) EPÜ
- weiterbehandlung (DPatG § 123a) → event-rooted (NULL proc), 1 month

The DPatG variant is event-rooted because no dpma.grant.* proceeding_type
exists yet — flagged in deadline_notes as editorial follow-up.

Idempotent: refuses to insert if (concept, proceeding, rule_code)
already exists.
2026-05-26 21:04:36 +02:00
mAi
b05bcf7eeb Merge: t-paliad-319 — mig 151 dedupe null.* procedural_events (9 archived, 5 name-groups consolidated) (m/paliad#144)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 20:54:50 +02:00
mAi
71e8023784 feat(db): mig 151 — dedupe null.* procedural_events (t-paliad-319 / m/paliad#144)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Consolidates 5 name-groups with synthetic null.<8hex> codes (minted by
mig 136 from legacy submission_code IS NULL rows) onto a single canonical
PE per name. 9 duplicate rows archived (is_active=false,
lifecycle_state='archived'), 9 sequencing_rules reparented onto their
canonical procedural_event. Worst offender: "Mängelbeseitigung /
Zahlung" 6 → 1.

Audit-first: per-row RAISE NOTICE before the writes, plus snapshots in
paliad.procedural_events_pre_151 and paliad.sequencing_rules_pre_151
(same TX, mirrors precedent pre_091/093/095/098/140). Post-asserts that
no name-group still has >1 active+published null.* row and no sr points
at an archived PE.

Pre-flight schema audit confirmed no audit trigger on procedural_events
or sequencing_rules (only INSTEAD OF triggers on deadline_rules_unified,
which don't fire on direct table writes), 0 deadlines + 0 draft_of refs
to the duplicates, and lifecycle_state has no CHECK constraint blocking
'archived'.

.down.sql best-effort restores sr.procedural_event_id and reactivates
the archived rows from the snapshot tables.

Mig already applied to youpc paliad schema via Supabase MCP within the
same TX as the applied_migrations row insert (checksum matches the
embedded file); deployed binary will see version 151 as applied.
2026-05-26 20:54:01 +02:00
mAi
d190fbe0a4 Merge: hotfix #3 mig 140 — filter POST check to active+published (B.2 dual-write scope)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 20:32:58 +02:00
mAi
e0a82d9f9e fix(mig 140): post-check filters to active+published rows only
The previous post-check compared unfiltered counts (snapshot 493 vs
sequencing_rules 231) and false-positived as "dual-write drift". Reality:
B.2 dual-write was scoped to is_active=true + lifecycle_state='published'
(the read-path universe). Archived + draft rows in deadline_rules were
never replicated to sequencing_rules because nothing read them.

Patch: filter both counts to active+published before comparison — the
invariant B.2 actually maintained. Archived/draft rows survive in
deadline_rules_pre_140 for forensic / future-backfill.

Third hotfix on mig 140 today (1: missing matview drop; 2: wrong post-check
comparand; 3: post-check missing lifecycle filter). The slice itself is
sound — every failure was in the verification path, not the data.
2026-05-26 20:32:58 +02:00
mAi
d326f9aa4a Merge: hotfix mig 140 — POST check compares snapshot to sequencing_rules (was view) (m/paliad#93 hotfix #2)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 20:28:45 +02:00
mAi
026ad2d5ee fix(mig 140): POST integrity check compares snapshot to sequencing_rules, not view
The previous post-check compared paliad.deadline_rules_pre_140 row count
to paliad.deadline_rules_unified row count and failed with
"snapshot has 493 rows, view has 231 rows — drift". That's a false
positive: the snapshot has every row (all lifecycle states + is_active),
the view filters to is_active+published. They're not supposed to match.

The right invariant: snapshot row count == sequencing_rules row count
(B.2 dual-write keeps them 1:1 across all lifecycle states). Patched.
View count stays in the RAISE NOTICE line as informational.

Refs t-paliad-305 / m/paliad#93 Slice B.4 hotfix #2.
2026-05-26 20:28:36 +02:00
mAi
13a65a6d6e Merge: Composer Slice F — section reorder/hide/add custom. Composer A→F complete (m/paliad#141)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 20:27:43 +02:00
mAi
bd7896ef68 feat(submissions): Composer Slice F — section reorder / hide / add custom (m/paliad#141)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
The final Composer slice per design doc §12. Lawyer gains full
control over section composition: drag-and-drop reorder, per-section
delete, "+ Add section" picker for custom slugs that don't appear in
the base's default spec. Combined with Slice B's hide toggle, this
closes out the A→F sequence — Composer A→F is complete.

Backend (internal/services/submission_section_service.go, +120 LoC):

- SectionService.Create — adds a new section row to a draft. Validates
  section_key + labels + kind (must be prose/requests/evidence).
  Auto-assigns next order_index when OrderIndex=0; collisions on
  (draft_id, section_key) surface as ErrInvalidInput.

- SectionService.Delete — removes one section by id. Returns
  ErrSubmissionSectionNotFound when nothing was deleted.

- SectionService.Reorder — accepts a sequence of section_ids, rewrites
  every row's order_index to (1..N)×10 transactionally. Returns the
  refreshed list. Sections not present in the sequence are silently
  ignored (defensive — partial reorder doesn't lose rows).

Handlers (internal/handlers/submission_sections.go, +180 LoC):

- POST /api/submission-drafts/{draft_id}/sections — owner-scoped via
  SubmissionDraftService.Get. 400 on slug collision / invalid kind.
- DELETE /api/submission-drafts/{draft_id}/sections/{section_id} —
  owner + section-belongs-to-draft cross-check. 204 on success.
- POST /api/submission-drafts/{draft_id}/sections/reorder — accepts
  {"section_order": [uuid, uuid, ...]}; returns refreshed sections list.

Frontend (frontend/src/client/submission-draft.ts, +260 LoC):

- Each section row gains a drag handle (⋮⋮) on the left of the head.
  Drag handle is the only draggable element; contentEditable
  selections inside the editor body keep working. HTML5 native DnD,
  no library.
- Drop-target highlighting via .submission-draft-section--drop-target
  (border-top accent). Cleanup on dragend / drop / cancel.
- Per-section "Delete" button next to the existing Hide/Include
  toggle. Confirm prompt prevents accidental loss of typed prose.
- "+ Add section" trailing affordance below the section list opens an
  inline form (slug + DE label + EN label + kind dropdown). Submit
  POSTs to the new endpoint; on success splices the row into
  state.view.sections and re-paints.

CSS (frontend/src/styles/global.css, +65 LoC):

- .submission-draft-section-handle (grab cursor + hover background +
  active=grabbing).
- .submission-draft-section--dragging / --drop-target visual states.
- .submission-draft-add-section form layout (dashed border + lime
  primary submit).

Tests (internal/services/submission_section_slice_f_test.go, NEW,
TEST_DATABASE_URL-gated):
- Create custom section + slug-collision surface as ErrInvalidInput.
- Delete + repeat-delete returns ErrSubmissionSectionNotFound.
- Reorder reverses 10 seeded sections + verifies the resulting
  order_index sequence is ascending and matches the input order.

Build hygiene: go build/vet/test -short clean (all packages);
bun run build clean (2906 i18n keys, data-i18n scan clean).

Hard rules honoured:
- NO new migrations (Slice F is pure code on Slice A's schema).
- NO behavior change for pre-Composer drafts (no section rows → no
  drag handles to drag).
- {{rule.X}} aliases preserved (custom sections render through the
  same composer pipeline as default sections).
- Q2/Q9/Q10 ratifications preserved.

This closes the Composer slice sequence A → F. The full feature set
ratified by m on 2026-05-26 is now in place:
  A — base picker + read-only section list (mig 146/147/148)
  B — editable prose + anchor-spliced render + MD→OOXML walker
  C — building-blocks library + section picker (mig 149)
  D — rich prose (headings, lists, blockquote, hyperlinks)
  E — specialist bases lg-duesseldorf + upc-formal (mig 150)
  F — section reorder / delete / add custom

t-paliad-318 Slice F
2026-05-26 20:26:53 +02:00
mAi
946f373651 Merge: Composer Slice E — specialist bases lg-duesseldorf + upc-formal (mig 150) + base-swap content survival (m/paliad#141)
Some checks failed
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 20:21:56 +02:00
41 changed files with 7721 additions and 179 deletions

View File

@@ -117,9 +117,13 @@ func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string
return fmt.Errorf("mkdir output: %w", err)
}
// 1. Proceeding types — UPC + active only. The unified upc.apl row
// 1. Proceeding types — UPC primaries only. The unified upc.apl row
// from B1 mig 134 is included; the 3 archived old appeal codes
// (is_active=false) are filtered out by the WHERE.
// (is_active=false) are filtered out by the is_active predicate.
// The kind='proceeding' predicate (mig 153, t-paliad-325) belts the
// is_active filter so phase/side_action/meta rows can't slip into
// the embedded catalog even if some future deploy re-activates one
// for an admin task.
var procs []litigationplanner.ProceedingType
if err := pool.SelectContext(ctx, &procs, `
SELECT id, code, name, name_en, description, jurisdiction,
@@ -127,7 +131,9 @@ func run(ctx context.Context, pool *sqlx.DB, output, version, sourceLabel string
trigger_event_label_de, trigger_event_label_en,
appeal_target
FROM paliad.proceeding_types
WHERE jurisdiction = 'UPC' AND is_active = true
WHERE jurisdiction = 'UPC'
AND is_active = true
AND kind = 'proceeding'
ORDER BY sort_order, id`); err != nil {
return fmt.Errorf("select proceeding_types: %w", err)
}

View File

@@ -0,0 +1,342 @@
// Command seed-orphan-concept-drafts stages draft sequencing_rules for
// deadline_concepts that have rule_count=0 ("orphans"). It calls the
// same services.RuleEditorService.Create that POST
// /admin/api/procedural-events runs internally, so the audit trigger
// + INSTEAD-OF view trigger fan-out into procedural_events +
// sequencing_rules + legal_sources fire identically. No HTTP/auth
// shell, no direct SQL writes by this command.
//
// All rules are created with lifecycle_state='draft' (forced by the
// service). The admin still reviews + publishes via
// /admin/procedural-events.
//
// t-paliad-320: editorial backlog from t-paliad-193, four remaining
// orphan concepts: counterclaim-for-revocation, versaeumnisurteil-
// einspruch, schriftsatznachreichung, weiterbehandlung. The
// weiterbehandlung concept gets two drafts (EPC Art. 121 + R. 135
// versus DPatG § 123a) since the two regimes have different durations
// and jurisdictions.
//
// Usage:
//
// DATABASE_URL=postgres://… go run ./cmd/seed-orphan-concept-drafts \
// [-dry-run] [-reason "free-text audit reason"]
//
// Idempotency: the command refuses to insert if any rule for a given
// (concept, proceeding_type, rule_code) already exists. Safe to re-run
// after a partial failure.
package main
import (
"context"
"database/sql"
"errors"
"flag"
"fmt"
"log"
"os"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/services"
)
// draftSpec captures one CreateRuleInput plus the metadata the command
// needs to resolve concept_id + proceeding_type_id from human-readable
// slugs/codes. ProceedingCode == "" means event-rooted
// (proceeding_type_id = NULL), used for cross-cutting rules whose
// jurisdiction has no matching proceeding_type yet.
type draftSpec struct {
Label string // human label for log output
ConceptSlug string
ProceedingCode string // "" → NULL proceeding_type_id (event-rooted)
SubmissionCode string
Name string
NameEN string
EventKind string
PrimaryParty string // "" → omit (NULL)
DurationValue int
DurationUnit string
Timing string
Priority string
IsCourtSet bool
RuleCode string
LegalSource string
DeadlineNotes string
DeadlineNotesEn string
}
func drafts() []draftSpec {
return []draftSpec{
// ─── 1. counterclaim-for-revocation (UPC R.25.1 ∧ R.23) ───────
{
Label: "counterclaim-for-revocation → upc.ccr.cfi",
ConceptSlug: "counterclaim-for-revocation",
ProceedingCode: "upc.ccr.cfi",
SubmissionCode: "upc.ccr.cfi.lodge",
Name: "Widerklage auf Nichtigkeit (CCR)",
NameEN: "Counterclaim for Revocation (CCR)",
EventKind: "filing",
PrimaryParty: "defendant",
DurationValue: 3,
DurationUnit: "months",
Timing: "after",
Priority: "mandatory",
IsCourtSet: false,
RuleCode: "RoP.025",
LegalSource: "UPC.RoP.25.1",
DeadlineNotes: "Die Widerklage auf Nichtigkeit (Counterclaim for Revocation, CCR) ist gemeinsam mit der Klageerwiderung (Statement of Defence) einzureichen — d. h. innerhalb von 3 Monaten ab Zustellung der Klageschrift " +
"(R. 23 i. V. m. R. 25.1 RoP). Inhaltliche Anforderungen folgen R. 25-30 RoP (insbes. R. 25.1(a)-(c) zu Antrag, Tatsachen und Beweismitteln; R. 27 zu Verfahren nach Einreichung; R. 30 zu einem Antrag auf Änderung des Patents).",
DeadlineNotesEn: "The Counterclaim for Revocation (CCR) must be lodged together with the Statement of Defence — i.e. within 3 months of service of the Statement of Claim " +
"(Rule 23 in conjunction with Rule 25.1 RoP). Substantive requirements follow Rules 25-30 RoP (in particular R. 25.1(a)-(c) on the application, facts and evidence; R. 27 on post-filing procedure; R. 30 on any application to amend the patent).",
},
// ─── 2. versaeumnisurteil-einspruch (ZPO § 339) ───────────────
{
Label: "versaeumnisurteil-einspruch → de.inf.lg",
ConceptSlug: "versaeumnisurteil-einspruch",
ProceedingCode: "de.inf.lg",
SubmissionCode: "de.inf.lg.einspruch_vu",
Name: "Einspruch gegen Versäumnisurteil",
NameEN: "Objection to default judgment",
EventKind: "filing",
PrimaryParty: "defendant",
DurationValue: 2,
DurationUnit: "weeks",
Timing: "after",
Priority: "mandatory",
IsCourtSet: false,
RuleCode: "§ 339 ZPO",
LegalSource: "DE.ZPO.339.1",
DeadlineNotes: "Notfrist von 2 Wochen ab Zustellung des Versäumnisurteils (§ 339(1) ZPO). " +
"Bei Auslandszustellung oder öffentlicher Bekanntmachung bestimmt das Gericht die Einspruchsfrist gesondert im Versäumnisurteil oder durch nachträglichen Beschluss (§ 339(2) ZPO) — in diesem Fall die gerichtlich festgesetzte Frist mit „Datum setzen“ überschreiben. " +
"Form: schriftlich oder zu Protokoll der Geschäftsstelle (§ 340(1) ZPO); die Einspruchsbegründung kann bis zum Verhandlungstermin nachgereicht werden (§ 340(3) ZPO).",
DeadlineNotesEn: "Statutory two-week emergency period (Notfrist) from service of the default judgment (§ 339(1) ZPO). " +
"If service is abroad or by public notice, the court sets the objection period separately in the default judgment or by a subsequent order (§ 339(2) ZPO) — in that case override with the court-set date. " +
"Form: in writing or before the registry clerk (§ 340(1) ZPO); substantive grounds may be filed up to the oral hearing (§ 340(3) ZPO).",
},
// ─── 3. schriftsatznachreichung (ZPO § 283) ───────────────────
{
Label: "schriftsatznachreichung → de.inf.lg",
ConceptSlug: "schriftsatznachreichung",
ProceedingCode: "de.inf.lg",
SubmissionCode: "de.inf.lg.nachreichung",
Name: "Schriftsatznachreichung",
NameEN: "Subsequent written submission",
EventKind: "filing",
PrimaryParty: "", // concept.party = "both" → no default
DurationValue: 3,
DurationUnit: "weeks",
Timing: "after",
Priority: "optional",
IsCourtSet: true,
RuleCode: "§ 283 ZPO",
LegalSource: "DE.ZPO.283",
DeadlineNotes: "Vom Gericht in der mündlichen Verhandlung gesetzte Schriftsatzfrist gem. § 283 ZPO. " +
"Sie wird nur auf Antrag einer Partei bestimmt, die sich auf neues Vorbringen des Gegners nicht erklären konnte. " +
"Die konkrete Frist (in der Praxis 2-3 Wochen) und der nachfolgende Verkündungstermin werden im Sitzungsprotokoll bzw. in der prozessleitenden Verfügung festgelegt — Default-Frist hier 3 Wochen, mit „Datum setzen“ überschreiben, sobald die Verfügung vorliegt. " +
"Nach Fristablauf darf das Gericht keine weiteren Erklärungen mehr berücksichtigen (§ 283 S. 2, § 296a ZPO).",
DeadlineNotesEn: "Court-set written-submission period under § 283 ZPO, granted on a party's application when it could not respond at the oral hearing to the opponent's new submissions. " +
"The actual period (in practice 2-3 weeks) and the announcement date are set in the hearing record / case-management order — default 3 weeks here, override via „set date“ once the order is on the file. " +
"After expiry, the court will disregard further submissions (§ 283 sent. 2, § 296a ZPO).",
},
// ─── 4. weiterbehandlung — EPC variant (Art. 121 + R. 135) ────
{
Label: "weiterbehandlung (EPC) → epa.grant.exa",
ConceptSlug: "weiterbehandlung",
ProceedingCode: "epa.grant.exa",
SubmissionCode: "epa.grant.exa.weiterbeh",
Name: "Antrag auf Weiterbehandlung",
NameEN: "Request for further processing",
EventKind: "filing",
PrimaryParty: "claimant",
DurationValue: 2,
DurationUnit: "months",
Timing: "after",
Priority: "mandatory",
IsCourtSet: false,
RuleCode: "Art. 121 EPÜ",
LegalSource: "EU.EPC-R.135.1",
DeadlineNotes: "Antrag auf Weiterbehandlung gem. Art. 121 EPÜ i. V. m. R. 135(1) EPÜ — 2 Monate ab Zustellung der Mitteilung über die Fristversäumung bzw. den eingetretenen Rechtsverlust. " +
"Der Antrag wird durch Zahlung der vorgeschriebenen Weiterbehandlungsgebühr gestellt; die versäumte Handlung muss innerhalb derselben 2-Monats-Frist nachgeholt werden (R. 135(1) EPÜ). " +
"Die Frist ist nicht verlängerbar. Ausgeschlossen sind insbesondere die Frist für die Weiterbehandlung selbst sowie die in R. 135(2) EPÜ ausdrücklich aufgeführten Fristen (u. a. die Beschwerdefrist nach Art. 108 EPÜ, die Prioritätsfrist nach Art. 87 EPÜ und die Frist zur Wiedereinsetzung).",
DeadlineNotesEn: "Request for further processing under Article 121 EPC in conjunction with Rule 135(1) EPC — two months from notification of the communication concerning the missed time limit or the loss of rights. " +
"The request is made by payment of the further-processing fee; the omitted act must be completed within the same two-month period (Rule 135(1) EPC). " +
"The period is non-extendable. Excluded: the further-processing period itself and the periods listed in Rule 135(2) EPC (notably the appeal period under Art. 108 EPC, the priority period under Art. 87 EPC, and the re-establishment period).",
},
// ─── 5. weiterbehandlung — DPatG § 123a variant ───────────────
// No `dpma.grant.*` proceeding_type exists yet, so this rule is
// event-rooted (proceeding_type_id NULL) — same pattern as 78
// other cross-cutting rules. Editorial follow-up: create a
// `dpma.grant.dpma` proceeding_type and reassign.
{
Label: "weiterbehandlung (DPatG § 123a) → event-rooted (NULL proceeding_type)",
ConceptSlug: "weiterbehandlung",
ProceedingCode: "", // event-rooted
SubmissionCode: "dpma.grant.weiterbeh",
Name: "Antrag auf Weiterbehandlung (DPMA)",
NameEN: "Request for further processing (DPMA, § 123a PatG)",
EventKind: "filing",
PrimaryParty: "claimant",
DurationValue: 1,
DurationUnit: "months",
Timing: "after",
Priority: "mandatory",
IsCourtSet: false,
RuleCode: "§ 123a PatG",
LegalSource: "DE.PatG.123a.1",
DeadlineNotes: "Antrag auf Weiterbehandlung einer DPMA-Patentanmeldung gem. § 123a PatG — 1 Monat ab Zustellung der Mitteilung über die Rechtsfolge der Fristversäumung. " +
"Innerhalb dieser Frist müssen (i) der Antrag schriftlich gestellt, (ii) die versäumte Handlung nachgeholt und (iii) die Weiterbehandlungsgebühr nach Patentkostengesetz (PatKostG) gezahlt werden. " +
"§ 123a PatG erfasst ausschließlich Anmeldungsfristen, deren Versäumung kraft Gesetzes die Zurückweisung der Anmeldung zur Folge hat. Für sonstige Fristversäumnisse kommt nur die Wiedereinsetzung nach § 123 PatG in Betracht (1 Monat ab Wegfall des Hindernisses, max. 1 Jahr ab Fristablauf). " +
"HINWEIS — Taxonomie: bisher kein dpma.grant.* proceeding_type vorhanden; Regel daher event-rooted (proceeding_type_id NULL). Editorial follow-up: dpma.grant.dpma proceeding_type anlegen und diese Regel umhängen.",
DeadlineNotesEn: "Request for further processing of a DPMA patent application under § 123a PatG — 1 month from notification of the consequence of the missed deadline. " +
"Within this period the applicant must (i) file the written request, (ii) complete the omitted act, and (iii) pay the further-processing fee under the German Patent Costs Act (PatKostG). " +
"§ 123a PatG covers only application-stage deadlines whose statutory consequence is rejection. For other missed deadlines, re-establishment under § 123 PatG is the only route (1 month from removal of the obstacle, max 1 year from the missed deadline). " +
"TAXONOMY NOTE: no dpma.grant.* proceeding_type exists yet; this rule is event-rooted (proceeding_type_id NULL). Editorial follow-up: create a dpma.grant.dpma proceeding_type and reassign this rule.",
},
}
}
func main() {
dryRun := flag.Bool("dry-run", false, "log the planned drafts but do not write")
reason := flag.String("reason", "t-paliad-320: editorial seed of orphan deadline-concept rules (researcher darwin + lex)", "audit reason recorded with each Create()")
flag.Parse()
dbURL := os.Getenv("DATABASE_URL")
if dbURL == "" {
log.Fatal("DATABASE_URL not set — export the paliad postgres URL before running")
}
ctx := context.Background()
conn, err := sqlx.Connect("postgres", dbURL)
if err != nil {
log.Fatalf("connect db: %v", err)
}
defer conn.Close()
rules := services.NewDeadlineRuleService(conn)
editor := services.NewRuleEditorService(conn, rules)
conceptIDs := map[string]uuid.UUID{}
proceedingIDs := map[string]int{}
specs := drafts()
for _, s := range specs {
if _, ok := conceptIDs[s.ConceptSlug]; ok {
continue
}
var id uuid.UUID
if err := conn.GetContext(ctx, &id,
`SELECT id FROM paliad.deadline_concepts WHERE slug = $1`, s.ConceptSlug); err != nil {
log.Fatalf("lookup concept %q: %v", s.ConceptSlug, err)
}
conceptIDs[s.ConceptSlug] = id
}
for _, s := range specs {
if s.ProceedingCode == "" {
continue
}
if _, ok := proceedingIDs[s.ProceedingCode]; ok {
continue
}
var id int
if err := conn.GetContext(ctx, &id,
`SELECT id FROM paliad.proceeding_types WHERE code = $1`, s.ProceedingCode); err != nil {
log.Fatalf("lookup proceeding_type %q: %v", s.ProceedingCode, err)
}
proceedingIDs[s.ProceedingCode] = id
}
fmt.Printf("Seeding %d drafts (dry-run=%v)\n", len(specs), *dryRun)
for i, s := range specs {
conceptID := conceptIDs[s.ConceptSlug]
var procID *int
if s.ProceedingCode != "" {
p := proceedingIDs[s.ProceedingCode]
procID = &p
}
// Idempotency: refuse if a rule with the same (concept, proceeding,
// rule_code) already exists in any lifecycle state.
if existing, err := findExisting(ctx, conn, conceptID, procID, s.RuleCode); err != nil {
log.Fatalf("[%d] idempotency check failed for %s: %v", i+1, s.Label, err)
} else if existing != uuid.Nil {
fmt.Printf(" [%d] SKIP %s — already exists as %s\n", i+1, s.Label, existing)
continue
}
input := services.CreateRuleInput{
Name: s.Name,
NameEN: s.NameEN,
ProceedingTypeID: procID,
DurationValue: s.DurationValue,
DurationUnit: s.DurationUnit,
Priority: s.Priority,
IsCourtSet: s.IsCourtSet,
}
input.ConceptID = &conceptID
code := s.SubmissionCode
input.SubmissionCode = &code
ek := s.EventKind
input.EventType = &ek
t := s.Timing
input.Timing = &t
rc := s.RuleCode
input.RuleCode = &rc
ls := s.LegalSource
input.LegalSource = &ls
dn := s.DeadlineNotes
input.DeadlineNotes = &dn
dne := s.DeadlineNotesEn
input.DeadlineNotesEn = &dne
if s.PrimaryParty != "" {
pp := s.PrimaryParty
input.PrimaryParty = &pp
}
if *dryRun {
fmt.Printf(" [%d] DRY %s (concept=%s, proc=%s, code=%s, %d %s, %s)\n",
i+1, s.Label, conceptID, codeOrNil(procID), code, s.DurationValue, s.DurationUnit, s.RuleCode)
continue
}
row, err := editor.Create(ctx, input, *reason)
if err != nil {
log.Fatalf(" [%d] CREATE failed for %s: %v", i+1, s.Label, err)
}
fmt.Printf(" [%d] OK %s → id=%s lifecycle=%s\n",
i+1, s.Label, row.ID, row.LifecycleState)
}
fmt.Println("Done.")
}
func findExisting(ctx context.Context, conn *sqlx.DB, conceptID uuid.UUID, procID *int, ruleCode string) (uuid.UUID, error) {
var id uuid.UUID
q := `
SELECT sr.id
FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
WHERE pe.concept_id = $1
AND sr.rule_code IS NOT DISTINCT FROM $2
AND sr.proceeding_type_id IS NOT DISTINCT FROM $3
LIMIT 1`
err := conn.GetContext(ctx, &id, q, conceptID, ruleCode, procID)
if errors.Is(err, sql.ErrNoRows) {
return uuid.Nil, nil
}
return id, err
}
func codeOrNil(p *int) string {
if p == nil {
return "<NULL>"
}
return fmt.Sprintf("%d", *p)
}

View File

@@ -0,0 +1,553 @@
# Design — Fristenrechner complete UX overhaul (dual-mode + project write-back)
**Task:** t-paliad-322
**Gitea:** m/paliad#146
**Inventor:** cronus (shift-1)
**Date:** 2026-05-26
**Status:** Draft for m's ratification — coder gate held
## 0. Premises verified live (before designing)
Verified against the live youpc Postgres (port 11833, paliad schema) and the live source tree on `mai/cronus/inventor-fristenrechner` @ HEAD.
### 0.1 Rule-and-event corpus today
| Table | Active+published rows | Notes |
|---|---|---|
| `paliad.procedural_events` | 222 (236 total) | The events that anchor a deadline. 4 `event_kind` buckets: `filing`, `hearing`, `decision`, `order`, plus `NULL` for legacy/dpma stragglers. |
| `paliad.sequencing_rules` | 231 | The deadlines themselves, anchored on `procedural_event_id` and (sometimes) `trigger_event_id`. 80 carry a `trigger_event_id`, 4 are `is_spawn=true`, 45 are `is_court_set=true`, 18 carry a `condition_expr`. |
| `paliad.deadline_concepts` | 57 | Hub layer above events (Klageerhebung, Wiedereinsetzung, …). |
| `paliad.proceeding_types` | 46 fristenrechner | 4 jurisdictions: UPC (35), DE (5), EPA (3), DPMA (3). |
| `paliad.event_categories` | 125 (103 leaves) | The current cascade tree — 5 user-bucket roots (`cms-eingang`, `muendl-verhandlung`, `beschluss-entscheidung`, `frist-verpasst`, `ich-moechte-einreichen`) + `sonstiges` leaf. UI hides the forward-workflow root (`HIDDEN_CASCADE_ROOTS` in `client/fristenrechner.ts:2605`). |
| `paliad.deadlines` | 10 (8 with `sequencing_rule_id`) | Demand-side still tiny. The 2 without `sequencing_rule_id` are manual entries. |
Live `primary_party` vocabulary on `sequencing_rules`: `claimant`, `defendant`, `both`, `court`, `NULL`. Live `priority` vocabulary: `mandatory`, `recommended`, `optional` (no `informational` rows yet — Phase 2 reserved the slot but seeding is deferred).
### 0.2 The legacy `deadline_rules` reader is a view
`paliad.deadline_rules_unified` (mig 139, Slice B.3) is a **view** over `sequencing_rules ⋈ procedural_events ⋈ legal_sources`. All Go calculator paths read through it (see `deadline_rule_service.go:70`). The physical `paliad.deadline_rules` table was dropped in mig 140; the view is the canonical legacy-shape reader. Important for this design: there is no "trigger event" table parallel to events — the rule rows themselves are the things the wizard must land on. `trigger_events` (110 rows) is the youpc-parity legacy table used by `/api/tools/event-deadlines` only.
### 0.3 The frontend today (`/tools/fristenrechner`)
Two server-rendered surfaces share the same page (`frontend/src/fristenrechner.tsx`, 657 lines) — the legacy "Procedure mode" (R1 step-list, proceeding picker, trigger date, flag checkboxes) and the **Pathway-B row stack** (`buildRowStack` at `client/fristenrechner.ts:2848`, 4009 lines total). Row stack composes three row kinds via a single `.fristen-row` primitive:
| Row | Source | Filter or qualifier today |
|---|---|---|
| R1 Perspective (Beide / Klägerseite / Beklagtenseite) | `currentPerspective`, prefilled from `project.our_side` | hybrid — narrows party-tagged cascade chips AND is used as a column-bucket hint in the result view |
| R2 Inbox channel (CMS / beA / Postal / Alle) | `currentInboxChannel` | filter — narrows cascade by forum (CMS → upc, beA → de, …) |
| R3..Rn Cascade chain | `event_categories` tree | each step narrows children by `inboxFilterAllowsForums` + `perspectiveAllowsParty` + `cascadeChildAllowsProject` |
The cascade auto-walks single-child branches under a project context and stops at the first branching point. The user picks a leaf; the leaf's slug feeds `/api/tools/fristenrechner/search?event_category_slug=…` which returns concept cards. Each card expands inline to a calc panel (trigger-date input + flags + computed deadline + "in Akte" CTA).
### 0.4 What is broken in this UI (m's verdict, 2026-05-26 21:21)
m's brief in m/paliad#146 enumerates four visible bugs:
1. **"Beide" as default perspective** is incoherent for the headline use case ("file a deadline because something happened" — you ARE one side).
2. **R2 (inbox) does not constrain R3 (cascade)** the way a user expects — picking CMS still leaves "Mündliche Verhandlung" / "Frist verpasst" on the next step. (Cause: those roots have `forums=NULL` in the seed → neutral → visible from every inbox.)
3. **Mixed axes** — the form layers filters (forum, inbox channel) on top of qualifiers (event-kind, perspective, proceeding_type) without making the difference visible. The user can't tell which picks narrow and which define.
4. **Trigger vs follow-up confusion** — the wizard's purpose is to identify the *trigger event*, then surface the *follow-up deadlines*. Today that split is not reflected in the form. After landing on a leaf, the user gets a flat list of concept cards and has to figure out which one is "the thing that happened" vs "the thing I have to file next".
m's verdict: "complete overhaul. Should be easy to use."
### 0.5 Anchor files for the eventual coder
- `frontend/src/client/fristenrechner.ts` (4009 LoC) — page brain. `buildRowStack` @ L2848, `renderRowStack` @ L3112, `runB1Search` (concept-card render) downstream, `expandCardCalc` @ L1337 (inline calc panel), `openSaveModal` @ L290 + `submitSave` @ L374 (project write-back).
- `frontend/src/fristenrechner.tsx` (657 LoC) — server-rendered shell. Contains both the Procedure-mode form **and** the Pathway-B row-stack scaffold. The new design replaces the row-stack scaffold; the procedure-mode form survives.
- `internal/handlers/fristenrechner.go` + `_search.go` + `_event_categories.go` — three handler files. `POST /api/tools/fristenrechner` (procedure-mode calc), `GET /search` (concept cards), `GET /event-categories` (cascade tree).
- `internal/services/fristenrechner.go` (661 LoC) — calculator adapter to `pkg/litigationplanner`. The calculator is **not** touched by this design.
- `internal/handlers/deadlines.go:167` + `services/deadline_service.go:411` (`CreateBulk`) — the project write-back endpoint (`POST /api/projects/{id}/deadlines/bulk`). This survives; the design extends its caller.
### 0.6 Adjacent design docs to read alongside
- `docs/design-determinator-row-cascade-2026-05-13.md` — the row-cascade pillars (Project-driven narrowing / Visual hierarchy overhaul / Persistent row stack). This overhaul **keeps** Pillars 2 and 3 and reworks Pillar 1's contract.
- `docs/design-fristen-phase2-2026-05-15.md` — the unified `sequencing_rules` model the calculator already runs on.
- `docs/audit-fristen-logic-2026-05-13.md` — the trigger-event / Pipeline-A-vs-C distinction.
---
## 1. Vision
**One page, two complementary entry paths, one result surface, one write-back.**
```text
┌───────────────────────── /tools/fristenrechner ─────────────────────────┐
│ │
│ ╭──────── Akte / kontextfrei ────────╮ (Step 0 — unchanged today) │
│ │ HL-2024-001 ▼ | ohne Akte │ │
│ ╰─────────────────────────────────────╯ │
│ │
│ ╭────── Entry mode tabs ──────╮ │
│ │ [⚡ Direkt suchen] │ ◀── A: power user, search + chips │
│ │ [🧭 Geführt] │ ◀── B: 3-5 question wizard │
│ ╰─────────────────────────────╯ │
│ │
│ ┌── Mode A: Suche ──────────────┐ ┌── Mode B: Wizard ────────────────┐│
│ │ search-box ▢▢▢▢▢▢▢▢▢▢▢▢▢▢▢ │ │ R1 Was ist passiert? ✓ filing ││
│ │ filter chips: │ │ R2 Forum? ✓ UPC ││
│ │ Forum · Proceeding · Event- │ │ R3 Verfahren? ✓ INF ││
│ │ Kind · Partei │ │ R4 Welcher Schritt? [active] ││
│ │ ┌ Ergebnis-Liste ────────────┐│ │ R5 Welche Seite? ✓ Kläger ││
│ │ │ procedural_event hits ││ │ ││
│ │ │ [Trigger einrasten →] ││ │ (Direkt-suchen ←) ││
│ │ └────────────────────────────┘│ └───────────────────────────────────┘│
│ └────────────────────────────────┘ │
│ │
│ ════ shared from here ═══════════════════════════════════════════════ │
│ │
│ ┌── Trigger event (locked) ──────────────────────────────────────────┐ │
│ │ 📥 Klageschrift wurde eingereicht │ │
│ │ upc.inf.cfi · Verletzungsverfahren · Klägerseite │ │
│ │ Trigger-Datum: [📅 2026-05-20] (heute) │ │
│ │ ändern ↩ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
│ │
│ ┌── Folge-Fristen ────────────────────────────────────────────────────┐ │
│ │ ◉ MANDATORY (auto-checked) │ │
│ │ ☑ Klageerwiderung (3 Monate) — 20.08.2026 — RoP 23 ✏ Datum │ │
│ │ ☑ ... │ │
│ │ ◇ OPTIONAL │ │
│ │ □ Wiedereinsetzungsantrag (R.320) — bei Versäumnis │ │
│ │ ◊ CONDITIONAL │ │
│ │ □ Erwiderung auf Nichtigkeitswiderklage nur wenn CCR │ │
│ │ ⇲ SPAWNED │ │
│ │ ☑ Berufung gegen Endurteil (kein Datum) │ │
│ │ ╭────────────────────────────╮ │ │
│ │ │ 4 ausgewählt → in Akte ▶ │ │ │
│ │ ╰────────────────────────────╯ │ │
│ └─────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────┘
```
The two modes never compete: they're two front doors into the **same** locked-trigger-event → follow-up-list → write-back flow.
---
## 2. Axis taxonomy — ratified (filters vs qualifiers)
The headline source of today's UX confusion is the unmarked mixing of *filters* (narrowing the question space without committing to an answer) and *qualifiers* (parts of the eventual deadline definition).
| Axis | Role | Source | Constrains | Visual in new UI |
|---|---|---|---|---|
| `forum` | **filter** | derived from `proceeding_types.jurisdiction` (UPC/DE/EPA/DPMA), or from `project.proceeding_type_id`, or user pick | which `proceeding_types` are reachable; which `event_categories` are visible | Mode A: filter chip strip. Mode B: explicit wizard row (R2). Pre-filled + collapsed when there's a project. |
| `proceeding_type` | **qualifier** | `project.proceeding_type_id` (binds via mig 096 codes) OR user pick during wizard | the set of `sequencing_rules` rows that can apply | Mode A: filter chip strip. Mode B: explicit wizard row (R3). Pre-filled + collapsed when there's a project. |
| `event_kind` | **filter** | `procedural_events.event_kind` (filing / hearing / decision / order) | which `procedural_events` are reachable as triggers | Mode A: filter chip strip. Mode B: explicit wizard row (R1 — the headline question). |
| `inbox channel` | **filter** (today) → **out of scope row** (new) | user pick | nothing the user can see (the rule corpus has no "inbox" column; it was only used to recolour the cascade) | Removed from the primary wizard. Pushed into Mode A's secondary chips (off by default). See §3.3. |
| `perspective (our_side)` | **qualifier in file-mode**, **filter in explore-mode** | `project.our_side` OR user pick OR implicit-via-event-kind | `sequencing_rules.primary_party`; result-view column bucketing | Wizard tail (R5) **only when** the trigger event's follow-ups actually differ by side. Pre-filled when project has `our_side`. |
| `instance_level` (first / appeal / cassation) | qualifier | `project.instance_level` (mig 084) — sparse | rare — used to disambiguate APP+DE | Surfaced only when the wizard hits APP+DE-style ambiguity. |
**Rule:** a **filter** narrows the visible options without locking in a deadline answer; it can be cleared and re-applied. A **qualifier** is part of the resulting deadline calculation and is locked the moment it's picked. Filters must propagate forward (Mode A's forum-chip narrows the proceeding-chip's options). Qualifiers are picked once and the answer view reads them.
The "Beide" perspective default (today's bug) is wrong because perspective is a *qualifier* in the headline use case ("file a deadline because something happened — you are one side"), not a *filter*. New default in Mode B: derive from the project's `our_side`, otherwise force a R5 pick (no "Beide"). See Q8 for the explore-mode exception.
---
## 3. Mode taxonomy
### 3.1 Mode A — "⚡ Direkt suchen" (power user)
Two visually distinct strips (per m §11.Q3):
```text
┌── Filter (eingrenzen) ─────────────────────────────────────────────────┐
│ Forum: [UPC] [DE] [EPA] [DPMA] [Alle] │
│ Verfahren: [upc.inf.cfi] [...] [Alle] │
│ Was passierte: [📥 Eingereicht] [🏛️ Termin] [⚖️ Entscheidung] [📜 Verfügung] [Alle] │
│ Partei: [Klägerseite] [Beklagtenseite] [Beide] │
├── Suchen ──────────────────────────────────────────────────────────────┤
│ 🔎 [_______________________________________________________________] │
└─────────────────────────────────────────────────────────────────────────┘
┌── Ergebnisse (klicken = als Trigger einrasten) ────────────────────────┐
│ 📥 Klageerhebung · upc.inf.cfi · UPC · 3 Folge-Fristen → │
│ ... │
└─────────────────────────────────────────────────────────────────────────┘
```
Single text input, four filter chip strips above it (Forum · Proceeding · Event-Kind · Partei), and a ranked result list of `procedural_events` underneath. The "Filter" strip is visibly grouped (e.g. light background + "Filter (eingrenzen)" header) so users see at a glance that those picks narrow but don't commit; clicking a result row IS the commit (the qualifier action).
- Search hits `/api/tools/fristenrechner/search` (extended to return events, not just concepts — see §6.1).
- Filter chips compose with the text query (`?forum=upc&pt=upc.inf.cfi&kind=filing&party=defendant&q=Klageerwiderung`).
- Result rows are individual `procedural_events` (not aggregated concept-cards). Each row shows: name (DE/EN), proceeding_type code, jurisdiction badge, event_kind icon, the rule-count it triggers ("3 Folge-Fristen").
- Click a row → "lock as trigger event" → page transitions to the §4 result view.
- Power affordance: a row with multiple linked rules can be locked in **per-rule** ("nur diese Frist") via a kebab menu on the row. (Sane default: lock the whole event; the kebab is for the lawyer who only wants one specific reactive deadline.)
### 3.2 Mode B — "🧭 Geführt" (the wizard)
A 3-5 question row stack that lands on one `procedural_events` row.
**Question order (strawman; m to ratify in Q5):**
1. **R1 — Was ist passiert?** Chips: 📥 Eingereicht (`filing`) · 🏛️ Termin (`hearing`) · ⚖️ Entscheidung (`decision`) · 📜 Verfügung (`order`) · 🕒 Frist versäumt (special bucket, routes to Wiedereinsetzung). One chip = one `event_kind` (or the special). Always asked. ~6 chips, fits one row.
2. **R2 — Vor welchem Gericht / bei welchem Amt?** Chips: UPC · LG/OLG/BGH · EPA · DPMA. Pre-filled from `project.proceeding_type → jurisdiction` (or `project.court` substring). **Skipped if R1 narrows to a single forum** (e.g. "Termin" + project has UPC → R2 is implied).
3. **R3 — In welchem Verfahren?** Chips: every active `proceeding_type` whose jurisdiction matches R2 AND whose event roster contains at least one event with R1's kind. Pre-filled from `project.proceeding_type_id`. **Auto-skipped** when the narrowed scope has only one candidate.
4. **R4 — Welches Schriftstück / Welcher Termin?** This is the wizard's landing question. Chips = `procedural_events` filtered by (R2 forum, R3 proceeding_type, R1 event_kind). Typical scope: 1-12 events. If the user types into this row, the chip layout flips to a search list (same widget as Mode A's result list, narrowed to the wizard's filters).
5. **R5 — Vertreten Sie Kläger- oder Beklagtenseite?** Asked **only when** the selected event's `sequencing_rules` have follow-ups that differ by `primary_party` (a quick "are there both claimant- and defendant-tagged rules among the follow-ups?" check on the catalog). Pre-filled from `project.our_side`. **Skipped otherwise.**
**Row badges** (per m §11.Q3): each wizard row carries a small "Filter" or "Qualifier" tag next to its row-number badge. R1 (event_kind), R2 (forum) → "Filter". R3 (proceeding_type), R4 (procedural_event), R5 (perspective) → "Qualifier". A user can tell at a glance which picks lock in vs which narrow.
Branching policy (locked):
- Pre-fill + collapse a row when the answer is implied by the project (Determinator §4 pattern, unchanged).
- Auto-skip a row when the narrowed scope has exactly one option (the user has effectively no choice). Show the skipped row as a compact `.fristen-row.is-prefilled` line with "(aus Akte)" or "(implizit aus R1)" annotation. *Don't hide the row* — m's "see your selections" pillar from the row-cascade design demands every decision stays visible.
- A user-edited upstream answer **preserves compatible downstream picks** (m §11.Q10): if a re-picked R2 (forum) keeps the existing R1 (event_kind) legal, R1 stays; if it makes R3 (proceeding_type) illegal, R3 resets to active. Rows whose pick was carried across an upstream change render with a one-render "erhalten" annotation so the user notices.
- "Welches Schriftstück?" (R4) is the landing question. Once R4 is answered, the wizard exits and the §4 result view takes over.
### 3.3 The dropped `inbox channel` row
R2-inbox in today's row stack is removed from the primary surface for both modes. Rationale:
- The rule corpus has no `inbox` column. The cascade's `forums=['cms']` etc. tags were a presentation-layer reflection of which forum naturally arrives on which channel — but the rule itself doesn't change based on whether a UPC document arrived via CMS or by post (it can't; only CMS is legal). So the only honest role for "inbox" is to nudge the forum filter on Mode A.
- Mode A keeps inbox as a *secondary* chip strip ("Erweitert" toggle, off by default). Picking CMS auto-sets the forum chip to UPC; picking beA auto-sets it to DE. The user can override.
- Mode B never asks. The wizard derives forum from project context or from R2.
This collapses one bug class entirely (R2-not-constraining-R3) by retiring R2 from the headline path.
---
## 4. Shared result view — "follow-up deadlines"
Once a trigger event is locked (via Mode A click or Mode B R4 pick), the same result view renders.
### 4.1 Trigger card (sticky header)
```text
┌─ Trigger-Ereignis ─────────────────────────────────────────────────────┐
│ 📥 Klageerhebung │
│ upc.inf.cfi · Verletzungsverfahren · UPC │
│ ⓘ "Einreichung der Klageschrift gemäß R.13 RoP" │
│ Trigger-Datum: 📅 2026-05-20 [ändern ↩] │
└─────────────────────────────────────────────────────────────────────────┘
```
`Trigger-Datum` defaults to today. The user can change it inline (date picker). Changing it re-renders the follow-ups with new computed dates.
The "ändern" link drops back to whichever mode the user came from with R1-R4 still answered. (Per Q4: the wizard preserves compatible upstream picks rather than rebooting.)
### 4.2 Follow-up groups
Group `sequencing_rules` rows that have the trigger event as **anchor** (i.e. `sr.procedural_event_id = trigger.id`) into 4 visible groups:
1. **MANDATORY** (`priority='mandatory'`) — pre-checked. The bread-and-butter follow-ups.
2. **RECOMMENDED** (`priority='recommended'`) — pre-checked. Best-practice fillings (R.19 EPÜ Einspruch, replication briefs).
3. **OPTIONAL** (`priority='optional'`) — unchecked. Discretionary actions (R.320 Wiedereinsetzung).
4. **CONDITIONAL** (`condition_expr IS NOT NULL`) — unchecked, with the condition rendered ("nur wenn CCR im Verfahren"). Lawyer ticks if applicable.
Plus a fifth implicit bucket:
5. **SPAWNED / CROSS-PROCEEDING** (`is_spawn=true`, `spawn_proceeding_type_id IS NOT NULL`) — surfaced as a separate sub-section with a clear "leitet ein neues Verfahren ein" annotation. Pre-checked when mandatory.
Recommendation (Q6): **4 visible groups, with SPAWNED inlined into whichever priority bucket it belongs to but tagged with a "⇲ neues Verfahren" badge.** Five groups is too many for a one-page result; folding SPAWNED into its priority keeps the math right (mandatory spawned = mandatory) while still flagging the cross-proceeding implication.
### 4.3 Per-rule row
```text
☑ Klageerwiderung ✏ Datum
3 Monate nach Klageerhebung 20.08.2026
RoP 23 · Beklagtenseite
ⓘ Schriftlich, mit Vollmacht. Erstmaliges Bestreiten der Patentverletzung.
```
Columns: checkbox · title (DE/EN) · duration phrase · computed due date · rule citation · party stance · expandable notes.
Inline date editor (✏ Datum) lets the lawyer override the computed date for this rule (same affordance as today's `wireDateEditClicks`). The override flows into the write-back payload.
`is_court_set=true` rules render with the "wird vom Gericht bestimmt" placeholder instead of a date and are unchecked-by-default (matches the current `openSaveModal` behaviour).
### 4.4 Result-view footer (write-back CTA)
```text
┌─ Auswahl ──────────────────────────────────────────────────────────────┐
│ 4 Fristen ausgewählt → In Akte HL-2024-001 eintragen ▶ │
│ (oder: 2 mit eigenem Datum, 2 mit Standardberechnung) │
└─────────────────────────────────────────────────────────────────────────┘
```
The CTA opens a **confirm-and-edit-dates modal** (per m §11.Q6) where the lawyer can revise each selected deadline's due date one last time, then commits via `POST /api/projects/{id}/deadlines/bulk` (today's endpoint).
**Kontextfrei mode (no Akte)** — per m §11.Q7, the entire write-back footer **does not render** when `project == null`. The result view stays informational. In its place, an inline nudge appears above the deadline groups:
```text
ⓘ Tipp: Wähle oben eine Akte, um diese Fristen einzutragen.
```
The "oben" link focuses the Akte picker. Once a project is picked, the nudge collapses and the footer materialises; no page reload, no result-view rebuild (the trigger event and date persist across the project pick).
Modal payload per deadline (extends today's `CreateDeadlineInput`):
```json
{
"title": "Klageerwiderung",
"rule_code": "RoP 23",
"due_date": "2026-08-20",
"original_due_date": "2026-08-20",
"source": "fristenrechner",
"rule_id": "<sequencing_rules.id>", /* maps to deadlines.sequencing_rule_id */
"notes": "..."
}
```
**audit_reason wording (per Q12):** every row inserted via this flow carries an audit-log breadcrumb on the project (matches the deadline `Verlauf` pattern). Default reason string:
> `Aus Fristenrechner — Trigger: {trigger_event_name} ({trigger_date_iso})`
e.g. `Aus Fristenrechner — Trigger: Klageerhebung (2026-05-20)`. Falls into `paliad.project_events` with `kind='deadline_created'` via the existing `DeadlineService.CreateBulk` audit hook; no schema change needed.
---
## 5. URL / state representation
The new flow keeps Pathway-B's URL-as-state contract, simplified:
| Param | Owner | Meaning |
|---|---|---|
| `project` | Step 0 | Active project UUID. Drives the prefills. |
| `mode` | mode tab | `wizard` (default) or `search`. |
| `q` | Mode A | Free text query. |
| `forum` | Mode A | Comma-separated forum codes (`upc,de`). Mode B writes this only when the wizard derives it. |
| `pt` | Mode A | Selected proceeding_type code. |
| `kind` | Mode A | event_kind chip pick. |
| `party` | both | Perspective. Mode A's chip; Mode B's R5. |
| `wizard` | Mode B | Dotted state cursor encoding which row is active and the picks made: `wizard=kind:filing,forum:upc,pt:upc.inf.cfi,active:event`. |
| `event` | both | The locked trigger `procedural_events.code`. Once set, the result view renders. |
| `trigger_date` | result | ISO date. Default = today; URL only carries it when overridden. |
| `selected` | result | Comma-separated `sequencing_rules.id` checkbox state. Only carried when it differs from the priority default. |
Deep links work end-to-end: `?project=…&event=upc.inf.cfi.soc&trigger_date=2026-05-20&selected=…` jumps a colleague straight to the result view with the same picks. (Per Q11 — query string, not pathname.)
`popstate` rebuilds the page from the params alone (same pattern as today). The wizard state cursor lets browser back/forward step the wizard rows instead of dropping back to the page root.
---
## 6. Backend contract changes
### 6.1 Extend `/api/tools/fristenrechner/search`
Today returns concept-cards. Add an alternate response shape (or a `?kind=events` flag) that returns `procedural_events` rows directly:
```json
{
"query": "Klageerhebung",
"filters": { "forum": "upc", "pt": null, "kind": "filing", "party": null },
"events": [
{
"id": "<uuid>",
"code": "upc.inf.cfi.soc",
"name_de": "Klageerhebung",
"name_en": "Statement of Claim",
"event_kind": "filing",
"proceeding_type": { "code": "upc.inf.cfi", "jurisdiction": "UPC", "name": "..." },
"follow_up_count": 3,
"concept_id": "<uuid>",
"score": 0.92
}
],
"total": 12
}
```
The concept-card shape stays available for the legacy Pathway-B-filter route (kept as a deep-link compat surface, not user-facing).
### 6.2 New `/api/tools/fristenrechner/follow-ups`
Given a trigger event id + trigger date + optional party qualifier, return the follow-up `sequencing_rules` rows, grouped + with computed dates. Wire shape:
```json
{
"trigger": { "id": "...", "code": "upc.inf.cfi.soc", "name_de": "Klageerhebung", "event_kind": "filing", "proceeding_type": { "code": "upc.inf.cfi", "name_de": "Verletzungsverfahren", "jurisdiction": "UPC" } },
"trigger_date": "2026-05-20",
"party": "claimant",
"follow_ups": [
{
"rule_id": "<uuid>",
"title_de": "Klageerwiderung",
"title_en": "Defence",
"priority": "mandatory",
"primary_party": "defendant",
"duration_phrase": "3 Monate",
"due_date": "2026-08-20",
"is_court_set": false,
"is_spawn": false,
"condition_expr": null,
"rule_code": "RoP 23",
"notes_de": "...",
"spawn_label": null,
"spawn_proceeding_type": null,
"appeal_target": null
}
]
}
```
Implementation: `FristenrechnerService.LookupFollowUps(ctx, eventID, triggerDate, party)` — wraps `catalog.LookupEvents(axes={EventID:…, Depth:Next})` (already implemented for the Litigation Planner per `services/fristenrechner.go:251`) and runs the result through `pkg/litigationplanner.Calculate` to fill the dates. The calculator is unchanged.
### 6.3 No schema changes
This design is pure UX + handler shape. The unified `sequencing_rules` model already has every column needed (priority, condition_expr, spawn_*, is_court_set, primary_party, applies_to_target). No migration accompanies this design.
---
## 7. Migration plan — from current row stack to the overhaul
Drop nothing on day one; co-exist for one release. The cutover is by URL flag.
| Phase | What changes | What survives | Branch |
|---|---|---|---|
| **S1 — Backend** | Add `GET /search?kind=events`. Add `GET /follow-ups`. Both feature-flagged behind a request header (off by default). | Existing endpoints. | one PR |
| **S2 — Result view** | New `frontend/src/client/fristenrechner-result.ts` module — given a trigger event + date, render the §4 result view. Mount under a `?overhaul=1` query flag on /tools/fristenrechner. The legacy `renderProcedureResults` stays. | All today's UI. | one PR |
| **S3 — Mode A** | New search-with-filter-chips UI. Mount alongside the row stack under `?overhaul=1`. | Row stack still primary. | one PR |
| **S4 — Mode B (wizard)** | New `frontend/src/client/fristenrechner-wizard.ts` — the 3-5 row stack. Replaces today's `buildRowStack` only when `?overhaul=1`. Project prefill logic from `buildRowStack` ports 1:1. | The legacy row stack stays in place under no flag. | one PR |
| **S5 — Flip the flag** | `?overhaul=1` becomes the default. Legacy row stack and `event_categories`-based cascade rendered with a hard-coded `?legacy=1` for two weeks. | Procedure mode (the upper half of `fristenrechner.tsx`) is unchanged throughout. | one PR |
| **S6 — Cleanup** | Drop the `buildRowStack` function tree and the `event_categories`-served cascade endpoint (the table can stay — it's still semantically a useful taxonomy for future tools, just not the Fristenrechner's UI). Drop the `HIDDEN_CASCADE_ROOTS` constant and the cascade-segment bridge. | None of today's row-stack code. | one PR |
Single project per slice; each PR rebases off main; no shared branches.
The `event_categories` table itself **stays**`audit-fristen-logic-2026-05-13.md` §2.4 already calls it "a config layer" useful for taxonomy work. The Fristenrechner just no longer reads it. Future tools (the "Ich möchte einreichen" forward-workflow surface m hid in `HIDDEN_CASCADE_ROOTS`) can resurrect it without DB migration.
---
## 8. Worked example — "PA at LG Düsseldorf bekommt einen Hinweisbeschluss via CMS in einer aktiven Akte"
Project: `HL-2024-001`, proceeding_type=`de.inf.lg` (Verletzungsverfahren LG), `our_side='defendant'`, `court='LG Düsseldorf'`.
### 8.1 Wizard path (Mode B, default)
User opens /tools/fristenrechner with that project in Step 0. Mode tab defaults to "🧭 Geführt".
Wizard rows render top-to-bottom, pre-filled where the project implies:
```text
[1] Was ist passiert? [ active — chips for filing/hearing/decision/order/missed ]
[2] Vor welchem Gericht? ✓ LG (aus Akte: HL-2024-001) ← prefilled+collapsed
[3] In welchem Verfahren? ✓ Verletzungsverfahren (de.inf.lg) ← prefilled+collapsed
```
User clicks ⚖️ Entscheidung in R1.
Row stack updates:
```text
[1] Was ist passiert? ✓ Entscheidung ← answered
[2] Vor welchem Gericht? ✓ LG (aus Akte) ← prefilled
[3] In welchem Verfahren? ✓ Verletzungsverfahren (de.inf.lg) ← prefilled
[4] Welche Entscheidung konkret? [ active — chips: Urteil, Beschluss, Hinweisbeschluss, ... ]
```
R4 chip set is the `procedural_events` whose `proceeding_type_id` = de.inf.lg AND `event_kind` = 'decision'. (Hinweisbeschluss is in this set — `de.inf.lg.hinweisbeschluss` or similar.)
User clicks Hinweisbeschluss. The wizard checks: do the follow-up rules differ by `primary_party`? In this case yes (the Hinweis triggers a reply window for the defendant only). So R5 fires:
```text
[5] Welche Seite vertreten Sie? ✓ Beklagtenseite (aus Akte) ← prefilled
```
R5 is pre-filled from `project.our_side='defendant'`. The user could click ändern to override, but doesn't.
Wizard transitions to the §4 result view. Trigger card: "📜 Hinweisbeschluss · de.inf.lg · LG · Beklagtenseite". Trigger date defaults to today.
### 8.2 Result view
Three follow-ups in scope (illustrative):
```text
MANDATORY
☑ Stellungnahme zum Hinweisbeschluss (Frist 4 Wochen) — 24.06.2026 — ZPO §139
RECOMMENDED
☑ Anpassung der Klageerwiderung — 24.06.2026 — best practice
OPTIONAL
□ Antrag auf Fristverlängerung (begründet) — auf Antrag
```
User unchecks "Anpassung", changes the Stellungnahme date inline to 2026-06-20 (one weekday earlier), clicks "In Akte HL-2024-001 eintragen ▶".
Modal opens with the 1 selected deadline + the user's date override. User confirms.
### 8.3 Write-back
Server-side: `POST /api/projects/HL-2024-001/deadlines/bulk` with one `CreateDeadlineInput`:
```json
{
"title": "Stellungnahme zum Hinweisbeschluss",
"rule_code": "ZPO §139",
"due_date": "2026-06-20",
"original_due_date": "2026-06-24",
"source": "fristenrechner",
"rule_id": "<sr-uuid>",
"notes": null
}
```
`DeadlineService.CreateBulk` inserts the row into `paliad.deadlines` (with `sequencing_rule_id` populated from `rule_id`), creates the audit event with the wording "Aus Fristenrechner — Trigger: Hinweisbeschluss (2026-05-26)", and the user is redirected to `/deadlines?project_id=…` with a green success toast.
### 8.4 Mode A path for the same user
User flips the mode tab to "⚡ Direkt suchen". Filter chips auto-load to Forum=DE + Proceeding=de.inf.lg (from project context). User types "Hinweis" — the result list shows `de.inf.lg.hinweisbeschluss` (and maybe `upc.inf.cfi.hinweis` filtered out because Forum=DE narrows it). User clicks. Same result view appears.
Total clicks Mode A: 2 (type + click). Mode B: 2 (R1 chip + R4 chip; R2/R3/R5 prefilled). The wizard wins for trainees who don't know vocabulary; search wins for power users who know "Hinweisbeschluss" and can type 4 chars.
---
## 9. What's NOT in scope
- **Replacing the `sequencing_rules` model.** Phase 3 schema is already what the calculator runs on.
- **Paliadin (LLM) integration into the wizard.** A "Frist-Extraktion aus Dokument" path is filed elsewhere (memory `b6a11b55…`) and stays out of this design. The wizard could later call out to Paliadin for "the user typed something we don't know" — Phase 2 of *this* overhaul, not Phase 1.
- **Calendar / Outlook sync** of created deadlines. Separate t-paliad ticket per project-status.md long-term goals.
- **Editing `sequencing_rules`** from the result view. Read-only here. The admin surface at `/admin/procedural-events` handles editing.
- **The Procedure-mode surface** (upper half of `fristenrechner.tsx`). The proceeding-picker + trigger-date + flag-checkbox UI stays exactly as it is today. That surface answers a different question ("show me the full procedural ablauf for upc.inf.cfi") and is the right tool for that question; the overhaul targets only the Pathway-B / row-stack half of the page.
---
## 10. Open questions for m (12 questions, batched for `AskUserQuestion`)
All 12 questions tracked in m/paliad#146 § "Open design questions". Each gets a recommended option (listed first in the AskUserQuestion call). Bundled into 3 batches of 4.
| # | Topic | Recommended pick |
|---|---|---|
| Q1 | Single page or stepper? | Single page with mode-tabs + collapsible rows. |
| Q2 | Mode switcher placement | Tab pair under Step-0 ("Akte / kontextfrei"). |
| Q3 | Filter-vs-qualifier UX | Qualifiers carry a small "(Pflichtangabe)" tag; filters render in a slimmer pill. |
| Q4 | Cascade tree (keep/replace) | Replace with the 5-question wizard. Drop `event_categories` from the Fristenrechner UI (table stays). |
| Q5 | Result grouping | 4 visible groups (Mandatory / Recommended / Optional / Conditional), SPAWNED folded with badge. |
| Q6 | Project write-back UX | Confirm-and-edit-dates modal (revise each date once before commit). |
| Q7 | No-project mode | CTA disabled with hint ("Wähle eine Akte oben"). Match today's pattern. |
| Q8 | Perspective semantics by mode | Mode B (file): qualifier — required pick. Mode A (search): filter — optional. |
| Q9 | Trigger-date input timing | In the result-view trigger card; default today; inline editable. |
| Q10 | Backward navigation | Preserve compatible downstream picks; reset only those invalidated. |
| Q11 | Deep-link encoding | Query string (`?event=…&trigger_date=…`). |
| Q12 | Audit reason wording | `Aus Fristenrechner — Trigger: {name} ({date})`. |
(Recommendations land as the "first option" in each AskUserQuestion call per the inventor SKILL contract.)
---
## 11. m's decisions (2026-05-26)
All 12 questions answered via `AskUserQuestion` on 2026-05-26 21:30. Recording each pick + the reasoning where it diverges from the inventor's recommendation. Sections of the design that are now load-bearing on these answers carry a "(m §11.Q{n})" cross-reference.
- **Q1 (Page layout): Single page, mode-tabs.** [= recommendation] Both modes share /tools/fristenrechner; the mode-tabs swap the question surface in place. Result view is shared. **Locks §3, §4, §5.**
- **Q2 (Mode switcher): Tab pair under Step-0.** [= recommendation] "⚡ Direkt suchen" / "🧭 Geführt" tabs render directly below the Akte picker. Project context survives the tab flip; compatible filter picks (forum, proceeding) carry across.
- **Q3 (Filter-vs-qualifier UX): Section split — Filter above, Qualifier below.** [≠ recommendation; m picked option 2.] Mode A's filter chips render in a "Filter (eingrenzen)" strip on top; below it, the result list is the qualifier surface (clicking a row locks). Mode B wizard rows carry a small "Filter" / "Qualifier" badge in the row badge area (e.g. R1/R2 = Filter, R3/R5 = Qualifier). The "(Pflichtangabe)" tag from the original recommendation is replaced by this section-level visual hierarchy. **Updates §3.1 (Mode A layout) and §3.2 (wizard row badges).**
- **Q4 (Cascade tree): Replace with wizard, keep table.** [= recommendation] The Fristenrechner UI stops reading `paliad.event_categories`. The table stays for future tools (the hidden "Ich möchte einreichen" forward-workflow). **Locks §3.2 and the cleanup in §7 S6.**
- **Q5 (Result grouping): 4 groups + SPAWNED badge.** [= recommendation] Mandatory / Recommended / Optional / Conditional are the four sub-sections; spawned rules fold into their priority bucket with a `⇲ neues Verfahren` badge. **Locks §4.2.**
- **Q6 (Write-back UX): Confirm-and-edit-dates modal.** [= recommendation] Inline checkbox selection in the result view → "In Akte eintragen ▶" → modal with editable due-date fields per row + Akte picker. **Locks §4.4.**
- **Q7 (No-project mode): Hide the CTA entirely.** [≠ recommendation; m picked option 3.] In kontextfrei mode the result view renders without the write-back footer at all — no disabled-with-hint button. Rationale (inferred from m's pick): the result view is informational by design in explore mode, and a permanently-disabled CTA is visual noise. **Updates §4.4** — the CTA is conditional on `project != null`, not on `disabled`. The hint message moves into the Step-0 picker: when a user is in kontextfrei mode and reaches a result view, a one-line nudge appears above the result groups ("Tipp: Wähle oben eine Akte, um diese Fristen einzutragen") with a link to focus the Akte picker. This preserves the affordance discovery without polluting the footer.
- **Q8 (Perspective semantics): Mode B qualifier, Mode A filter.** [= recommendation] Wizard Mode B's R5 is required and Klagerseite/Beklagtenseite only (no "Beide"); Mode A's perspective chip is a filter with a "Beide" option, off by default. **Locks §2 axis table and §3.2 R5 description.**
- **Q9 (Trigger-date input): In the result-view trigger card.** [= recommendation] The sticky header card on the result view shows the date; default today; inline editable. Changing it re-renders follow-up dates live. **Locks §4.1.**
- **Q10 (Backward navigation): Preserve compatible picks.** [= recommendation] Re-opening any wizard row keeps downstream picks that are still legal under the new upstream value; resets only the picks the new value invalidates. A small chip-strip annotation ("erhalten") appears for one render-cycle on rows whose pick was carried so the user notices. **Updates §3.2 branching policy.**
- **Q11 (Deep-link encoding): Query string.** [= recommendation] `?project=…&mode=…&event=…&trigger_date=…&selected=…&forum=…&pt=…&kind=…&party=…` — every state piece is a query param. `popstate` rebuilds the page from params. **Locks §5.**
- **Q12 (Audit reason wording): `Aus Fristenrechner — Trigger: {name} ({date})`.** [= recommendation] German-locale, includes the trigger event name and its ISO date. Stored as `paliad.project_events.metadata->>'audit_reason'` via the existing `DeadlineService.CreateBulk` audit hook. **Locks §4.4.**
### 11.1 What changed from the strawman as a result
Two follow-on edits flow from m's picks:
1. **§3.1 Mode A layout** — top strip is "Filter (eingrenzen)" with the four filter chip groups (Forum · Proceeding · Event-Kind · Partei); the result list directly below carries the implicit "click here to lock" qualifier action. No "(Pflichtangabe)" tag.
2. **§4.4 Write-back footer** — the footer is rendered conditionally on `project != null`. The kontextfrei-mode informational nudge moves into the result view body above the deadline groups.
These edits don't change the §7 migration plan or the §6 backend contracts.
---
## 12. Synthesis links
- mBrian topic: `topic-fristenrechner` (existing) — file this design as a `[synthesis]` node linked `triggered_by` t-paliad-322 and `related_to` the row-cascade + Phase 2 designs.
- Related memories: row-cascade design `0fbd2c1a-…`, Phase 2 design `a454dc86-…`, audit logic `f6c0c3a2-…`.

View File

@@ -0,0 +1,580 @@
# Design — `paliad.proceeding_types` taxonomy cleanup: primary proceedings vs phases vs side-actions vs meta
**Task:** t-paliad-324
**Gitea:** m/paliad#147
**Inventor:** atlas (shift-1)
**Date:** 2026-05-26
**Status:** Draft — coder gate held until m ratifies the 10 design questions in §9
**Branch:** `mai/atlas/inventor-proceeding`
---
## 0. Premises verified live (before designing)
Verified against live youpc Postgres (port 11833, `paliad` schema) on 2026-05-26 22:05. Findings supersede the audit grouping in m/paliad#147 wherever they diverge — the issue body was correct on shape but conservative on counts.
### 0.1 The 46-row table, fully classified by usage
`paliad.proceeding_types` has 49 rows total; 46 active, 3 inactive (`upc.apl.merits/cost/order` — superseded by `upc.apl.unified`, id 160) plus 1 archive bucket (`_archived_litigation`, id 32). Cross-references against the four downstream consumers:
| Consumer | Column | Active rows that point at the 46 active types |
|---|---|---|
| `paliad.sequencing_rules.proceeding_type_id` | rule's anchor proceeding | **18 distinct rows used** — the primaries with corpus. 28 rows have 0 rules. |
| `paliad.sequencing_rules.spawn_proceeding_type_id` | cross-proceeding spawn target | **1 distinct row used**`upc.apl.merits` (id=11, **inactive!**). 0 active types are spawn targets. |
| `paliad.projects.proceeding_type_id` | project's primary type | **6 distinct rows used** (across 18 projects). All 6 are in the 18 primaries. |
| `paliad.event_category_concepts.proceeding_type_code` | concept's owning proceeding | **18 distinct codes used.** 3 of those codes (`upc.apl.merits`, `upc.apl.order`, `upc.apl.cost`) point at **inactive** rows — pre-existing data drift from the `upc.apl.unified` merger (flagged §8, out of scope here). |
The audit answer in one sentence: **of the 46 active rows, only 18 have any downstream consumer pointing at them today** (the 18 primaries with corpus). The remaining 28 rows are decorative — they exist in the table but nothing references them.
This makes reparenting **trivially safe**: no FK invariant breaks, no SQL update touches existing data, no migration risk.
### 0.2 The 18 primaries with corpus (rules + concepts)
Ordered by `paliad.sequencing_rules` count (descending), with `event_category_concepts` count alongside:
| id | code | jurisdiction | rules | concepts | projects |
|---:|---|---|---:|---:|---:|
| 8 | `upc.inf.cfi` | UPC | 25 | 14 | 1 |
| 9 | `upc.rev.cfi` | UPC | 17 | 10 | 0 |
| 160 | `upc.apl.unified` | UPC | 16 | 0 *(see drift note)* | 0 |
| 12 | `de.inf.lg` | DE | 11 | 4 | 1 |
| 13 | `de.null.bpatg` | DE | 10 | 4 | 1 |
| 14 | `epa.opp.opd` | EPA | 8 | 7 | 1 |
| 15 | `epa.opp.boa` | EPA | 8 | 12 | 0 |
| 16 | `epa.grant.exa` | EPA | 8 | 0 | 0 |
| 17 | `upc.dmgs.cfi` | UPC | 8 | 1 | 0 |
| 26 | `de.inf.bgh` | DE | 8 | 17 | 0 |
| 25 | `de.inf.olg` | DE | 7 | 8 | 0 |
| 10 | `upc.pi.cfi` | UPC | 7 | 3 | 0 |
| 27 | `de.null.bgh` | DE | 6 | 10 | 0 |
| 29 | `dpma.appeal.bpatg` | DPMA | 5 | 6 | 0 |
| 30 | `dpma.appeal.bgh` | DPMA | 4 | 8 | 0 |
| 28 | `dpma.opp.dpma` | DPMA | 4 | 3 | 1 |
| 18 | `upc.disc.cfi` | UPC | 4 | 1 | 0 |
| 35 | `upc.ccr.cfi` | UPC | 1 | 0 | 1 |
These 18 are unambiguously **primary proceedings** in the m/paliad#147 sense — self-contained matters, own filing, own deadline cascade, own ablauf. They survive every model.
### 0.3 The 4 unloaded primaries (Group A continued)
Four more active rows are conceptually primaries but carry **zero rules and zero concepts today** — seeded for catalog completeness, waiting for corpus:
| id | code | jurisdiction | what it is |
|---:|---|---|---|
| 171 | `upc.dni.cfi` | UPC | Negative Feststellungsklage — standalone declaratory action |
| 172 | `upc.epo.review` | UPC | Überprüfung von EPA-Entscheidungen — standalone review action |
| 179 | `upc.bsv.cfi` | UPC | Beweissicherung / saisie — standalone evidence-preservation order |
| 188 | `upc.pl.cfi` | UPC | Schutzschrift — pre-litigation defensive filing |
These are **primary** by character (each has its own RoP-defined filing pathway and its own deadline tree once rules get seeded) but **unloaded** today. Decision: keep them as `kind='proceeding'` so Mode B R3 surfaces them for future rule attachment and `pkg/litigationplanner` accepts them as valid catalog codes.
§9 Q3.b discusses `upc.pl.cfi` (it's the only borderline — Schutzschrift is technically a pre-action filing, not a proceeding at the time of filing). m's call.
### 0.4 The 28 non-primary rows
The 28 active rows that have **zero rules + zero concepts + zero projects pointing at them** group cleanly into three categories:
#### Group B — Phases of a primary CFI proceeding (5 rows)
These describe stages *within* an existing CFI proceeding, not standalone matters. A `upc.inf.cfi` action passes through interim → oral → decision phases; the phase isn't a separately-elected proceeding type.
| id | code | name |
|---:|---|---|
| 173 | `upc.cfi.interim` | CFI - Zwischenverfahren |
| 174 | `upc.cfi.oral` | CFI - Mündliche Verhandlung |
| 175 | `upc.cfi.decision` | CFI - Endentscheidung |
| 176 | `upc.costs.cfi` | Separate Kostenentscheidung *(post-decision sub-phase)* |
| 185 | `upc.default.cfi` | Versäumnisentscheidung *(alt. decision outcome)* |
The "phase" concept already has a natural home in the data model: `paliad.procedural_events.event_kind` (filing/hearing/decision/order). What `upc.cfi.interim` actually represents is "all events with kind=filing under upc.inf.cfi/upc.rev.cfi/upc.pi.cfi/etc."; `upc.cfi.oral` is "all events with kind=hearing"; `upc.cfi.decision` is "all events with kind=decision". The proceeding-type row buys nothing the event_kind already carries.
#### Group C — Side-actions inside a proceeding (10 rows)
Applications and court orders that arise *inside* a primary proceeding. They could each become a `condition_expr`-gated rule on the parent proceeding when corpus arrives; they don't need their own proceeding row.
| id | code | name |
|---:|---|---|
| 178 | `upc.evidence.cfi` | Beweisanordnungen (allgemein) |
| 182 | `upc.experiments.cfi` | Gerichtlich angeordnete Versuche |
| 177 | `upc.security.cfi` | Sicherheitsleistung |
| 184 | `upc.intervention.rop` | Streitbeitritt |
| 165 | `upc.parties.change` | Parteiwechsel / Patentübergang |
| 170 | `upc.optout.cfi` | Antrag auf Opt-out |
| 180 | `upc.inspection.cfi` | Besichtigungsantrag |
| 181 | `upc.freezing.cfi` | Anordnung zur Vermögenssperre |
| 187 | `upc.withdrawal.rop` | Klagerücknahme |
| 183 | `upc.rehearing.coa` | Wiederaufnahmeantrag |
A subtle distinction: `upc.bsv.cfi` (Beweissicherung) IS a standalone primary (its own RoP filing) whereas `upc.evidence.cfi` (Beweisanordnungen allgemein) is a side-action class (orders the court makes inside any proceeding). The two are not duplicates; the categorisation is structural, not nominal.
#### Group D — Cross-cutting administrative / meta (8 rows)
These describe rules-of-procedure mechanics, not matters a lawyer takes on. None of them is a "Verfahren" in any user-facing sense.
| id | code | name |
|---:|---|---|
| 162 | `upc.case.mgmt` | Verfahrensverwaltung |
| 161 | `upc.general.rop` | Allgemeine Bestimmungen |
| 163 | `upc.service.rop` | Zustellung von Schriftsätzen |
| 168 | `upc.language.rop` | Verfahrenssprache |
| 164 | `upc.representation.rop` | Vertretung / Anwaltsprivileg |
| 166 | `upc.fees.court` | Gerichtsgebühren |
| 167 | `upc.legalaid.cfi` | Prozesskostenhilfe |
| 186 | `upc.special.cfi` | Besondere Verfahrenslagen |
| 169 | `upc.reestablishment.rop` | Wiedereinsetzung in den vorigen Stand *(cross-cutting; applies to every proceeding)* |
`upc.reestablishment.rop` lands in Group D because **every** proceeding has a Wiedereinsetzung path — it isn't a kind-of-proceeding, it's a cross-cutting remedy. Today's rules already model it correctly (it's a `condition_expr`-gated rule on each primary, not a separately-elected proceeding type).
### 0.5 Counts reconciled
| Group | Count | Total of 46 |
|---|---:|---:|
| A.1 Primary with corpus (18 rows) | 18 | |
| A.2 Primary, unloaded (4 rows) | 4 | |
| B Phases (5 rows) | 5 | |
| C Side-actions (10 rows) | 10 | |
| D Meta / cross-cutting (9 rows) | 9 | |
| **Total** | | **46 ✓** |
m/paliad#147's audit listed 8 Group-D rows; live data shows 9 once `upc.reestablishment.rop` is moved into the meta bucket (it appeared as ambiguous "cross-cutting admin / meta" — confirming this design's read).
---
## 1. Categorization — ratified
The taxonomy proposal: a row in `paliad.proceeding_types` has exactly one of four **structural kinds**.
| `kind` | What it is | Visible in Mode B R3 wizard? | In `pkg/litigationplanner` catalog? | Eligible for `projects.proceeding_type_id`? |
|---|---|---|---|---|
| `proceeding` | A self-contained matter with its own filing pathway and its own deadline tree | **Yes** | **Yes** (filtered by `kind='proceeding' AND is_active=true`) | **Yes** |
| `phase` | A stage *within* a primary proceeding | No | No | No |
| `side_action` | An application/order that arises inside a primary proceeding | No | No | No |
| `meta` | RoP mechanics, cross-cutting rules, court administration | No | No | No |
This is **Model 1 from m/paliad#147** (kind discriminator on `proceeding_types`). §2 explains why it beats Models 2-4 for the actual data.
The 46 active rows map to the 4 kinds as follows:
- **`proceeding` (22 rows):** all 18 primaries-with-corpus + the 4 unloaded primaries from §0.3. Specifically the union of §0.2 + §0.3.
- **`phase` (5 rows):** the §0.4 Group B list.
- **`side_action` (10 rows):** the §0.4 Group C list.
- **`meta` (9 rows):** the §0.4 Group D list (incl. `upc.reestablishment.rop`).
### 1.1 Edge calls
- **`upc.ccr.cfi` (id 35)** — stays `kind='proceeding'` with the existing routing-to-`upc.inf.cfi` from t-paliad-204 §0.3 S1 (the determinator surfaces it, the mapping returns inf.cfi's id with `with_ccr=true`). Rationale: the routing layer is already built and m ratified it 2026-05-18. This design does not re-open that decision. §9 Q7 lets m revisit.
- **`upc.pl.cfi` (Schutzschrift, id 188)** — borderline. Schutzschrift is filed *before* a proceeding exists; it's a defensive pre-litigation filing. Recommendation: keep as `kind='proceeding'` (it has its own RoP path + its own deadlines once seeded). The alternative — calling it `side_action` of a not-yet-existing inf.cfi — is semantically backwards. §9 Q3.b lets m revisit.
- **`upc.bsv.cfi` (saisie, id 179)** vs **`upc.evidence.cfi` (id 178)** — bsv stays `kind='proceeding'` (own RoP filing under R.192-198), evidence stays `kind='side_action'` (the orders a court makes inside any proceeding under R.190). The codes are not duplicates.
### 1.2 What the categorisation buys
- **Mode B R3 (Fristenrechner overhaul, t-paliad-322)** queries `proceeding_types WHERE is_active AND kind='proceeding'` and gets a clean 22-row pick list — no phase/side-action/meta noise.
- **`projects.proceeding_type_id` integrity** is enforceable: an FK + CHECK (or a triggered constraint, see §3.3) blocks setting a project's type to anything except `kind='proceeding'`.
- **`pkg/litigationplanner` snapshot generator** filters identically; youpc.org's catalog stays UPC-primary-only with no leakage of phase/admin rows.
- **Determinator + dropdowns** get a forward-compatible filter; future feature work (e.g. "show me all side-actions available in this proceeding") becomes a different query against the same table.
- **Forward-compatibility for new rows** — when corpus for a side-action arrives (e.g. `upc.evidence.cfi` gains 4 sequencing_rules with `condition_expr='evidence_order_issued'`), the rules anchor on the *parent* primary, not on the side-action row. The kind classification stays correct; the side-action row remains a taxonomic label.
---
## 2. Model choice — Model 1 (kind discriminator)
### 2.1 The four candidate models, scored
| Model | Schema churn | Models phase parentage? | Mode B R3 filter | Migration risk | Verdict |
|---|---|---|---|---|---|
| **1. `kind` discriminator on `proceeding_types`** | One column + CHECK constraint | No, but doesn't need to | `WHERE kind='proceeding'` | Trivial — UPDATE only | **Recommended** |
| 2. Self-referencing `parent_id` | One column + FK + CHECK | Yes, but parentage is wrong shape (phases are phase-of-EVERY-CFI, not of one) | `WHERE parent_id IS NULL` | Trivial | Over-modelled |
| 3. Separate tables | Three new tables + view/JOINs | Yes, fully | Just query `proceeding_types` | Migration churn + every consumer query learns a new shape | Overkill for 28 unused rows |
| 4. Move phases into `procedural_events` | One mass row-move + DELETE | n/a (phases vanish from `proceeding_types`) | Trivial | Highest — would touch event_kind taxonomy and Fristenrechner result-view structure | Wrong shape (phases ≠ events) |
### 2.2 Why Model 1 wins
The fundamental observation: **the 28 non-primary rows have zero downstream pressure**. No rule, no project, no concept, no spawn FK references them. They exist in the table as taxonomic placeholders — names someone wrote down so future corpus could attach. We don't need to physically restructure the table; we just need to label what's what so consumers can filter correctly.
Model 1 gives us exactly that with one column. The other models pay schema/migration cost to model a parent-child relationship that **no consumer queries**. Mode B R3 doesn't ask "what are the phases of upc.inf.cfi?" — it asks "what are the proceedings I can pick?". The Fristenrechner result view doesn't ask the proceeding-types table about phases — phases live inside `procedural_events.event_kind` and the priority-bucket sub-sections in the §4.2 of the Fristenrechner overhaul doc.
Model 2's `parent_id` is wrong in shape: `upc.cfi.interim` doesn't have ONE parent (`upc.inf.cfi`), it has SEVEN parents (every CFI proceeding). Modelling that as a self-reference would force either (a) duplicating the phase rows per primary, or (b) using NULL parent_id for "applies to all". Both options are uglier than just dropping parent_id and trusting `kind='phase'`.
Model 3's separate tables would create rich relations that no consumer reads. Premature relational normalisation.
Model 4 would force phases into `procedural_events`, but phases aren't events. A phase is a *bucket of events*. The bucket is already implicit in the `event_kind` column (filing → interim, hearing → oral, decision → decision). If anything, Model 4 is *backwards* — phases should disappear into `event_kind`, not become event rows. The way to "delete" the phase rows from proceeding_types is just to deactivate them (or mark them `kind='phase'`); we don't need to re-locate them into another table to claim that conceptual move.
### 2.3 What we don't do — physical deletion
The 28 non-primary rows are NOT dropped from the table. They:
- Get tagged with the right `kind` value.
- Optionally get `is_active=false` flipped (m's call, §9 Q9).
- Stay in the table so consumers that historically referenced them by id (admin tools, audit logs, future schema-rescue scripts) keep working.
`DROP` is a one-way door we don't need to walk through. The CHECK constraint + kind tagging gives us the same logical cleanliness with none of the irreversibility risk.
---
## 3. Schema sketch + migration plan
### 3.1 DDL — the new column
```sql
-- Migration NNN_proceeding_types_kind.up.sql
-- (NNN = whatever MAX(version) + 1 is at write time; see project-status.md
-- for the live numbering. As of 2026-05-26 the head is mig 152 per the
-- recent dedupe of identical sequencing_rule clones.)
ALTER TABLE paliad.proceeding_types
ADD COLUMN kind text NOT NULL DEFAULT 'proceeding'
CHECK (kind IN ('proceeding', 'phase', 'side_action', 'meta'));
COMMENT ON COLUMN paliad.proceeding_types.kind IS
'Structural classification — see docs/design-proceeding-types-taxonomy-2026-05-26.md §1. '
'proceeding = self-contained matter (own filing + deadline tree); '
'phase = stage inside a primary CFI proceeding; '
'side_action = application/order inside a proceeding; '
'meta = RoP mechanics, court admin, cross-cutting remedies.';
CREATE INDEX proceeding_types_kind_active_idx
ON paliad.proceeding_types(kind, is_active)
WHERE is_active = true;
```
The DEFAULT keeps existing inserts (admin tooling, snapshot tests) safe: any new row defaults to `proceeding`. The CHECK enforces the vocabulary at write time.
### 3.2 Data move — UPDATE statements, no INSERT/DELETE
```sql
-- Phases (per m's Q2 carve-out: upc.costs.cfi (176) is NOT a phase, it stays primary)
UPDATE paliad.proceeding_types
SET kind = 'phase'
WHERE id IN (173, 174, 175, 185); -- §0.4 Group B minus 176
-- Side-actions
UPDATE paliad.proceeding_types
SET kind = 'side_action'
WHERE id IN (178, 182, 177, 184, 165, 170, 180, 181, 187, 183); -- §0.4 Group C
-- Meta / cross-cutting
UPDATE paliad.proceeding_types
SET kind = 'meta'
WHERE id IN (162, 161, 163, 168, 164, 166, 167, 186, 169); -- §0.4 Group D
-- Primaries (incl. m's Q2 carve-out for upc.costs.cfi) stay on the DEFAULT
-- 'proceeding' value — no UPDATE needed.
-- Per m's Q9: deactivate the non-primary rows so the admin list surfaces only
-- primaries. The kind column carries the semantic info; is_active controls UI
-- visibility. Reversible — flip is_active back on if a row gains corpus.
UPDATE paliad.proceeding_types
SET is_active = false
WHERE kind IN ('phase', 'side_action', 'meta');
```
Per m's Q9, the `is_active=false` flip is mandatory in this mig. After it: 23 active rows (all `kind='proceeding'`), 23 inactive rows (the phase/side_action/meta set), in addition to the pre-existing inactive appeal-triplet + archived bucket. The `kind` column tells consumers what each row IS; `is_active` tells consumers whether to show it.
### 3.3 Optional integrity constraints
If m wants stronger guarantees that `projects.proceeding_type_id` can only point at primaries, add a deferrable FK validator. Cleanest pattern in Postgres:
```sql
-- Option A: trigger-based check (works for any kind set, deferred-friendly).
CREATE OR REPLACE FUNCTION paliad.assert_project_type_is_proceeding()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF NEW.proceeding_type_id IS NOT NULL THEN
PERFORM 1 FROM paliad.proceeding_types
WHERE id = NEW.proceeding_type_id AND kind = 'proceeding';
IF NOT FOUND THEN
RAISE EXCEPTION 'projects.proceeding_type_id must reference a kind=proceeding row, got id=%', NEW.proceeding_type_id
USING ERRCODE = '23514';
END IF;
END IF;
RETURN NEW;
END $$;
CREATE TRIGGER projects_proceeding_type_kind_check
BEFORE INSERT OR UPDATE OF proceeding_type_id ON paliad.projects
FOR EACH ROW EXECUTE FUNCTION paliad.assert_project_type_is_proceeding();
```
Per m's Q8: **trigger on `projects` only**, no symmetric enforcement on `sequencing_rules`. Projects are written via the public app (the surface most exposed to operator error); rules are edited via the admin `/admin/procedural-events` surface which already validates against active+published lifecycle. The single trigger is enough.
### 3.4 Migration sequencing — single self-contained mig
One migration file:
```
internal/db/migrations/153_proceeding_types_kind.up.sql
internal/db/migrations/153_proceeding_types_kind.down.sql
```
Up does ALTER + UPDATE + (optional) trigger creation. Down does DROP COLUMN (cascading the trigger if present). No data loss on either direction — the kind column is purely additive.
Mig number depends on what knuth lands first; the coder reads `MAX(version)` at write time per the project's mig conventions.
---
## 4. FK reparenting tables
There is no reparenting to do. Below for completeness:
| Source table.column | Pointing at non-primary rows? | Action |
|---|---|---|
| `sequencing_rules.proceeding_type_id` | **0 active rules** (verified §0.1) | None |
| `sequencing_rules.spawn_proceeding_type_id` | **0 active rules** point at non-primaries; 4 active rules point at id=11 (inactive `upc.apl.merits`) | Pre-existing drift, out of scope (§8) |
| `projects.proceeding_type_id` | **0 projects** (all 6 distinct values are primaries) | None |
| `event_category_concepts.proceeding_type_code` | **0 concepts** point at non-primary codes; 30 concepts point at `upc.apl.merits/order/cost` codes (which are inactive but conceptually primaries) | Pre-existing drift, out of scope (§8) |
The "FK reparent" section of the acceptance criteria in m/paliad#147 is a no-op for this design: the 28 rows being re-classified have **no incoming references** to reparent. The migration is pure relabelling.
---
## 5. Worked example — `upc.cfi.interim` after the mig
### 5.1 Today (broken)
Someone created the row `upc.cfi.interim` (id 173, name "CFI - Zwischenverfahren") in `paliad.proceeding_types` with `category='fristenrechner'`. The intent was probably "we'll attach interim-phase rules here later". Result:
- The row appears in the Mode B R3 wizard chip strip (if R3 queries `WHERE is_active=true AND jurisdiction='UPC'`) — confusing to the user, because "Zwischenverfahren" is not a proceeding they pick; it's a stage their proceeding passes through.
- The row could be set as `projects.proceeding_type_id` (no FK constraint forbids it today) — corrupting the SmartTimeline's lane logic, which assumes the project's type is a primary.
- The row appears in admin /admin/proceeding-types lists, polluting the primary-proceedings overview.
### 5.2 After mig 153
The migration runs:
```sql
UPDATE paliad.proceeding_types SET kind = 'phase' WHERE id = 173;
-- Optionally: UPDATE paliad.proceeding_types SET is_active = false WHERE id = 173;
```
Now:
- Mode B R3 query becomes `WHERE is_active=true AND jurisdiction = $1 AND kind='proceeding'`. `upc.cfi.interim` is filtered out — it is not a "Verfahren" the user can pick.
- A future admin who tries to set a project's `proceeding_type_id = 173` either fails the optional trigger from §3.3 (with a clear error) or gets a code-level rejection from `ProjectService.SetProceedingType` (which the coder will harden to filter by `kind='proceeding'`).
- The `pkg/litigationplanner` snapshot generator filter becomes `WHERE is_active=true AND category='fristenrechner' AND kind='proceeding' AND jurisdiction IN ('UPC')`. The row never makes it into the youpc.org catalog.
The row itself stays in the database. Its id is stable. Future work that wants to *use* the phase row as a taxonomic label (e.g. "show me which event_kinds map to which UPC phases") gets a clean shape: query `WHERE kind='phase' AND code LIKE 'upc.cfi.%'`.
### 5.3 Where interim-phase deadlines actually live
The user-facing concept "interim phase" is already modelled correctly, just elsewhere:
- A `procedural_events` row like `upc.inf.cfi.soc` (Statement of Claim) has `event_kind='filing'`. The Fristenrechner overhaul (t-paliad-322 §4.2) groups follow-ups by priority + presents them under the trigger card. There is no UI element that needs a "Zwischenverfahren" proceeding-type label to operate.
- A future "show me the full ablauf of UPC inf, broken down by phase" feature can derive phases from `procedural_events.event_kind` ordering + the rule sequence_order. The `proceeding_types` table doesn't need to carry the phase labels.
---
## 6. Consumer impact
### 6.1 `projects.proceeding_type_id`
| Concern | Before | After mig 153 |
|---|---|---|
| Valid values | Any active proceeding_types row | Any `kind='proceeding'` active row (22 rows) |
| Enforcement | None at DB level | Optional trigger (§3.3 / §9 Q8) |
| Code-level filter in ProjectService | No filter on kind | Filter to `kind='proceeding'` when listing pickable types |
| Existing data | 6 distinct values (all in 22) | No change — all 6 are kind='proceeding' |
| SmartTimeline lane logic | Assumes primary-proceeding shape | Assumption now FK-enforceable |
**No data migration on existing projects.** The 6 currently-used proceeding types are all in the primary set.
### 6.2 `sequencing_rules.proceeding_type_id` + `spawn_proceeding_type_id`
| Concern | Before | After mig 153 |
|---|---|---|
| `proceeding_type_id` valid values | Any active row | Any active row (no enforcement change; admin curation suffices) |
| `spawn_proceeding_type_id` valid values | Any active row | Same — spawns conceptually must point at a primary, but enforcement stays in admin tooling |
| Existing data | 157 rules anchored on 18 primaries | No change — all 157 already on `kind='proceeding'` rows |
| `id=11 spawn pressure` (`upc.apl.merits`, inactive) | 4 active spawn rules point here | Pre-existing drift, out of scope (§8) |
No `sequencing_rules` table changes accompany this mig. The post-mig invariant **"every active rule's `proceeding_type_id` is a `kind='proceeding'` row"** holds without any UPDATE.
### 6.3 Fristenrechner Mode B R3 (t-paliad-322, knuth's S3+)
§3.2 R3 of the Fristenrechner overhaul says:
> Chips: every active `proceeding_type` whose jurisdiction matches R2 AND whose event roster contains at least one event with R1's kind.
After mig 153, the R3 query gains one more AND-clause:
```sql
SELECT pt.id, pt.code, pt.name, pt.name_en, pt.sort_order
FROM paliad.proceeding_types pt
WHERE pt.is_active = true
AND pt.kind = 'proceeding' -- NEW
AND pt.jurisdiction = $1 -- from R2
AND EXISTS (
SELECT 1 FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
WHERE sr.proceeding_type_id = pt.id
AND pe.event_kind = $2 -- from R1
AND sr.is_active = true
)
ORDER BY pt.sort_order, pt.code;
```
The `kind='proceeding'` filter is the only line that changes. Knuth's S3 implementation reads from this query; the chip pool shrinks from "all 35 active UPC types" to "the 14 primary UPC types that have rules" (still narrowed further by R1's event_kind via the EXISTS subquery).
No coder churn beyond adding the AND-clause. The mig 153 lands either alongside knuth's S3 work or independently (§7 sequencing decision).
### 6.4 Litigation Planner suite (t-paliad-292)
The package's catalog snapshot generator (`pkg/litigationplanner/scripts/snapshot/main.go`) currently filters:
```go
// scripts/snapshot/main.go
const proceedingTypesQuery = `
SELECT id, code, name, name_en, jurisdiction, default_color, sort_order, display_order,
trigger_event_label_de, trigger_event_label_en
FROM paliad.proceeding_types
WHERE is_active = true
AND category = 'fristenrechner'
AND jurisdiction = $1
`
```
After mig 153, this query gains the same `AND kind = 'proceeding'` line. The UPC snapshot shrinks from "potentially 35 rows" to a clean primary-only set. Today's snapshot probably already includes the phase/side-action/meta rows (since `is_active=true` is true for all of them) — depending on whether a snapshot has been regenerated since the 161-188 rows landed, the embedded JSON may be carrying decorative rows that the youpc.org catalog never resolves to rules. Mig 153 + a snapshot regen cleans this up.
The package's `Catalog.Proceeding(ctx, code, hint)` interface stays unchanged. A youpc-side call asking for `code='upc.cfi.interim'` previously returned the row + zero rules (technically valid but useless); after mig 153 the snapshot doesn't include it and the call returns `ErrUnknownProceedingType`. That's the correct shape — youpc users never had a reason to ask for a phase row.
The scenarios design (`paliad.scenarios.spec.proceedings[].code`) gains an integrity check at write time: the validator already asserts every code resolves to an active proceeding; now it additionally asserts `kind='proceeding'`. A user trying to compose a scenario with `code='upc.cfi.interim'` gets a clear error. (The validator is paliad-side, not library-side — see Litigation Planner doc §5 "Validatable at write time".)
### 6.5 Admin /admin/procedural-events list (recently shipped, t-paliad-321)
The proceeding-type column in the admin list (m/paliad#144 follow-up, just landed) renders one of the 46 active codes per row. Post-mig 153, the admin filter dropdown can:
- Default to showing only `kind='proceeding'` rows (clean primary view).
- Offer a "show all kinds" toggle for admins triaging the non-primary rows.
This is presentation-only — the underlying admin queries don't need to change immediately. The kind column is a forward-compat hook.
### 6.6 Knowledge-platform pages (Gerichtsverzeichnis, Patentglossar)
Untouched. None of those pages query `proceeding_types` directly.
### 6.7 Fristen export / paliad data export (t-paliad-279)
Untouched. The exporter dumps `proceeding_types` as a whole (no kind-filter); after mig 153 it dumps the same rows with the new kind column. Forward-compat by default.
---
## 7. Migration sequencing decision vs m/paliad#146
m/paliad#146 (Fristenrechner overhaul, t-paliad-322 / 323) is on the S1-S6 train under knuth. m's directive at task brief time: **knuth pauses at the S1+S2 seam waiting for this taxonomy decision**.
Three options were on the table:
(a) **Pause #146 until taxonomy clean** — knuth blocked, this design lands first, then knuth resumes S3+.
(b) **Land #146 against current shape, migrate later** — knuth ships S3-S6 against the current 46-row table, taxonomy mig follows.
(c) **Land taxonomy in parallel, knuth re-targets if needed** — both run, knuth's S3 picks up the new filter when mig 153 is ready.
**Recommendation: (c) parallel-land** with the following caveats:
- The taxonomy mig is **additive** (ADD COLUMN with safe DEFAULT, no DROP, no data move beyond UPDATEs that touch unreferenced rows). Knuth's S3 implementation can be written with or without the `kind='proceeding'` filter — adding the filter is a one-line patch the moment mig 153 lands.
- The R3 chip-pool query in knuth's S3 PR should be **future-proofed by also adding the `kind='proceeding'` filter behind a feature flag or an env-time SQL constant**, defaulting to "no filter" pre-mig and "filter" post-mig. (Or simpler: knuth writes the filter unconditionally; the migration lands first; ordering is mechanical.)
- The mig 153 PR should land **before** knuth's S3 PR ships to main, so the filter is never false-positive (chipping phase rows users can't actually pick). Both PRs can be drafted in parallel; the squeeze happens at merge time.
- Sequence on main: mig 153 → knuth S3 (with filter) → knuth S4-S6.
Option (c) keeps knuth productive (S3 work can start immediately after this design ratifies; doesn't have to wait for the mig to merge) and avoids the option (a) idle cost.
Option (b) was rejected because it leaves the Mode B R3 wizard chipping 35 UPC rows on initial release — exactly the bug m flagged in m/paliad#147 ("half of the 46 active proceeding_types are not primary proceedings"). The user would see phase rows in R3 day one of the Fristenrechner overhaul shipping; we'd be shipping the bug.
Option (a) was rejected as the safest but slowest path. The taxonomy mig is trivial enough (one ALTER + four UPDATE statements + optional trigger) that parallel-running has no real risk.
§9 Q10 gives m the chance to pick differently.
---
## 8. Out of scope (flagged for separate work)
- **`upc.apl.*` data drift.** 30 rows in `paliad.event_category_concepts` reference the inactive `upc.apl.merits` / `upc.apl.order` / `upc.apl.cost` codes (the pre-`upc.apl.unified` triplet). 4 active sequencing_rules reference `spawn_proceeding_type_id=11` (the inactive `upc.apl.merits` row). This is a pre-existing inconsistency from the appeal unification mig — needs its own follow-up ticket. Not blocking this design; can be cleaned up in a separate migration that retargets concepts + spawn FKs to `upc.apl.unified` (id=160).
- **Renaming or relabelling primary proceedings.** Out per m/paliad#147 acceptance — editorial work, not structural.
- **Adding new proceeding types beyond the existing corpus.** Out per m/paliad#147 acceptance.
- **The Fristenrechner UI overhaul itself (m/paliad#146).** Separate track; this design only tells knuth's S3 what set to chip.
- **The scenarios design (m/paliad#124).** Already ratified in `docs/design-litigation-planner-2026-05-26.md` §5; this design only refines the spec validator's "every code resolves to a primary" check.
- **DROPing the non-primary rows physically.** Reversible deactivation via `kind=...` + optional `is_active=false` is enough; physical deletion adds irreversibility risk for no functional gain.
- **Migration of `event_category_concepts.proceeding_type_code` to a real FK.** It's text today, joined softly; converting to FK is a separate hardening task.
---
## 9. Open questions for m (10 decision questions)
Sent via `AskUserQuestion` in 3 batches per inventor SKILL contract (4+3+3). m's picks land in §10 below after the round-trip.
| # | Topic | Recommended pick |
|---|---|---|
| Q1 | Model choice | Model 1 (kind discriminator) |
| Q2 | Phases — linear sub-phases of every CFI, or separately-elected? | Implicit: phases live in `procedural_events.event_kind`, not as proceeding_types |
| Q3.a | Side-actions — triggered by parent event, or initiated out-of-band? | Mixed; today's data has no rules, future rules anchor on the parent primary with `condition_expr` |
| Q3.b | `upc.pl.cfi` (Schutzschrift) — primary or side-action? | Primary (own RoP filing pathway) |
| Q4 | Collapse `de.inf.lg`/`olg`/`bgh` into one `de.inf` with instance_level qualifier? | No — keep discrete |
| Q5 | Collapse `de.null.bpatg`/`bgh` into one `de.null` with instance_level qualifier? | No — keep discrete |
| Q6 | Should DE follow the `upc.apl.unified` pattern? | No (= keep discrete, locks Q4+Q5) |
| Q7 | `upc.ccr.cfi` — proceeding row with routing (status quo), or `with_ccr` flag on `upc.inf.cfi`? | Keep as proceeding (status quo per t-paliad-204 S1) |
| Q8 | Enforce `projects.proceeding_type_id``kind='proceeding'` at the DB level? | Yes, via trigger (§3.3) |
| Q9 | Set `is_active=false` on the 28 non-primary rows after mig 153? | Yes (cleanest admin UX) |
| Q10 | Sequencing vs m/paliad#146 — pause / parallel / re-target? | (c) parallel-land — mig first, then knuth S3 with filter |
Q11 in the issue body ("how many rules need new condition_expr disambiguation?") is **empirically answered, no decision needed**: 0 rules need new condition_expr — every active rule is already correctly anchored to a primary. Surfaced in §4 + §6.2.
---
## 10. m's decisions (2026-05-27)
All 11 questions answered via `AskUserQuestion` on 2026-05-27 09:52 (3 batches of 4+4+3). 10 of 11 picks = recommendation; Q9 diverged at the chip-picker but m's follow-up instruction ("I follow your recommendation") flips Q9 to the recommendation as well. Q2 carries a precise carve-out captured verbatim below.
- **Q1 (Model): Model 1 — kind discriminator.** [= recommendation] One column + CHECK constraint + UPDATE statements. **Locks §1, §2, §3.1, §3.2.**
- **Q2 (Phases): Generally option 1 (implicit via `procedural_events.event_kind`), with carve-outs.** [≈ option 1 with carve-out] m's verbatim call:
> Generally 1, but I agree with costs which are not only a phase but also "standalone" side proceedings. But default decision application is not.
Concretely:
- `upc.cfi.interim` (173) → `kind='phase'`
- `upc.cfi.oral` (174) → `kind='phase'`
- `upc.cfi.decision` (175) → `kind='phase'`
- `upc.default.cfi` (185) → `kind='phase'` (m: "default decision application is not [a standalone side proceeding]")
- **`upc.costs.cfi` (176) → `kind='proceeding'`** (m: "costs are not only a phase but also standalone side proceedings"). The Separate Kostenentscheidung can be filed as its own application under R.151 RoP independently of the parent decision; m's read is that the standalone-application character outweighs the phase-of-CFI character.
Net: 4 phase rows (not 5 as in the strawman), 23 primary-proceeding rows (not 22). **Updates §0.4 Group B count, §0.5 totals row, §1 categorisation, §3.2 UPDATE statement IDs (drop 176 from the phase UPDATE).**
- **Q3.a (Side-actions): kind='side_action', rules anchor on parent primary.** [= recommendation] All 10 §0.4 Group C rows get `kind='side_action'`. When corpus arrives, rules attach to the parent primary with a `condition_expr` flag. **Locks §1.1, §3.2 side-action UPDATE.**
- **Q3.b (Schutzschrift): kind='proceeding'.** [= recommendation] `upc.pl.cfi` (188) stays in the primary set on the strength of its own RoP filing pathway. **Locks §0.3 unloaded-primary list.**
- **Q4 (DE inf collapse): Keep discrete.** [= recommendation] `de.inf.lg/olg/bgh` stay as 3 separate primaries. No collapse, no instance_level qualifier introduction. **Locks §0.2 + §1 DE-side categorisation.**
- **Q5 (DE null collapse): Keep discrete.** [= recommendation] `de.null.bpatg/bgh` stay separate. Symmetric with Q4. **Locks §0.2 + §1 DE-side categorisation.**
- **Q6 (DE follow upc.apl pattern): No — keep DE discrete.** [= recommendation] Locks Q4+Q5. The `upc.apl.unified` consolidation was about same-court appeal variants; DE appeals are different-court-instance appeals — different problem. **No code-rename work falls out of this design.**
- **Q7 (CCR shape): Keep status quo.** [= recommendation] `upc.ccr.cfi` stays as `kind='proceeding'` with the existing routing-to-`upc.inf.cfi` from t-paliad-204 §0.3 S1. **Locks §1.1.**
- **Q8 (DB trigger): Trigger on `projects` only.** [= recommendation] BEFORE INSERT/UPDATE trigger on `paliad.projects` enforces `proceeding_type_id → kind='proceeding'`. No trigger on `sequencing_rules` (admin tooling already gates). **Locks §3.3 — keep the `projects` trigger DDL, drop the optional `sequencing_rules` variant.**
- **Q9 (Deactivate non-primaries): Yes — deactivate.** [m's chip-pick was "keep active"; flipped to recommendation per m's "I follow your recommendation" instruction] All `kind IN ('phase', 'side_action', 'meta')` rows get `is_active=false` in mig 153. The admin `/admin/proceeding-types` list shows only the 23 active primaries. Rows stay in the table with their `kind` tag so future tooling that wants to surface them can flip `is_active` back on. **Updates §3.2 — uncomment the optional `UPDATE … SET is_active=false` block.**
- **Q10 (Sequencing vs #146): Parallel-land.** [= recommendation] Mig 153 + knuth's S3 PR drafted in parallel; mig merges first; knuth's S3 includes the `kind='proceeding'` filter in R3's chip query from day one. No idle cost; no bug shipped. **Locks §7.**
### 10.1 What changed from the strawman as a result
Two material edits flow from m's picks:
1. **§0.4 Group B (Phases) drops `upc.costs.cfi` (id 176)** — moved into the primary set. Phase count: 5 → 4. Primary count: 22 → 23. §0.2 picks up id 176 as an unloaded primary (zero rules today; future corpus will attach).
2. **§3.2 migration includes the `is_active=false` UPDATE** (was optional in the strawman, now mandatory):
```sql
UPDATE paliad.proceeding_types
SET is_active = false
WHERE kind IN ('phase', 'side_action', 'meta');
```
This is what the post-mig 153 cleanup looks like: 23 active rows (all `kind='proceeding'`), 23 inactive rows (4 phase + 10 side_action + 9 meta + the pre-existing 3 inactive appeal-triplet + 1 archived bucket = 27 inactive total, but 23 of those are the freshly-deactivated taxonomy rows).
These edits don't change the §7 sequencing decision or the §6 consumer-impact analysis. They tighten the mig file and shift one row's classification.
### 10.2 Final categorisation (post-decisions)
| `kind` | Count | Codes |
|---|---:|---|
| `proceeding` | **23** | upc.inf.cfi, upc.rev.cfi, upc.pi.cfi, upc.dmgs.cfi, upc.disc.cfi, upc.ccr.cfi, upc.apl.unified, upc.dni.cfi, upc.epo.review, upc.bsv.cfi, upc.pl.cfi, **upc.costs.cfi** (m's Q2 carve-out), de.inf.lg, de.inf.olg, de.inf.bgh, de.null.bpatg, de.null.bgh, epa.opp.opd, epa.opp.boa, epa.grant.exa, dpma.opp.dpma, dpma.appeal.bpatg, dpma.appeal.bgh |
| `phase` | **4** | upc.cfi.interim, upc.cfi.oral, upc.cfi.decision, upc.default.cfi |
| `side_action` | **10** | upc.evidence.cfi, upc.experiments.cfi, upc.security.cfi, upc.intervention.rop, upc.parties.change, upc.optout.cfi, upc.inspection.cfi, upc.freezing.cfi, upc.withdrawal.rop, upc.rehearing.coa |
| `meta` | **9** | upc.case.mgmt, upc.general.rop, upc.service.rop, upc.language.rop, upc.representation.rop, upc.fees.court, upc.legalaid.cfi, upc.special.cfi, upc.reestablishment.rop |
| **Total** | **46** | ✓ |
Post-mig 153: 23 active (all `kind='proceeding'`), 23 deactivated (the phase/side_action/meta set).
---
## 11. Synthesis links
- mBrian topic: `topic-fristenrechner` — file this design as a `[synthesis]` node, link `related_to` the proceeding-code-taxonomy doc (2026-05-18) and the Fristenrechner overhaul (2026-05-26), `triggered_by` t-paliad-324.
- Related design docs: `docs/design-proceeding-code-taxonomy-2026-05-18.md` (the code-shape doc), `docs/design-fristenrechner-overhaul-2026-05-26.md` (knuth's parent design), `docs/design-litigation-planner-2026-05-26.md` §5 (scenarios spec validator).
- Related migrations: 095 (fristen gap-fill, spawn FK invariant), 096 (proceeding code rename), 152 (sequencing_rule dedupe + admin column).

View File

@@ -102,9 +102,9 @@ export function renderAdminRulesList(): string {
<thead>
<tr>
<th data-i18n="admin.procedural_events.col.code">Submission Code</th>
<th data-i18n="admin.procedural_events.col.proceeding">Verfahren</th>
<th data-i18n="admin.rules.col.legal_citation">Rechtsgrundlage</th>
<th data-i18n="admin.rules.col.name">Name</th>
<th data-i18n="admin.rules.col.proceeding">Verfahrenstyp</th>
<th data-i18n="admin.rules.col.priority">Priorit&auml;t</th>
<th data-i18n="admin.rules.col.lifecycle">Lifecycle</th>
<th data-i18n="admin.rules.col.modified">Zuletzt ge&auml;ndert</th>

View File

@@ -11,6 +11,13 @@ import { initSidebar } from "./sidebar";
interface Rule {
id: string;
proceeding_type_id?: number | null;
// proceeding_type_code is the joined paliad.proceeding_types.code
// for proceeding_type_id, populated server-side by the
// /admin/api/procedural-events LIST handler (t-paliad-321). Lets the
// table show the 3-segment proceeding code (e.g. "upc.inf.cfi") at
// a glance without depending on the FILTER-dropdown's limited
// proceeding list. NULL on event-rooted rules.
proceeding_type_code?: string | null;
// submission_code is the proceeding-prefixed identifier of this rule
// within its proceeding (e.g. `upc.inf.cfi.soc`), distinct from
// rule_code (the legal citation, e.g. `RoP.013.1`).
@@ -138,6 +145,19 @@ function proceedingLabel(id: number | null | undefined): string {
return `${pt.code} · ${name}`;
}
// proceedingCodeCell renders the LIST table's Proceeding column. Uses
// the server-side joined proceeding_type_code when available
// (t-paliad-321), falling back to the dropdown-lookup proceedingLabel
// for older API responses or for rules whose proceeding_type_id
// resolves but proceeding_type_code didn't (defence-in-depth). NULL
// proceeding_type_id renders as the em-dash placeholder used
// elsewhere in the admin table.
function proceedingCodeCell(r: Rule): string {
if (r.proceeding_type_code) return r.proceeding_type_code;
if (r.proceeding_type_id == null) return "—";
return proceedingLabel(r.proceeding_type_id);
}
function buildFilterURL(): string {
const qs = new URLSearchParams();
if (activeProceeding) qs.set("proceeding_type_id", activeProceeding);
@@ -233,9 +253,9 @@ function renderRulesTable() {
tbody.innerHTML = rules.map((r) => `
<tr data-row-id="${esc(r.id)}" class="admin-rules-row">
<td class="admin-rules-col-code"><code>${esc(r.submission_code || "")}</code></td>
<td class="admin-rules-col-proceeding"><code>${esc(proceedingCodeCell(r))}</code></td>
<td class="admin-rules-col-legal"><code>${esc(r.rule_code || "")}</code></td>
<td>${esc(name(r))}</td>
<td>${esc(proceedingLabel(r.proceeding_type_id ?? null))}</td>
<td><span class="admin-rules-priority admin-rules-priority-${esc(r.priority)}">${esc(priorityLabel(r.priority))}</span></td>
<td><span class="${lifecycleClass(r.lifecycle_state)}">${esc(lifecycleLabel(r.lifecycle_state))}</span></td>
<td class="admin-rules-col-modified">${esc(fmtDateTime(r.updated_at))}</td>

View File

@@ -0,0 +1,507 @@
// Fristenrechner overhaul Mode A — "Direkt suchen" (design §3.1).
//
// Power-user surface: a filter strip (Forum / Verfahren / Was passierte /
// Partei) over a free-text search box over a result list of
// procedural_events. Clicking a row locks the event as the trigger and
// transitions to the shared result view (S2). Inbox channel chip lives
// as a secondary "Erweitert" toggle per design §3.3 — picking CMS / beA
// / Postal auto-sets the Forum chip.
//
// Section-split visual hierarchy per m §11.Q3: filter strip on top
// ("Filter (eingrenzen)") with the four chip groups, search box and
// result list below — clicking a result row IS the qualifier action.
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
import { getLang, t, tDyn } from "./i18n";
import { mountResultView } from "./fristenrechner-result";
// Wire shape from GET /api/tools/fristenrechner/search?kind=events.
// Mirrors services.EventSearchResponse server-side.
interface EventSearchHit {
id: string;
code: string;
name_de: string;
name_en: string;
event_kind?: string;
description?: string;
primary_party?: string;
proceeding_type: {
id: number;
code: string;
name_de: string;
name_en: string;
jurisdiction?: string;
};
anchor_rule_id: string;
follow_up_count: number;
concept_id?: string;
score: number;
}
interface EventSearchResponse {
query: string;
events: EventSearchHit[];
total: number;
}
interface ProceedingChip {
code: string;
name: string;
nameEN: string;
group: string;
}
// Module-local state — single Mode A surface at a time.
interface ModeAState {
jurisdiction: string; // "" = Alle
proc: string; // proceeding_types.code, "" = Alle
eventKind: string; // "" = Alle
party: string; // "" = Alle (Mode A's filter semantics, §11.Q8)
q: string; // free-text query
inbox: string; // CMS / bea / postal / "" — secondary, design §3.3
inboxOpen: boolean;
}
const state: ModeAState = {
jurisdiction: "",
proc: "",
eventKind: "",
party: "",
q: "",
inbox: "",
inboxOpen: false,
};
// Debounce token for search input — avoid hammering the server on
// every keystroke.
let searchSeq = 0;
let searchTimer: ReturnType<typeof setTimeout> | null = null;
// Chip data — static. Forum and event-kind are closed-set per design;
// party is closed-set with "Beide" option (Mode A is filter mode,
// §11.Q8). Inbox secondary chip set per §3.3.
const FORUMS = ["UPC", "DE", "EPA", "DPMA"] as const;
const EVENT_KINDS = ["filing", "hearing", "decision", "order"] as const;
const PARTIES = ["claimant", "defendant", "both"] as const;
// Forum auto-derivation from inbox chip per §3.3: CMS → UPC, beA → DE,
// Postal → no narrowing (postal arrives at every jurisdiction).
const INBOX_TO_FORUM: Record<string, string> = {
cms: "UPC",
bea: "DE",
postal: "",
};
// MODE_A_HOST_ID is the DOM id of the container Mode A renders into.
// The mode shell (fristenrechner-result.mountModeShell) creates this
// element under the overhaul root and hands it to Mode A; Mode A
// otherwise has no opinion about its placement on the page.
const MODE_A_HOST_ID = "fristen-overhaul-mode-host";
export function isModeASurfaceMounted(): boolean {
return !!document.getElementById("fristen-mode-a-root");
}
// mountModeA renders the Mode A surface into the overhaul root. Reads
// initial state from URL params so deep links restore the previous
// filter / search state.
export async function mountModeA(): Promise<void> {
const root = document.getElementById(MODE_A_HOST_ID);
if (!root) return;
// Hydrate state from URL.
const params = new URLSearchParams(window.location.search);
state.jurisdiction = (params.get("forum") || "").toUpperCase();
state.proc = params.get("pt") || "";
state.eventKind = params.get("kind") || "";
state.party = params.get("party") || "";
state.q = params.get("q") || "";
renderShell();
await loadProceedingChips();
void runSearch();
}
// renderShell builds the Mode A markup. Idempotent re-call from the
// boot path; row-level rewrites use renderResults / renderFilterStrip
// for finer-grained updates.
function renderShell(): void {
const root = document.getElementById("fristen-overhaul-root");
if (!root) return;
root.innerHTML = `
<div id="fristen-mode-a-root" class="fristen-mode-a-root">
<section class="fristen-mode-a-filters" aria-label="${escAttr(t("deadlines.overhaul.modea.filters.label"))}">
<header class="fristen-mode-a-filters-header">
<span class="fristen-mode-a-filters-title">${escHtml(t("deadlines.overhaul.modea.filters.heading"))}</span>
</header>
<div class="fristen-mode-a-chip-row" data-axis="forum">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.forum"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-forum"></div>
</div>
<div class="fristen-mode-a-chip-row" data-axis="proc">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.proc"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-proc"></div>
</div>
<div class="fristen-mode-a-chip-row" data-axis="kind">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.kind"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-kind"></div>
</div>
<div class="fristen-mode-a-chip-row" data-axis="party">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.party"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-party"></div>
</div>
<details class="fristen-mode-a-inbox" ${state.inboxOpen ? "open" : ""}>
<summary class="fristen-mode-a-inbox-summary">${escHtml(t("deadlines.overhaul.modea.inbox.summary"))}</summary>
<div class="fristen-mode-a-chip-row" data-axis="inbox">
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.inbox"))}</span>
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-inbox"></div>
</div>
</details>
</section>
<section class="fristen-mode-a-search" aria-label="${escAttr(t("deadlines.overhaul.modea.search.label"))}">
<div class="fristen-mode-a-search-input-wrap">
<svg class="fristen-mode-a-search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7"></circle>
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
</svg>
<input type="search" id="fristen-mode-a-search-input"
class="fristen-mode-a-search-input"
autocomplete="off" spellcheck="false"
data-i18n-placeholder="deadlines.overhaul.modea.search.placeholder"
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing…"
value="${escAttr(state.q)}" />
</div>
</section>
<section class="fristen-mode-a-results" aria-label="${escAttr(t("deadlines.overhaul.modea.results.label"))}">
<header class="fristen-mode-a-results-header">
<span class="fristen-mode-a-results-title">${escHtml(t("deadlines.overhaul.modea.results.heading"))}</span>
<span class="fristen-mode-a-results-count" id="fristen-mode-a-results-count"></span>
</header>
<ul class="fristen-mode-a-result-list" id="fristen-mode-a-result-list" role="listbox" aria-live="polite"></ul>
</section>
</div>
`;
renderForumChips();
renderKindChips();
renderPartyChips();
renderInboxChips();
// Proceeding chips render later, after fetch.
// Wire search input.
const input = document.getElementById("fristen-mode-a-search-input") as HTMLInputElement | null;
if (input) {
input.addEventListener("input", () => {
state.q = input.value;
scheduleSearch(180);
});
input.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") {
e.preventDefault();
scheduleSearch(0);
}
});
}
}
// Filter-strip chip renderers ----------------------------------------
function renderForumChips(): void {
const host = document.getElementById("fristen-mode-a-chips-forum");
if (!host) return;
const chips = [
chipHtml("forum", "", t("deadlines.overhaul.modea.chip.all"), state.jurisdiction === ""),
...FORUMS.map((j) => chipHtml("forum", j, j, state.jurisdiction === j)),
];
host.innerHTML = chips.join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
const v = btn.dataset.value || "";
state.jurisdiction = v;
// Forum change invalidates the proc pick if it falls outside.
state.proc = "";
syncUrl();
renderForumChips();
void loadProceedingChips();
scheduleSearch(0);
});
});
}
function renderKindChips(): void {
const host = document.getElementById("fristen-mode-a-chips-kind");
if (!host) return;
const chips = [
chipHtml("kind", "", t("deadlines.overhaul.modea.chip.all"), state.eventKind === ""),
...EVENT_KINDS.map((k) => chipHtml("kind", k, t(`deadlines.overhaul.kind.${k}` as never), state.eventKind === k, eventKindIconForChip(k))),
];
host.innerHTML = chips.join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
state.eventKind = btn.dataset.value || "";
syncUrl();
renderKindChips();
scheduleSearch(0);
});
});
}
function renderPartyChips(): void {
const host = document.getElementById("fristen-mode-a-chips-party");
if (!host) return;
const chips = [
chipHtml("party", "", t("deadlines.overhaul.modea.chip.all"), state.party === ""),
...PARTIES.map((p) => chipHtml("party", p, t(`deadlines.party.${p}` as never), state.party === p)),
];
host.innerHTML = chips.join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
state.party = btn.dataset.value || "";
syncUrl();
renderPartyChips();
scheduleSearch(0);
});
});
}
function renderInboxChips(): void {
const host = document.getElementById("fristen-mode-a-chips-inbox");
if (!host) return;
const opts = [
{ v: "", label: t("deadlines.overhaul.modea.chip.all") },
{ v: "cms", label: "CMS" },
{ v: "bea", label: "beA" },
{ v: "postal", label: t("deadlines.overhaul.modea.inbox.postal") },
];
host.innerHTML = opts.map((o) => chipHtml("inbox", o.v, o.label, state.inbox === o.v)).join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
const v = btn.dataset.value || "";
state.inbox = v;
// Auto-nudge forum from inbox per design §3.3.
const nudge = INBOX_TO_FORUM[v];
if (nudge !== undefined && nudge !== "") {
state.jurisdiction = nudge;
state.proc = "";
renderForumChips();
void loadProceedingChips();
}
renderInboxChips();
scheduleSearch(0);
});
});
}
// Proceeding chips — dynamic fetch.
let lastProcFetchKey = "";
async function loadProceedingChips(): Promise<void> {
const host = document.getElementById("fristen-mode-a-chips-proc");
if (!host) return;
const key = `j=${state.jurisdiction}`;
if (lastProcFetchKey === key) return; // cached for current jurisdiction
lastProcFetchKey = key;
host.innerHTML = `<span class="fristen-mode-a-chip-loading">${escHtml(t("deadlines.overhaul.modea.loading"))}</span>`;
const url = new URL("/api/tools/proceeding-types", window.location.origin);
url.searchParams.set("kind", "proceeding");
if (state.jurisdiction) url.searchParams.set("jurisdiction", state.jurisdiction);
let chips: ProceedingChip[] = [];
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (resp.ok) {
const data = (await resp.json()) as ProceedingChip[] | null;
chips = data || [];
}
} catch {
// Soft-fail: chip strip just hides; search still runs without
// proceeding narrowing.
}
renderProceedingChips(chips);
}
function renderProceedingChips(chips: ProceedingChip[]): void {
const host = document.getElementById("fristen-mode-a-chips-proc");
if (!host) return;
const lang = getLang();
if (chips.length === 0) {
host.innerHTML = `<span class="fristen-mode-a-chip-empty">${escHtml(t("deadlines.overhaul.modea.no_proceedings"))}</span>`;
return;
}
const rendered = [
chipHtml("proc", "", t("deadlines.overhaul.modea.chip.all"), state.proc === ""),
...chips.map((c) => {
const label = lang === "en" ? c.nameEN || c.name : c.name;
return chipHtml("proc", c.code, label, state.proc === c.code, undefined, c.code);
}),
];
host.innerHTML = rendered.join("");
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
state.proc = btn.dataset.value || "";
syncUrl();
renderProceedingChips(chips);
scheduleSearch(0);
});
});
}
// Search ------------------------------------------------------------
function scheduleSearch(delayMs: number): void {
if (searchTimer !== null) clearTimeout(searchTimer);
searchTimer = setTimeout(() => {
searchTimer = null;
void runSearch();
}, delayMs);
}
async function runSearch(): Promise<void> {
searchSeq++;
const mySeq = searchSeq;
const list = document.getElementById("fristen-mode-a-result-list");
const count = document.getElementById("fristen-mode-a-results-count");
if (!list || !count) return;
list.innerHTML = `<li class="fristen-mode-a-result-loading">${escHtml(t("deadlines.overhaul.modea.loading"))}</li>`;
count.textContent = "";
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
url.searchParams.set("kind", "events");
if (state.q) url.searchParams.set("q", state.q);
if (state.jurisdiction) url.searchParams.set("jurisdiction", state.jurisdiction);
if (state.proc) url.searchParams.set("proc", state.proc);
if (state.eventKind) url.searchParams.set("event_kind", state.eventKind);
if (state.party) url.searchParams.set("party", state.party);
let data: EventSearchResponse;
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
if (mySeq === searchSeq) {
list.innerHTML = `<li class="fristen-mode-a-result-error">${escHtml(t("deadlines.overhaul.modea.search_error"))}</li>`;
}
return;
}
data = (await resp.json()) as EventSearchResponse;
} catch {
if (mySeq === searchSeq) {
list.innerHTML = `<li class="fristen-mode-a-result-error">${escHtml(t("deadlines.overhaul.modea.search_error"))}</li>`;
}
return;
}
if (mySeq !== searchSeq) return; // stale response
renderResults(data);
}
function renderResults(data: EventSearchResponse): void {
const list = document.getElementById("fristen-mode-a-result-list");
const count = document.getElementById("fristen-mode-a-results-count");
if (!list || !count) return;
count.textContent = tDyn("deadlines.overhaul.modea.results.count").replace("{n}", String(data.total));
if (data.events.length === 0) {
list.innerHTML = `<li class="fristen-mode-a-result-empty">${escHtml(t("deadlines.overhaul.modea.no_results"))}</li>`;
return;
}
const lang = getLang();
list.innerHTML = data.events.map((e) => {
const name = lang === "en" ? e.name_en || e.name_de : e.name_de;
const pt = e.proceeding_type;
const ptName = lang === "en" ? pt.name_en || pt.name_de : pt.name_de;
const icon = eventKindIconForChip(e.event_kind);
const followUps = tDyn("deadlines.overhaul.modea.row.followups").replace("{n}", String(e.follow_up_count));
const juris = pt.jurisdiction || "";
return `
<li class="fristen-mode-a-result" data-event-code="${escAttr(e.code)}" tabindex="0" role="option">
<span class="fristen-mode-a-result-icon" aria-hidden="true">${icon}</span>
<div class="fristen-mode-a-result-body">
<div class="fristen-mode-a-result-title">${escHtml(name)}</div>
<div class="fristen-mode-a-result-meta">
<span class="fristen-mode-a-result-pt">${escHtml(pt.code)}</span>
<span class="fristen-mode-a-result-pt-name">${escHtml(ptName)}</span>
${juris ? `<span class="fristen-mode-a-result-juris">${escHtml(juris)}</span>` : ""}
<span class="fristen-mode-a-result-followups">${escHtml(followUps)}</span>
</div>
</div>
<span class="fristen-mode-a-result-cta" aria-hidden="true">&rarr;</span>
</li>
`;
}).join("");
list.querySelectorAll<HTMLLIElement>(".fristen-mode-a-result").forEach((li) => {
li.addEventListener("click", () => commitEvent(li.dataset.eventCode || ""));
li.addEventListener("keydown", (e) => {
const k = (e as KeyboardEvent).key;
if (k === "Enter" || k === " ") {
e.preventDefault();
commitEvent(li.dataset.eventCode || "");
}
});
});
}
// Commit — user picked a result; lock the event as trigger and
// transition to the §4 result view (S2).
function commitEvent(code: string): void {
if (!code) return;
// Reflect in URL before re-mounting so the result view's deep link
// is consistent.
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
url.searchParams.set("event", code);
// Preserve project / forum / kind filters so a back-navigation
// brings Mode A back with the same filters.
history.pushState(null, "", url.pathname + url.search + url.hash);
void mountResultView({
eventRef: code,
party: state.party || undefined,
});
}
// Helpers -----------------------------------------------------------
function chipHtml(axis: string, value: string, label: string, active: boolean, icon?: string, title?: string): string {
const cls = `fristen-mode-a-chip${active ? " is-active" : ""}`;
const t = title ? ` title="${escAttr(title)}"` : "";
const i = icon ? `<span class="fristen-mode-a-chip-icon" aria-hidden="true">${icon}</span>` : "";
return `<button type="button" class="${cls}" data-axis="${escAttr(axis)}" data-value="${escAttr(value)}"${t}>${i}<span class="fristen-mode-a-chip-label">${escHtml(label)}</span></button>`;
}
function eventKindIconForChip(kind?: string): string {
switch (kind) {
case "filing": return "&#128229;";
case "hearing": return "&#127963;&#65039;";
case "decision": return "&#9878;&#65039;";
case "order": return "&#128220;";
default: return "&#128269;";
}
}
// syncUrl writes the active filter set into the URL so the deep link
// restores Mode A in the same state.
function syncUrl(): void {
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
setOrClear(url, "forum", state.jurisdiction);
setOrClear(url, "pt", state.proc);
setOrClear(url, "kind", state.eventKind);
setOrClear(url, "party", state.party);
setOrClear(url, "q", state.q);
history.replaceState(null, "", url.pathname + url.search + url.hash);
}
function setOrClear(url: URL, key: string, val: string): void {
if (val) url.searchParams.set(key, val);
else url.searchParams.delete(key);
}

View File

@@ -0,0 +1,72 @@
import { describe, expect, test } from "bun:test";
import {
defaultChecked,
groupFollowUps,
type FollowUpRule,
} from "./fristenrechner-result";
// Pure helpers exercised here; the DOM-driven render path is covered
// by the live page test path (S2 is mount-on-deep-link, S3+S4 add the
// entry-mode UIs in later slices).
function mk(partial: Partial<FollowUpRule>): FollowUpRule {
return {
rule_id: "r" + Math.random().toString(36).slice(2, 8),
event_code: "evt",
title_de: "Frist",
title_en: "Deadline",
priority: "mandatory",
is_court_set: false,
is_spawn: false,
is_bilateral: false,
has_condition: false,
...partial,
};
}
describe("groupFollowUps — design §4.2 priority+condition buckets", () => {
test("groups by priority; conditional takes precedence over priority", () => {
const rows = [
mk({ priority: "mandatory" }),
mk({ priority: "recommended" }),
mk({ priority: "optional" }),
mk({ priority: "mandatory", has_condition: true }), // → conditional
mk({ priority: "optional", has_condition: true }), // → conditional
];
const g = groupFollowUps(rows);
expect(g.mandatory.length).toBe(1);
expect(g.recommended.length).toBe(1);
expect(g.optional.length).toBe(1);
expect(g.conditional.length).toBe(2);
});
test("unknown priority falls through to optional", () => {
const g = groupFollowUps([mk({ priority: "informational" })]);
expect(g.optional.length).toBe(1);
expect(g.mandatory.length).toBe(0);
});
});
describe("defaultChecked — pre-checks mandatory + recommended, not conditional/court-set", () => {
test("mandatory rules pre-checked", () => {
expect(defaultChecked(mk({ priority: "mandatory" }))).toBe(true);
});
test("recommended rules pre-checked", () => {
expect(defaultChecked(mk({ priority: "recommended" }))).toBe(true);
});
test("optional rules unchecked", () => {
expect(defaultChecked(mk({ priority: "optional" }))).toBe(false);
});
test("conditional rules unchecked", () => {
expect(defaultChecked(mk({ priority: "mandatory", has_condition: true }))).toBe(false);
});
test("court-set rules unchecked even when mandatory", () => {
expect(defaultChecked(mk({ priority: "mandatory", is_court_set: true }))).toBe(false);
});
test("spawned rules pre-checked when mandatory", () => {
expect(defaultChecked(mk({ priority: "mandatory", is_spawn: true }))).toBe(true);
});
test("spawned optional rules unchecked", () => {
expect(defaultChecked(mk({ priority: "optional", is_spawn: true }))).toBe(false);
});
});

View File

@@ -0,0 +1,672 @@
// Fristenrechner overhaul — shared result view (design §4).
//
// Given a locked trigger event + a trigger date, this module renders
// the result surface: a sticky trigger card on top, then four priority
// groups (mandatory / recommended / optional / conditional) of follow-up
// rules with computed dates, then a write-back footer that calls the
// existing POST /api/projects/{id}/deadlines/bulk.
//
// The two future entry paths (Mode A "Direkt suchen" in S3, Mode B
// wizard in S4) both land here once they've identified a trigger
// procedural_event. S2 mounts the surface under `?overhaul=1` and is
// deep-linkable on its own via `?overhaul=1&event=<code>&trigger_date=…`.
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
import { getLang, t, tDyn } from "./i18n";
// Wire shape from GET /api/tools/fristenrechner/follow-ups. Mirrors
// services.FollowUpsResponse server-side.
export interface FollowUpRule {
rule_id: string;
event_code: string;
title_de: string;
title_en: string;
priority: string;
primary_party?: string;
duration_value?: number;
duration_unit?: string;
timing?: string;
due_date?: string;
original_due_date?: string;
was_adjusted?: boolean;
is_court_set: boolean;
is_spawn: boolean;
is_bilateral: boolean;
has_condition: boolean;
rule_code?: string;
legal_source?: string;
legal_source_display?: string;
legal_source_url?: string;
notes_de?: string;
notes_en?: string;
spawn_label?: string;
spawn_proceeding_code?: string;
concept_id?: string;
}
export interface FollowUpsResponse {
trigger: {
id: string;
code: string;
name_de: string;
name_en: string;
event_kind?: string;
proceeding_type: {
id: number;
code: string;
name_de: string;
name_en: string;
jurisdiction?: string;
};
anchor_rule_id: string;
};
trigger_date: string;
party?: string;
follow_ups: FollowUpRule[];
}
// Per-rule UI state — checkbox, optional date override.
interface RuleSelection {
checked: boolean;
override?: string;
}
// Module-local state. Single result view at a time; the surface
// re-renders in place when the user changes the trigger date or
// re-locks a different event.
let currentResponse: FollowUpsResponse | null = null;
const selections = new Map<string, RuleSelection>();
let currentProjectId: string | null = null;
// Public API ----------------------------------------------------------
// isOverhaulMode reports whether the page is in overhaul mode.
// After Slice S5 (t-paliad-323), overhaul is the default; the legacy
// wizard / row-stack / cascade is only reachable via `?legacy=1` for
// a two-week deprecation window. The `?overhaul=1` deep links from
// S2-S4 still work — they're now redundant with the default but kept
// alive so bookmarks don't 302 / lose state.
export function isOverhaulMode(): boolean {
return new URLSearchParams(window.location.search).get("legacy") !== "1";
}
// resolveProjectId reads the active Akte from the URL query string.
// Returns null when in kontextfrei mode (no project picked).
function resolveProjectId(): string | null {
const p = new URLSearchParams(window.location.search).get("project");
return p && p.length > 0 ? p : null;
}
// MODE_TAB_KEYS — the two entry-mode tabs landed by S3 + S4. S2's deep
// link path bypasses these (jumps straight to the result view via
// ?event=); the tabs appear when no event is locked yet.
export type ModeTab = "search" | "wizard";
// mountModeShell renders the mode-tab pair under the page header and
// hosts whichever mode panel is currently active. Called from the boot
// path when no `?event=` is present. S3 wires Mode A; S4 will add
// Mode B and the actual tab switching.
export async function mountModeShell(activeTab: ModeTab): Promise<void> {
const root = document.getElementById("fristen-overhaul-root");
if (!root) return;
root.hidden = false;
// Defer to the per-mode module to render into the root. The tab
// strip itself is a small header above the mode panel — for S3 we
// render the shell + Mode A in one shot.
// S4 will replace this with a real tab switcher.
const tabs = `
<nav class="fristen-mode-tabs" role="tablist" aria-label="${escAttr(t("deadlines.overhaul.modes.label"))}">
<button type="button" class="fristen-mode-tab${activeTab === "search" ? " is-active" : ""}" role="tab"
aria-selected="${activeTab === "search"}" data-tab="search">
<span class="fristen-mode-tab-icon" aria-hidden="true">&#9889;</span>
<span class="fristen-mode-tab-label">${escHtml(t("deadlines.overhaul.modes.search"))}</span>
</button>
<button type="button" class="fristen-mode-tab${activeTab === "wizard" ? " is-active" : ""}" role="tab"
aria-selected="${activeTab === "wizard"}" data-tab="wizard">
<span class="fristen-mode-tab-icon" aria-hidden="true">&#129517;</span>
<span class="fristen-mode-tab-label">${escHtml(t("deadlines.overhaul.modes.wizard"))}</span>
</button>
</nav>
<div id="fristen-overhaul-mode-host"></div>
`;
root.innerHTML = tabs;
// Wire tab switching. S3 only has Mode A wired; Mode B is a
// placeholder until S4.
root.querySelectorAll<HTMLButtonElement>(".fristen-mode-tab").forEach((btn) => {
btn.addEventListener("click", () => {
const tab = (btn.dataset.tab || "search") as ModeTab;
void mountModeShell(tab);
});
});
// Mount the active mode panel into the host. S3 only routes "search";
// "wizard" renders a placeholder until S4 lands.
const host = document.getElementById("fristen-overhaul-mode-host");
if (!host) return;
if (activeTab === "search") {
// Lazy import to keep the bundle layered and avoid a circular ref
// between fristenrechner-result.ts ↔ fristenrechner-mode-a.ts.
const mod = await import("./fristenrechner-mode-a");
await mod.mountModeA();
} else {
const mod = await import("./fristenrechner-wizard");
await mod.mountWizard();
}
}
// MountOptions configures the surface entry. Both entry-mode paths
// (Mode A in S3, Mode B in S4) call mount() with the event reference
// that the user committed.
export interface MountOptions {
// eventRef is the procedural_event code OR its uuid OR the anchor
// sequencing_rule id. Resolved server-side; the wire returns the
// canonical code so the URL bookmark is stable.
eventRef: string;
// triggerDate is YYYY-MM-DD. Defaults to today when omitted.
triggerDate?: string;
// party is "claimant" | "defendant"; mode A may pass "both" or
// "court". When omitted, follow-ups are returned without party
// narrowing.
party?: string;
// courtId selects the holiday calendar for the per-rule date
// adjustment. Optional.
courtId?: string;
}
// mountResultView fetches /follow-ups and renders the result surface
// into the host container. Re-callable: replaces previous state.
export async function mountResultView(opts: MountOptions): Promise<void> {
const root = document.getElementById("fristen-overhaul-root");
if (!root) return;
root.hidden = false;
const triggerDate = opts.triggerDate || todayIso();
currentProjectId = resolveProjectId();
// Show a quick "loading…" placeholder so the user sees something
// immediately, even on a cold fetch.
root.innerHTML = `<div class="fristen-overhaul-loading">${escHtml(t("deadlines.overhaul.loading"))}</div>`;
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
url.searchParams.set("event", opts.eventRef);
url.searchParams.set("trigger_date", triggerDate);
if (opts.party) url.searchParams.set("party", opts.party);
if (opts.courtId) url.searchParams.set("court_id", opts.courtId);
let data: FollowUpsResponse;
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
const body = await resp.json().catch(() => ({}) as { error?: string });
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(body.error || t("deadlines.overhaul.load_error"))}</div>`;
return;
}
data = (await resp.json()) as FollowUpsResponse;
} catch (err) {
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(t("deadlines.overhaul.load_error"))}</div>`;
return;
}
currentResponse = data;
selections.clear();
for (const r of data.follow_ups) {
selections.set(r.rule_id, { checked: defaultChecked(r) });
}
renderSurface();
// Reflect the canonical event code + trigger date in the URL so the
// deep-link survives a reload.
syncUrlState(data.trigger.code, data.trigger_date);
}
// Render --------------------------------------------------------------
function renderSurface(): void {
const root = document.getElementById("fristen-overhaul-root");
if (!root || !currentResponse) return;
const lang = getLang();
const trig = currentResponse.trigger;
const triggerName = lang === "en" ? trig.name_en || trig.name_de : trig.name_de;
const ptName = lang === "en" ? trig.proceeding_type.name_en || trig.proceeding_type.name_de : trig.proceeding_type.name_de;
const juris = trig.proceeding_type.jurisdiction || "";
const kindIcon = eventKindIcon(trig.event_kind);
const triggerCard = `
<section class="fristen-overhaul-trigger" aria-label="${escAttr(t("deadlines.overhaul.trigger.label"))}">
<header class="fristen-overhaul-trigger-header">
<span class="fristen-overhaul-kind-icon" aria-hidden="true">${kindIcon}</span>
<h2 class="fristen-overhaul-trigger-title">${escHtml(triggerName)}</h2>
</header>
<div class="fristen-overhaul-trigger-meta">
<span class="fristen-overhaul-trigger-code">${escHtml(trig.code)}</span>
<span class="fristen-overhaul-trigger-pt">${escHtml(ptName)}</span>
${juris ? `<span class="fristen-overhaul-trigger-juris">${escHtml(juris)}</span>` : ""}
</div>
<div class="fristen-overhaul-trigger-date">
<label for="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-label">
${escHtml(t("deadlines.overhaul.trigger.date"))}
</label>
<input type="date" id="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-input"
value="${escAttr(currentResponse.trigger_date)}" />
</div>
</section>
`;
const groups = groupFollowUps(currentResponse.follow_ups);
const groupHtml = renderGroups(groups, lang);
const nudge = currentProjectId
? ""
: `<div class="fristen-overhaul-nudge">${escHtml(t("deadlines.overhaul.nudge.no_project"))}</div>`;
const footer = currentProjectId
? renderFooter()
: "";
root.innerHTML = `
${triggerCard}
${nudge}
<section class="fristen-overhaul-groups" aria-label="${escAttr(t("deadlines.overhaul.followups.label"))}">
${groupHtml}
</section>
${footer}
<div class="fristen-overhaul-msg" id="fristen-overhaul-msg" role="status" aria-live="polite"></div>
`;
wireSurfaceEvents();
}
export interface GroupedFollowUps {
mandatory: FollowUpRule[];
recommended: FollowUpRule[];
optional: FollowUpRule[];
conditional: FollowUpRule[];
}
// groupFollowUps splits the wire list into the four visible groups per
// design §4.2. Conditional (sr.condition_expr IS NOT NULL) takes
// precedence over the priority bucket so a "nur wenn CCR" mandatory
// rule renders under Conditional with the gating language visible.
export function groupFollowUps(rows: FollowUpRule[]): GroupedFollowUps {
const out: GroupedFollowUps = { mandatory: [], recommended: [], optional: [], conditional: [] };
for (const r of rows) {
if (r.has_condition) {
out.conditional.push(r);
continue;
}
switch (r.priority) {
case "mandatory":
out.mandatory.push(r);
break;
case "recommended":
out.recommended.push(r);
break;
case "optional":
out.optional.push(r);
break;
default:
// unknown / informational — fold into optional so the row is at
// least visible. Future Phase 2 'informational' tier gets a
// dedicated bucket once seeded.
out.optional.push(r);
}
}
return out;
}
function renderGroups(groups: GroupedFollowUps, lang: "de" | "en"): string {
const blocks: string[] = [];
if (groups.mandatory.length > 0) {
blocks.push(renderGroup("mandatory", t("deadlines.overhaul.group.mandatory"), groups.mandatory, lang));
}
if (groups.recommended.length > 0) {
blocks.push(renderGroup("recommended", t("deadlines.overhaul.group.recommended"), groups.recommended, lang));
}
if (groups.optional.length > 0) {
blocks.push(renderGroup("optional", t("deadlines.overhaul.group.optional"), groups.optional, lang));
}
if (groups.conditional.length > 0) {
blocks.push(renderGroup("conditional", t("deadlines.overhaul.group.conditional"), groups.conditional, lang));
}
if (blocks.length === 0) {
return `<div class="fristen-overhaul-empty">${escHtml(t("deadlines.overhaul.empty"))}</div>`;
}
return blocks.join("");
}
function renderGroup(slug: string, label: string, rows: FollowUpRule[], lang: "de" | "en"): string {
const items = rows.map((r) => renderRule(r, lang)).join("");
return `
<div class="fristen-overhaul-group fristen-overhaul-group--${escAttr(slug)}">
<h3 class="fristen-overhaul-group-title">${escHtml(label)}</h3>
<ul class="fristen-overhaul-rule-list">
${items}
</ul>
</div>
`;
}
function renderRule(r: FollowUpRule, lang: "de" | "en"): string {
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
const sel = selections.get(r.rule_id);
const checked = sel ? sel.checked : defaultChecked(r);
const dateOverride = sel?.override;
const computedDate = r.due_date || "";
const effectiveDate = dateOverride || computedDate;
const disabled = r.is_court_set || (r.is_spawn && !r.due_date);
// Duration phrase: "3 Monate" / "14 Tage" — language-aware.
const durationPhrase = formatDurationPhrase(r, lang);
const dateCell = r.is_court_set
? `<span class="fristen-overhaul-rule-court-set">${escHtml(t("deadlines.court.set"))}</span>`
: effectiveDate
? `<span class="fristen-overhaul-rule-date" data-rule-id="${escAttr(r.rule_id)}">${escHtml(formatDateForLang(effectiveDate, lang))}</span>`
: `<span class="fristen-overhaul-rule-date fristen-overhaul-rule-date--unknown">&mdash;</span>`;
const partyBadge = r.primary_party
? `<span class="fristen-overhaul-rule-party fristen-overhaul-rule-party--${escAttr(r.primary_party)}">${escHtml(t(`deadlines.party.${r.primary_party}` as never))}</span>`
: "";
const sourceBadge = r.legal_source_display
? r.legal_source_url
? `<a class="fristen-overhaul-rule-source" href="${escAttr(r.legal_source_url)}" target="_blank" rel="noreferrer">${escHtml(r.legal_source_display)}</a>`
: `<span class="fristen-overhaul-rule-source">${escHtml(r.legal_source_display)}</span>`
: r.rule_code
? `<span class="fristen-overhaul-rule-source">${escHtml(r.rule_code)}</span>`
: "";
const spawnBadge = r.is_spawn
? `<span class="fristen-overhaul-rule-spawn" title="${escAttr(t("deadlines.overhaul.spawn.tooltip"))}">${escHtml(t("deadlines.overhaul.spawn.badge"))}${r.spawn_proceeding_code ? ` · ${escHtml(r.spawn_proceeding_code)}` : ""}</span>`
: "";
const condBadge = r.has_condition
? `<span class="fristen-overhaul-rule-cond">${escHtml(t("deadlines.overhaul.condition.badge"))}</span>`
: "";
const notesHtml = notes
? `<details class="fristen-overhaul-rule-notes"><summary>${escHtml(t("deadlines.overhaul.notes.summary"))}</summary><p>${escHtml(notes)}</p></details>`
: "";
const editBtn = r.is_court_set || r.is_spawn || !computedDate
? ""
: `<button type="button" class="fristen-overhaul-rule-edit-date" data-rule-id="${escAttr(r.rule_id)}" title="${escAttr(t("deadlines.overhaul.edit_date.title"))}" aria-label="${escAttr(t("deadlines.overhaul.edit_date.title"))}">${escHtml(t("deadlines.overhaul.edit_date.label"))}</button>`;
return `
<li class="fristen-overhaul-rule${disabled ? " is-disabled" : ""}" data-rule-id="${escAttr(r.rule_id)}">
<label class="fristen-overhaul-rule-check">
<input type="checkbox" data-rule-id="${escAttr(r.rule_id)}"
${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
<span class="visually-hidden">${escHtml(t("deadlines.overhaul.select_rule"))}</span>
</label>
<div class="fristen-overhaul-rule-body">
<div class="fristen-overhaul-rule-title-row">
<span class="fristen-overhaul-rule-title">${escHtml(title)}</span>
${spawnBadge}
${condBadge}
</div>
<div class="fristen-overhaul-rule-meta-row">
${durationPhrase ? `<span class="fristen-overhaul-rule-duration">${escHtml(durationPhrase)}</span>` : ""}
${partyBadge}
${sourceBadge}
</div>
${notesHtml}
</div>
<div class="fristen-overhaul-rule-date-cell">
${dateCell}
${editBtn}
</div>
</li>
`;
}
function renderFooter(): string {
const selectedCount = countSelected();
return `
<footer class="fristen-overhaul-footer" id="fristen-overhaul-footer">
<span class="fristen-overhaul-footer-count" id="fristen-overhaul-footer-count">
${escHtml(tDyn("deadlines.overhaul.footer.count").replace("{n}", String(selectedCount)))}
</span>
<button type="button" class="fristen-overhaul-footer-cta btn-primary btn-cta-lime"
id="fristen-overhaul-write-back"
${selectedCount === 0 ? "disabled" : ""}>
${escHtml(t("deadlines.overhaul.footer.cta"))}
</button>
</footer>
`;
}
// Event wiring --------------------------------------------------------
function wireSurfaceEvents(): void {
// Trigger-date change → re-fetch with new date.
const dateInput = document.getElementById("fristen-overhaul-trigger-date") as HTMLInputElement | null;
if (dateInput && currentResponse) {
dateInput.addEventListener("change", () => {
if (!currentResponse) return;
const newDate = dateInput.value;
if (!newDate) return;
void mountResultView({
eventRef: currentResponse.trigger.code,
triggerDate: newDate,
party: currentResponse.party,
});
});
}
// Checkbox toggles → update selections + footer count.
const root = document.getElementById("fristen-overhaul-root");
if (root) {
root.querySelectorAll<HTMLInputElement>(".fristen-overhaul-rule-check input[type=checkbox]").forEach((cb) => {
cb.addEventListener("change", () => {
const id = cb.dataset.ruleId || "";
const sel = selections.get(id) ?? { checked: cb.checked };
sel.checked = cb.checked;
selections.set(id, sel);
refreshFooterCount();
});
});
// Per-rule date override.
root.querySelectorAll<HTMLButtonElement>(".fristen-overhaul-rule-edit-date").forEach((btn) => {
btn.addEventListener("click", () => editRuleDate(btn));
});
}
// Write-back CTA.
const cta = document.getElementById("fristen-overhaul-write-back");
if (cta) cta.addEventListener("click", () => void submitWriteBack());
}
function editRuleDate(btn: HTMLButtonElement): void {
const ruleId = btn.dataset.ruleId || "";
const rule = currentResponse?.follow_ups.find((r) => r.rule_id === ruleId);
if (!rule) return;
const sel = selections.get(ruleId) ?? { checked: defaultChecked(rule) };
const current = sel.override || rule.due_date || todayIso();
const dateCell = btn.parentElement;
if (!dateCell) return;
const dateSpan = dateCell.querySelector<HTMLSpanElement>(".fristen-overhaul-rule-date");
if (!dateSpan) return;
const input = document.createElement("input");
input.type = "date";
input.value = current;
input.className = "fristen-overhaul-rule-date-input";
dateSpan.replaceWith(input);
btn.disabled = true;
input.focus();
const commit = () => {
const newDate = input.value;
if (newDate && newDate !== current) {
sel.override = newDate;
selections.set(ruleId, sel);
}
renderSurface();
};
input.addEventListener("blur", commit, { once: true });
input.addEventListener("keydown", (e) => {
if ((e as KeyboardEvent).key === "Enter") {
e.preventDefault();
input.blur();
} else if ((e as KeyboardEvent).key === "Escape") {
renderSurface();
}
});
}
function refreshFooterCount(): void {
const countEl = document.getElementById("fristen-overhaul-footer-count");
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
const n = countSelected();
if (countEl) {
countEl.textContent = tDyn("deadlines.overhaul.footer.count").replace("{n}", String(n));
}
if (cta) cta.disabled = n === 0;
}
function countSelected(): number {
let n = 0;
if (!currentResponse) return 0;
for (const r of currentResponse.follow_ups) {
if (r.is_court_set) continue;
const sel = selections.get(r.rule_id);
if (sel?.checked) n++;
}
return n;
}
// Write-back ----------------------------------------------------------
async function submitWriteBack(): Promise<void> {
if (!currentResponse) return;
if (!currentProjectId) return;
const msg = document.getElementById("fristen-overhaul-msg");
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
const lang = getLang();
const deadlines: Array<Record<string, unknown>> = [];
for (const r of currentResponse.follow_ups) {
const sel = selections.get(r.rule_id);
if (!sel?.checked) continue;
if (r.is_court_set) continue;
const dueDate = sel.override || r.due_date;
if (!dueDate) continue;
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
deadlines.push({
title,
rule_code: r.rule_code || undefined,
due_date: dueDate,
original_due_date: r.original_due_date || r.due_date || undefined,
source: "fristenrechner",
rule_id: r.rule_id,
notes: notes || undefined,
audit_reason: auditReason(),
});
}
if (deadlines.length === 0 || !msg || !cta) return;
cta.disabled = true;
msg.textContent = "";
msg.className = "fristen-overhaul-msg";
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(currentProjectId)}/deadlines/bulk`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deadlines }),
});
if (!resp.ok) {
const body = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = body.error || t("deadlines.save.error");
msg.className = "fristen-overhaul-msg form-msg-error";
cta.disabled = false;
return;
}
msg.innerHTML = `${escHtml(t("deadlines.save.success"))} <a href="/deadlines?project_id=${encodeURIComponent(currentProjectId)}">${escHtml(t("deadlines.save.success.link"))}</a>`;
msg.className = "fristen-overhaul-msg form-msg-ok";
setTimeout(() => {
if (cta) cta.disabled = false;
}, 1500);
} catch {
msg.textContent = t("deadlines.save.error");
msg.className = "fristen-overhaul-msg form-msg-error";
cta.disabled = false;
}
}
// audit reason per design §11.Q12: "Aus Fristenrechner — Trigger: {name} ({date})".
function auditReason(): string {
if (!currentResponse) return "";
const name = currentResponse.trigger.name_de;
const date = currentResponse.trigger_date;
return `Aus Fristenrechner — Trigger: ${name} (${date})`;
}
// Helpers -------------------------------------------------------------
export function defaultChecked(r: FollowUpRule): boolean {
if (r.is_court_set) return false;
if (r.is_spawn) return r.priority === "mandatory";
if (r.has_condition) return false;
return r.priority === "mandatory" || r.priority === "recommended";
}
function formatDurationPhrase(r: FollowUpRule, lang: "de" | "en"): string {
if (!r.duration_value || !r.duration_unit) return "";
const unitDE: Record<string, string> = {
days: "Tage",
months: "Monate",
weeks: "Wochen",
years: "Jahre",
};
const unitEN: Record<string, string> = {
days: "days",
months: "months",
weeks: "weeks",
years: "years",
};
const u = (lang === "en" ? unitEN : unitDE)[r.duration_unit] || r.duration_unit;
return `${r.duration_value} ${u}`;
}
function formatDateForLang(iso: string, lang: "de" | "en"): string {
// YYYY-MM-DD → DE: DD.MM.YYYY / EN: DD MMM YYYY (short).
if (!iso || iso.length < 10) return iso;
const [y, m, d] = iso.split("-");
if (!y || !m || !d) return iso;
if (lang === "en") {
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
const idx = parseInt(m, 10) - 1;
const mn = idx >= 0 && idx < months.length ? months[idx] : m;
return `${parseInt(d, 10)} ${mn} ${y}`;
}
return `${d}.${m}.${y}`;
}
function eventKindIcon(kind?: string): string {
switch (kind) {
case "filing": return "&#128229;"; // inbox/letter
case "hearing": return "&#127963;&#65039;"; // courthouse
case "decision": return "&#9878;&#65039;"; // scales
case "order": return "&#128220;"; // page
default: return "&#128197;"; // calendar
}
}
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
function syncUrlState(eventCode: string, triggerDate: string): void {
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
url.searchParams.set("event", eventCode);
url.searchParams.set("trigger_date", triggerDate);
history.replaceState(null, "", url.pathname + url.search + url.hash);
}

View File

@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test";
import { followUpsDifferByParty } from "./fristenrechner-wizard";
describe("followUpsDifferByParty — R5 trigger condition (S4, design §3.2)", () => {
test("true when both claimant and defendant rules present", () => {
expect(followUpsDifferByParty([
{ primary_party: "claimant" },
{ primary_party: "defendant" },
])).toBe(true);
});
test("false when all claimant", () => {
expect(followUpsDifferByParty([
{ primary_party: "claimant" },
{ primary_party: "claimant" },
])).toBe(false);
});
test("false when all defendant", () => {
expect(followUpsDifferByParty([
{ primary_party: "defendant" },
])).toBe(false);
});
test("false when only 'both' rules", () => {
// "Both" rules are bilateral procedural moves (Vertraulichkeits-
// Erwiderung); they don't gate R5 because either party can be
// looking at them.
expect(followUpsDifferByParty([
{ primary_party: "both" },
{ primary_party: "both" },
])).toBe(false);
});
test("false when only court rules", () => {
expect(followUpsDifferByParty([
{ primary_party: "court" },
])).toBe(false);
});
test("true when mixed with both / court alongside the asymmetric pair", () => {
expect(followUpsDifferByParty([
{ primary_party: "both" },
{ primary_party: "claimant" },
{ primary_party: "court" },
{ primary_party: "defendant" },
])).toBe(true);
});
test("false on empty list", () => {
expect(followUpsDifferByParty([])).toBe(false);
});
});

View File

@@ -0,0 +1,711 @@
// Fristenrechner overhaul Mode B — "Geführt" / wizard (design §3.2).
//
// 3-5 question row stack that lands the user on one procedural_event
// (the trigger), then transitions to the shared §4 result view.
//
// R1 Was ist passiert? (event_kind) always asked
// R2 Vor welchem Gericht? (jurisdiction) skip if R1 narrows
// R3 In welchem Verfahren? (proceeding_type) auto-skip when 1 option
// R4 Welches Schriftstück? (procedural_event — land) always asked
// R5 Welche Seite vertreten Sie? (party) only when follow-ups differ
//
// Row badges per §11.Q3: R1+R2 = "Filter", R3+R4+R5 = "Qualifier".
// R5 has NO "Beide" option per §11.Q8 (Mode B is the file-mode where
// perspective is a qualifier).
// Pre-fill + collapse rows from project (project.proceeding_type →
// R3 + R2 derived; project.our_side → R5). Preserve compatible
// downstream picks on back-navigation (§11.Q10).
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
import { getLang, t, tDyn } from "./i18n";
import { mountResultView } from "./fristenrechner-result";
// Wire shapes — duplicates the parts of fristenrechner-mode-a.ts we
// need; kept local so the wizard doesn't depend on Mode A.
interface EventSearchHit {
id: string;
code: string;
name_de: string;
name_en: string;
event_kind?: string;
proceeding_type: {
id: number;
code: string;
name_de: string;
name_en: string;
jurisdiction?: string;
};
follow_up_count: number;
}
interface EventSearchResponse {
events: EventSearchHit[];
total: number;
}
interface ProceedingChip {
code: string;
name: string;
nameEN: string;
group: string;
}
interface ProjectSummary {
id: string;
proceeding_type_id?: number | null;
our_side?: string | null;
}
type Forum = "UPC" | "DE" | "EPA" | "DPMA";
type EventKindRow = "filing" | "hearing" | "decision" | "order" | "missed";
type WizardParty = "claimant" | "defendant";
// WIZARD_HOST_ID is the DOM id the wizard renders into. Mounted by
// fristenrechner-result.mountModeShell which creates the host element
// under the overhaul root.
const WIZARD_HOST_ID = "fristen-overhaul-mode-host";
// FORUMS + EVENT_KINDS — closed sets. Keep parallel to Mode A's lists
// so re-grouping happens in one place.
const FORUMS: Forum[] = ["UPC", "DE", "EPA", "DPMA"];
const EVENT_KINDS: EventKindRow[] = ["filing", "hearing", "decision", "order", "missed"];
// Single wizard state. Module-local; one wizard at a time.
interface WizardState {
// Picks. "" = not answered. R5 only set when the question is asked.
r1: EventKindRow | "";
r2: Forum | "";
r3: string; // proceeding_types.code
r4: string; // procedural_events.code
r5: WizardParty | "";
// Pre-fill provenance — when a pick came from the project context,
// the row renders with an "aus Akte" tag so the user notices.
r2FromProject: boolean;
r3FromProject: boolean;
r5FromProject: boolean;
// Implicit fills — R2 auto-derived from R1 when R1 narrows to one
// forum (e.g. "missed" → no narrowing, "filing" → cross-forum, but
// if downstream R3 lookup returns a single forum we can mark R2 as
// implicit).
r2Implicit: boolean;
r3Implicit: boolean;
}
const state: WizardState = {
r1: "", r2: "", r3: "", r4: "", r5: "",
r2FromProject: false, r3FromProject: false, r5FromProject: false,
r2Implicit: false, r3Implicit: false,
};
// Loaded from the project (if any).
let projectSummary: ProjectSummary | null = null;
// Proceeding chip cache key: jurisdiction × event_kind.
let lastProcCacheKey = "";
let cachedProcChips: ProceedingChip[] = [];
// Event chip cache: keyed on R3 code + R1 event_kind.
let lastEventCacheKey = "";
let cachedEventChips: EventSearchHit[] = [];
// Public API ---------------------------------------------------------
export async function mountWizard(): Promise<void> {
const host = document.getElementById(WIZARD_HOST_ID);
if (!host) return;
// Hydrate from URL state (mode=wizard&forum=UPC&pt=upc.inf.cfi&…).
const params = new URLSearchParams(window.location.search);
state.r1 = (params.get("kind") as EventKindRow) || "";
state.r2 = (params.get("forum") as Forum) || "";
state.r3 = params.get("pt") || "";
state.r4 = params.get("event") || "";
state.r5 = (params.get("party") as WizardParty) || "";
// Project prefills.
const projectId = params.get("project");
if (projectId) {
projectSummary = await fetchProject(projectId);
await applyProjectPrefills();
} else {
projectSummary = null;
}
renderShell();
void renderRows();
}
// applyProjectPrefills derives R2 + R3 + R5 from the project when they
// haven't been set explicitly. Project picks take precedence over
// unspecified state, but a user-supplied URL pick wins over the
// project default.
async function applyProjectPrefills(): Promise<void> {
if (!projectSummary) return;
// Map our_side → R5.
if (!state.r5) {
const side = projectSummary.our_side;
if (side === "claimant" || side === "applicant" || side === "appellant") {
state.r5 = "claimant";
state.r5FromProject = true;
} else if (side === "defendant" || side === "respondent") {
state.r5 = "defendant";
state.r5FromProject = true;
}
}
// Map proceeding_type_id → R3 + infer R2 jurisdiction.
if (projectSummary.proceeding_type_id && !state.r3) {
const pt = await fetchProceedingByID(projectSummary.proceeding_type_id);
if (pt) {
state.r3 = pt.code;
state.r3FromProject = true;
if (pt.group && !state.r2) {
state.r2 = pt.group as Forum;
state.r2FromProject = true;
}
}
}
}
// Render -------------------------------------------------------------
function renderShell(): void {
const host = document.getElementById(WIZARD_HOST_ID);
if (!host) return;
host.innerHTML = `
<div class="fristen-wizard-root">
<header class="fristen-wizard-header">
<h2 class="fristen-wizard-title">${escHtml(t("deadlines.overhaul.wizard.heading"))}</h2>
<p class="fristen-wizard-hint">${escHtml(t("deadlines.overhaul.wizard.hint"))}</p>
</header>
<div class="fristen-wizard-rows" id="fristen-wizard-rows" aria-live="polite"></div>
</div>
`;
}
async function renderRows(): Promise<void> {
const host = document.getElementById("fristen-wizard-rows");
if (!host) return;
// Resolve dynamic row prerequisites BEFORE building markup so chip
// sets are populated.
if (state.r1 && state.r2) {
await ensureProceedingChips(state.r2, state.r1);
// Auto-skip R3 when the narrowed pool has exactly one option.
if (!state.r3 && cachedProcChips.length === 1) {
state.r3 = cachedProcChips[0].code;
state.r3Implicit = true;
}
}
if (state.r1 && state.r3) {
await ensureEventChips(state.r3, state.r1);
}
const rows: string[] = [];
rows.push(rowR1());
if (shouldShowR2()) rows.push(rowR2());
if (shouldShowR3()) rows.push(rowR3());
if (shouldShowR4()) rows.push(rowR4());
if (state.r4 && shouldShowR5Sync()) rows.push(rowR5Loading());
host.innerHTML = rows.join("");
wireRowEvents();
// R5 conditional check — fires after R4 picked. Inspects /follow-ups
// to see whether they actually differ by party. If yes, show R5. If
// no, or R5 already set, transition straight to result view.
if (state.r4) {
void maybeAdvanceFromR4();
}
}
// Should-show predicates --------------------------------------------
function shouldShowR2(): boolean {
// Skip R2 only when R1 narrows to a single forum — which today
// never happens for the closed event_kind set (every kind exists in
// multiple jurisdictions). Always show R2 until we have empirical
// evidence otherwise.
return state.r1 !== "" && state.r1 !== "missed";
}
function shouldShowR3(): boolean {
if (state.r1 === "" || state.r2 === "") return false;
if (state.r3 && state.r3Implicit) return true; // visible compact
return true;
}
function shouldShowR4(): boolean {
return state.r3 !== "" && state.r1 !== "";
}
// shouldShowR5Sync renders the placeholder row immediately; the actual
// asked-or-not decision happens after the async follow-ups probe in
// maybeAdvanceFromR4.
function shouldShowR5Sync(): boolean {
return state.r4 !== "";
}
// Row builders ------------------------------------------------------
function rowR1(): string {
const chips = EVENT_KINDS.map((k) => {
const label = t(`deadlines.overhaul.kind.${k}` as never);
const icon = eventKindIcon(k);
return chipHtml("r1", k, label, state.r1 === k, icon);
}).join("");
return rowShell({
n: 1,
badge: "filter",
label: t("deadlines.overhaul.wizard.r1.label"),
active: !state.r1,
answeredText: state.r1 ? t(`deadlines.overhaul.kind.${state.r1}` as never) : "",
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
function rowR2(): string {
const chips = FORUMS.map((f) => chipHtml("r2", f, f, state.r2 === f)).join("");
return rowShell({
n: 2,
badge: "filter",
label: t("deadlines.overhaul.wizard.r2.label"),
active: !state.r2,
fromProject: state.r2FromProject,
answeredText: state.r2 || "",
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
function rowR3(): string {
if (cachedProcChips.length === 0) {
return rowShell({
n: 3, badge: "qualifier",
label: t("deadlines.overhaul.wizard.r3.label"),
active: true,
body: `<div class="fristen-wizard-empty">${escHtml(t("deadlines.overhaul.wizard.r3.empty"))}</div>`,
});
}
const lang = getLang();
const chips = cachedProcChips.map((p) => {
const label = lang === "en" ? p.nameEN || p.name : p.name;
return chipHtml("r3", p.code, label, state.r3 === p.code, undefined, p.code);
}).join("");
let answered = "";
if (state.r3) {
const hit = cachedProcChips.find((p) => p.code === state.r3);
if (hit) answered = lang === "en" ? hit.nameEN || hit.name : hit.name;
}
return rowShell({
n: 3,
badge: "qualifier",
label: t("deadlines.overhaul.wizard.r3.label"),
active: !state.r3,
fromProject: state.r3FromProject,
implicit: state.r3Implicit,
answeredText: answered,
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
function rowR4(): string {
if (cachedEventChips.length === 0) {
return rowShell({
n: 4, badge: "qualifier",
label: t("deadlines.overhaul.wizard.r4.label"),
active: true,
body: `<div class="fristen-wizard-empty">${escHtml(t("deadlines.overhaul.wizard.r4.empty"))}</div>`,
});
}
const lang = getLang();
const chips = cachedEventChips.map((e) => {
const label = lang === "en" ? e.name_en || e.name_de : e.name_de;
return chipHtml("r4", e.code, label, state.r4 === e.code, eventKindIcon(e.event_kind as EventKindRow));
}).join("");
let answered = "";
if (state.r4) {
const hit = cachedEventChips.find((e) => e.code === state.r4);
if (hit) answered = lang === "en" ? hit.name_en || hit.name_de : hit.name_de;
}
return rowShell({
n: 4,
badge: "qualifier",
label: t("deadlines.overhaul.wizard.r4.label"),
active: !state.r4,
answeredText: answered,
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
function rowR5Loading(): string {
// Placeholder while we probe whether R5 is needed. The async
// follow-ups probe replaces this with rowR5 chips or skips
// straight to the result view.
return rowShell({
n: 5, badge: "qualifier",
label: t("deadlines.overhaul.wizard.r5.label"),
active: !state.r5,
fromProject: state.r5FromProject,
answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "",
body: `<div class="fristen-wizard-probe">${escHtml(t("deadlines.overhaul.wizard.r5.probing"))}</div>`,
});
}
function rowR5Chips(): string {
const chips = (["claimant", "defendant"] as const).map((p) =>
chipHtml("r5", p, t(`deadlines.party.${p}` as never), state.r5 === p)).join("");
return rowShell({
n: 5, badge: "qualifier",
label: t("deadlines.overhaul.wizard.r5.label"),
active: !state.r5,
fromProject: state.r5FromProject,
answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "",
body: `<div class="fristen-wizard-chips">${chips}</div>`,
});
}
interface RowShellOpts {
n: number;
badge: "filter" | "qualifier";
label: string;
active: boolean;
body: string;
answeredText?: string;
fromProject?: boolean;
implicit?: boolean;
}
function rowShell(o: RowShellOpts): string {
const cls = `fristen-wizard-row fristen-wizard-row--${o.badge}` +
(o.active ? " is-active" : " is-answered") +
(o.fromProject ? " is-from-project" : "") +
(o.implicit ? " is-implicit" : "");
const badgeText = o.badge === "filter"
? t("deadlines.overhaul.wizard.badge.filter")
: t("deadlines.overhaul.wizard.badge.qualifier");
const annotations: string[] = [];
if (o.fromProject) annotations.push(`<span class="fristen-wizard-row-anno">${escHtml(t("deadlines.overhaul.wizard.anno.from_project"))}</span>`);
if (o.implicit) annotations.push(`<span class="fristen-wizard-row-anno">${escHtml(t("deadlines.overhaul.wizard.anno.implicit"))}</span>`);
const answered = o.answeredText
? `<span class="fristen-wizard-row-answer">${escHtml(o.answeredText)}</span>`
: "";
const edit = !o.active
? `<button type="button" class="fristen-wizard-row-edit" data-row="${o.n}">${escHtml(t("deadlines.overhaul.wizard.edit"))}</button>`
: "";
return `
<section class="${cls}" data-row="${o.n}">
<header class="fristen-wizard-row-header">
<span class="fristen-wizard-row-n">${o.n}</span>
<span class="fristen-wizard-row-badge fristen-wizard-row-badge--${o.badge}">${escHtml(badgeText)}</span>
<span class="fristen-wizard-row-label">${escHtml(o.label)}</span>
${annotations.join("")}
${answered}
${edit}
</header>
${o.active ? `<div class="fristen-wizard-row-body">${o.body}</div>` : ""}
</section>
`;
}
// Event wiring ------------------------------------------------------
function wireRowEvents(): void {
document.querySelectorAll<HTMLButtonElement>(".fristen-wizard-row .fristen-mode-a-chip").forEach((btn) => {
btn.addEventListener("click", () => {
const axis = btn.dataset.axis || "";
const value = btn.dataset.value || "";
handleChip(axis, value);
});
});
document.querySelectorAll<HTMLButtonElement>(".fristen-wizard-row-edit").forEach((btn) => {
btn.addEventListener("click", () => {
const n = parseInt(btn.dataset.row || "0", 10);
handleEdit(n);
});
});
}
function handleChip(axis: string, value: string): void {
switch (axis) {
case "r1": {
if (state.r1 === value) return;
state.r1 = value as EventKindRow;
// R1 change resets R3/R4 (event-kind narrows the pools).
state.r3 = "";
state.r3Implicit = false;
state.r4 = "";
state.r5 = state.r5FromProject ? state.r5 : "";
cachedEventChips = [];
lastEventCacheKey = "";
cachedProcChips = [];
lastProcCacheKey = "";
break;
}
case "r2": {
if (state.r2 === value) return;
state.r2 = value as Forum;
state.r2FromProject = false;
state.r2Implicit = false;
// R2 change may invalidate R3 → reset.
state.r3 = "";
state.r3FromProject = false;
state.r3Implicit = false;
state.r4 = "";
cachedProcChips = [];
lastProcCacheKey = "";
cachedEventChips = [];
lastEventCacheKey = "";
break;
}
case "r3": {
if (state.r3 === value) return;
state.r3 = value;
state.r3FromProject = false;
state.r3Implicit = false;
state.r4 = "";
cachedEventChips = [];
lastEventCacheKey = "";
break;
}
case "r4": {
if (state.r4 === value) return;
state.r4 = value;
break;
}
case "r5": {
if (state.r5 === value) return;
state.r5 = value as WizardParty;
state.r5FromProject = false;
break;
}
}
syncUrl();
void renderRows();
}
function handleEdit(n: number): void {
switch (n) {
case 1:
state.r1 = ""; state.r2 = ""; state.r3 = ""; state.r4 = ""; state.r5 = state.r5FromProject ? state.r5 : "";
cachedProcChips = []; lastProcCacheKey = "";
cachedEventChips = []; lastEventCacheKey = "";
break;
case 2:
state.r2 = ""; state.r2FromProject = false; state.r2Implicit = false;
state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false;
state.r4 = "";
cachedProcChips = []; lastProcCacheKey = "";
cachedEventChips = []; lastEventCacheKey = "";
break;
case 3:
state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false;
state.r4 = "";
cachedEventChips = []; lastEventCacheKey = "";
break;
case 4:
state.r4 = "";
state.r5 = state.r5FromProject ? state.r5 : "";
break;
case 5:
state.r5 = ""; state.r5FromProject = false;
break;
}
syncUrl();
void renderRows();
}
// maybeAdvanceFromR4 fetches /follow-ups for the picked event to
// decide whether R5 is needed. If R5 is already set OR the
// follow-ups don't differ by party, transition straight to the
// result view. Else swap the R5 loading row for the chip picker.
async function maybeAdvanceFromR4(): Promise<void> {
if (!state.r4) return;
if (state.r5) {
// R5 already answered (project prefill or explicit pick) → go.
void launchResult();
return;
}
// Probe follow-ups.
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
url.searchParams.set("event", state.r4);
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
// Soft-fail → swap to R5 chips so the user can decide manually.
swapR5(rowR5Chips());
return;
}
const data = (await resp.json()) as { follow_ups: Array<{ primary_party?: string }> };
const differs = followUpsDifferByParty(data.follow_ups);
if (!differs) {
void launchResult();
return;
}
swapR5(rowR5Chips());
} catch {
swapR5(rowR5Chips());
}
}
function swapR5(html: string): void {
const host = document.getElementById("fristen-wizard-rows");
if (!host) return;
const r5 = host.querySelector('.fristen-wizard-row[data-row="5"]');
if (!r5) {
host.insertAdjacentHTML("beforeend", html);
} else {
r5.outerHTML = html;
}
wireRowEvents();
}
function launchResult(): void {
// Hand off to the §4 result view. The URL already carries the
// picks via syncUrl(); add event= so the boot path treats this
// as a deep-link.
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
url.searchParams.set("event", state.r4);
if (state.r5) url.searchParams.set("party", state.r5);
else url.searchParams.delete("party");
history.pushState(null, "", url.pathname + url.search + url.hash);
void mountResultView({ eventRef: state.r4, party: state.r5 || undefined });
}
export function followUpsDifferByParty(rows: Array<{ primary_party?: string }>): boolean {
let hasClaimant = false, hasDefendant = false;
for (const r of rows) {
if (r.primary_party === "claimant") hasClaimant = true;
else if (r.primary_party === "defendant") hasDefendant = true;
if (hasClaimant && hasDefendant) return true;
}
return false;
}
// Fetches -----------------------------------------------------------
async function fetchProject(id: string): Promise<ProjectSummary | null> {
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`, { headers: { Accept: "application/json" } });
if (!resp.ok) return null;
return (await resp.json()) as ProjectSummary;
} catch {
return null;
}
}
async function fetchProceedingByID(id: number): Promise<ProceedingChip | null> {
// The proceeding-types endpoint returns codes, names, jurisdictions
// but doesn't carry the id (the wire shape FristenrechnerType is
// code-keyed). Walk the unfiltered list and pick by sort-order
// proximity / sort-fallback: we need the row whose id matches; since
// the wire doesn't expose id, fetch the projects detail to get the
// code directly. Cheap workaround: rely on /api/projects/{id}'s
// proceeding_type_id being matched against the proceeding-types list
// by jurisdiction round-trip is not possible without id. Instead
// expose the proceeding-types-by-id mapping via a follow-up endpoint
// later. For now hit the unfiltered list and assume the project's
// pick is in the active set.
//
// Pragmatic fallback: query the full list and return the only entry
// whose pseudo-id-via-sort-order matches. The lookup is unreliable
// until the wire shape includes id; for the project-prefill case the
// user can always re-pick R3 / R2 if the prefill misfires.
try {
const resp = await fetch(`/api/tools/proceeding-types`, { headers: { Accept: "application/json" } });
if (!resp.ok) return null;
const list = (await resp.json()) as ProceedingChip[] | null;
if (!list || list.length === 0) return null;
// Without id in the wire we cannot match by id. Skip the prefill
// silently — R3 stays unanswered and the user picks manually.
// (S5/follow-up can extend the wire shape to include id.)
void id;
return null;
} catch {
return null;
}
}
async function ensureProceedingChips(forum: Forum, kind: EventKindRow): Promise<void> {
const key = `${forum}\x00${kind}`;
if (lastProcCacheKey === key) return;
lastProcCacheKey = key;
const url = new URL("/api/tools/proceeding-types", window.location.origin);
url.searchParams.set("kind", "proceeding");
url.searchParams.set("jurisdiction", forum);
if (kind !== "missed") url.searchParams.set("event_kind", kind);
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
cachedProcChips = [];
return;
}
const data = (await resp.json()) as ProceedingChip[] | null;
cachedProcChips = data || [];
} catch {
cachedProcChips = [];
}
}
async function ensureEventChips(procCode: string, kind: EventKindRow): Promise<void> {
const key = `${procCode}\x00${kind}`;
if (lastEventCacheKey === key) return;
lastEventCacheKey = key;
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
url.searchParams.set("kind", "events");
url.searchParams.set("proc", procCode);
if (kind !== "missed") url.searchParams.set("event_kind", kind);
url.searchParams.set("limit", "100");
try {
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
if (!resp.ok) {
cachedEventChips = [];
return;
}
const data = (await resp.json()) as EventSearchResponse;
cachedEventChips = data.events || [];
} catch {
cachedEventChips = [];
}
}
// Helpers -----------------------------------------------------------
function chipHtml(axis: string, value: string, label: string, active: boolean, icon?: string, title?: string): string {
const cls = `fristen-mode-a-chip${active ? " is-active" : ""}`;
const tt = title ? ` title="${escAttr(title)}"` : "";
const i = icon ? `<span class="fristen-mode-a-chip-icon" aria-hidden="true">${icon}</span>` : "";
return `<button type="button" class="${cls}" data-axis="${escAttr(axis)}" data-value="${escAttr(value)}"${tt}>${i}<span class="fristen-mode-a-chip-label">${escHtml(label)}</span></button>`;
}
function eventKindIcon(kind?: EventKindRow): string {
switch (kind) {
case "filing": return "&#128229;";
case "hearing": return "&#127963;&#65039;";
case "decision": return "&#9878;&#65039;";
case "order": return "&#128220;";
case "missed": return "&#9202;";
default: return "&#128197;";
}
}
function syncUrl(): void {
const url = new URL(window.location.href);
url.searchParams.set("overhaul", "1");
url.searchParams.set("mode", "wizard");
setOrClear(url, "kind", state.r1);
setOrClear(url, "forum", state.r2);
setOrClear(url, "pt", state.r3);
// event=… is set only on launchResult; the wizard URL carries the
// R4 candidate via r4= so back/forward navigates within the wizard.
setOrClear(url, "r4", state.r4);
setOrClear(url, "party", state.r5);
history.replaceState(null, "", url.pathname + url.search + url.hash);
}
function setOrClear(url: URL, key: string, val: string): void {
if (val) url.searchParams.set(key, val);
else url.searchParams.delete(key);
}

View File

@@ -30,6 +30,7 @@ import {
type EventChoice,
type ChoiceKind,
} from "./views/event-card-choices";
import { isOverhaulMode, mountModeShell, mountResultView } from "./fristenrechner-result";
let lastResponse: DeadlineResponse | null = null;
@@ -113,6 +114,50 @@ onLangChange(() => {
let selectedType = "";
// t-paliad-323 Slice S2 — Fristenrechner overhaul boot. Hides the
// legacy step / pathway shells and mounts the result view. S3+S4 will
// hook entry-mode UIs into this; S2 is deep-link only.
function bootOverhaulMode(): void {
// Hide every legacy section so only the overhaul root is visible.
// The page wrapper (`<main>`, `<section class="tool-page">`, the
// tool-header) stays so the sidebar + title carry through.
const hideIds = [
"fristen-step1",
"fristen-step1-summary",
"fristen-step2",
"fristen-pathway-b",
"fristen-step3a",
"fristen-pathway-a",
];
for (const id of hideIds) {
const el = document.getElementById(id);
if (el) {
el.hidden = true;
el.style.display = "none";
}
}
// S2 deep-link contract: ?overhaul=1&event=<code>&trigger_date=…
// When event is missing, leave the surface empty — S3/S4 will mount
// entry-mode UIs onto this surface in later slices.
const params = new URLSearchParams(window.location.search);
const eventRef = params.get("event") || "";
const triggerDate = params.get("trigger_date") || undefined;
const party = params.get("party") || undefined;
const courtId = params.get("court_id") || undefined;
if (!eventRef) {
// No trigger event locked yet → show the mode tab pair + active
// mode panel (S3 = Mode A direct search; S4 will add Mode B
// wizard). The mode param in the URL picks which tab opens
// first; default is search (S3).
const mode = (params.get("mode") || "search") === "wizard" ? "wizard" : "search";
void mountModeShell(mode);
return;
}
void mountResultView({ eventRef, triggerDate, party, courtId });
}
function showStep(n: number) {
for (let i = 1; i <= 3; i++) {
const el = document.getElementById(`step-${i}`);
@@ -656,6 +701,21 @@ document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
// t-paliad-323 Slice S5 — Fristenrechner overhaul is the default
// boot path. The legacy three-step wizard / Pathway A+B shells are
// reachable only via `?legacy=1` for a two-week deprecation window
// after this commit lands. Deep-linkable via
// /tools/fristenrechner?event=<code>&trigger_date=…&project=…
// for the result view, or via
// /tools/fristenrechner → Mode A (search) tab
// /tools/fristenrechner?mode=wizard → Mode B wizard tab
// The `?overhaul=1` deep links from S2-S4 still resolve (the
// detector treats absence of `?legacy=1` as "overhaul").
if (isOverhaulMode()) {
bootOverhaulMode();
return;
}
// Proceeding type selection
document.querySelectorAll<HTMLButtonElement>(".proceeding-btn").forEach((btn) => {
btn.addEventListener("click", () => selectProceeding(btn));
@@ -2595,33 +2655,16 @@ interface EventCategoryNode {
let eventCategoryTree: EventCategoryNode[] | null = null;
let eventCategoryFetchInflight: Promise<EventCategoryNode[]> | null = null;
// Top-level cascade roots that represent forward-looking workflows ("I
// want to file X, what deadlines does my action trigger?") rather than
// the backward-looking calc the Fristenrechner is built for ("event Y
// happened, what deadlines spawn?"). m's 2026-05-20 ask (m/paliad#57):
// remove these from the "Was ist passiert?" picker — they belong in a
// future forward-workflow tool, not here. The DB rows stay so that
// future tool can pick them back up; we just hide them at the UI layer.
const HIDDEN_CASCADE_ROOTS: ReadonlySet<string> = new Set([
"ich-moechte-einreichen",
]);
// t-paliad-323 Slice S6: the cascade endpoint
// /api/tools/fristenrechner/event-categories was retired alongside
// HIDDEN_CASCADE_ROOTS. loadEventCategoryTree stays as a stub that
// returns an empty tree — every caller below it sits in the legacy
// Pathway B cascade which `?legacy=1` mode never boots into after
// initB1Cascade's early-return guard (see L3598). The whole subtree
// is dead-coded; a follow-up will lift it out wholesale.
async function loadEventCategoryTree(): Promise<EventCategoryNode[]> {
if (eventCategoryTree) return eventCategoryTree;
if (eventCategoryFetchInflight) return eventCategoryFetchInflight;
eventCategoryFetchInflight = (async () => {
try {
const r = await fetch("/api/tools/fristenrechner/event-categories");
if (!r.ok) throw new Error(`HTTP ${r.status}`);
const data = await r.json();
const raw = (data.tree || []) as EventCategoryNode[];
eventCategoryTree = raw.filter((n) => !HIDDEN_CASCADE_ROOTS.has(n.slug));
return eventCategoryTree;
} finally {
eventCategoryFetchInflight = null;
}
})();
return eventCategoryFetchInflight;
eventCategoryTree = [];
return eventCategoryTree;
}
function readB1PathFromURL(): string {
@@ -3536,30 +3579,14 @@ async function loadAndRenderB1() {
}
async function initB1Cascade() {
const panel = document.getElementById("fristen-b1-panel");
if (!panel) return;
// t-paliad-180: mode-radio retired; the row-stack's mode-row click
// handler drives tree↔filter routing. No standalone change listener
// needed here — showBMode() triggers loadAndRenderB1 when the
// pathway enters tree mode.
// Initial render if the URL already lands in tree mode.
const sp = new URLSearchParams(window.location.search);
if (sp.get("path") === "b" && sp.get("mode") === "tree") {
loadAndRenderB1();
}
// popstate restores the cascade depth.
window.addEventListener("popstate", () => {
const params = new URLSearchParams(window.location.search);
if (params.get("path") === "b" && params.get("mode") === "tree") {
// Always re-render — tree may not have loaded yet on first popstate.
currentActiveRow = null;
cascadeAutoWalkStopAfter = null;
loadAndRenderB1();
}
});
// t-paliad-323 Slice S6: the legacy Pathway B row-stack / cascade
// is dead-coded. Mode A (S3) + Mode B wizard (S4) replace it; the
// overhaul default boot (S5) handles every user route. Early-return
// here keeps the legacy module imports linked (for ?legacy=1 entry)
// while ensuring no cascade fetch / row-stack render fires. The
// helper bodies stay for one cleanup follow-up that lifts the whole
// subtree out.
return;
}
document.addEventListener("DOMContentLoaded", initB1Cascade);
@@ -3660,23 +3687,11 @@ function getActiveForumsParam(): string {
}
function initForumFilter() {
// Hydrate from URL on first load.
for (const slug of readForumsFromURL()) {
activeForums.add(slug);
}
renderForumChips();
// Restore on browser nav.
window.addEventListener("popstate", () => {
activeForums.clear();
for (const slug of readForumsFromURL()) {
activeForums.add(slug);
}
renderForumChips();
});
// Re-render labels on language change.
onLangChange(() => renderForumChips());
// t-paliad-323 Slice S6: dead-coded alongside initB1Cascade. The
// legacy forum-chip strip lived in the Pathway B B2-search panel
// which the overhaul has retired. Helper bodies stay for the
// follow-up cleanup that lifts the whole Pathway B subtree.
return;
}
document.addEventListener("DOMContentLoaded", initForumFilter);
@@ -3960,49 +3975,11 @@ async function persistInboxPref(ch: InboxChannel) {
}
async function initInboxFilter() {
// t-paliad-180: the standalone inbox chip strip is retired; inbox
// state still drives cascade narrowing + B2 fine-bucket sync, just
// surfaced through the row-stack row now. This init still hydrates
// from URL / saved preference + wires the popstate restore.
if (!document.getElementById("fristen-b1-panel")) return;
let initial: InboxChannel = readInboxFromURL();
if (initial === null) {
try {
const resp = await fetch("/api/me", { credentials: "same-origin" });
if (resp.ok) {
const me = (await resp.json()) as { forum_pref?: string | null };
if (me.forum_pref && INBOX_CHANNEL_VALUES.has(me.forum_pref)) {
initial = me.forum_pref as InboxChannel;
}
}
} catch {
// Anonymous visitor or transient error — leave the chip unset.
}
}
applyInboxFilter(initial);
// Sync B2 fine-bucket chips from the inbox on hydrate ONLY when the
// URL doesn't explicitly carry ?forum=… — an explicit forum= comes
// from a shared link and should win over the user's saved inbox
// preference. initForumFilter (which runs first) has already
// populated activeForums from URL forum=, so we leave it alone here.
if (initial !== null && readForumsFromURL().length === 0) {
applyFineForumsFromInbox(initial);
writeForumsToURL(true);
}
window.addEventListener("popstate", () => {
const newInbox = readInboxFromURL();
applyInboxFilter(newInbox);
// popstate can land on a URL with inbox= but no forum= (the user
// navigated to a state where derivation should re-apply). Don't
// touch activeForums when forum= is explicit — initForumFilter's
// own popstate handler has already loaded it from the URL.
if (newInbox !== null && readForumsFromURL().length === 0) {
applyFineForumsFromInbox(newInbox);
}
});
// t-paliad-323 Slice S6: dead-coded alongside initB1Cascade /
// initForumFilter. The inbox-channel row lived inside Pathway B's
// row-stack which the overhaul has retired. Helper bodies stay
// for the follow-up cleanup that lifts the whole subtree.
return;
}
document.addEventListener("DOMContentLoaded", initInboxFilter);

View File

@@ -1010,6 +1010,80 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.save.error": "\u00dcbernahme fehlgeschlagen.",
"deadlines.save.skip_court_set": "Gerichtsbestimmte Termine ohne Datum werden \u00fcbersprungen.",
// Fristenrechner overhaul \u2014 shared result view (S2, design \u00a74).
"deadlines.overhaul.loading": "Folge-Fristen werden geladen\u2026",
"deadlines.overhaul.load_error": "Folge-Fristen konnten nicht geladen werden.",
"deadlines.overhaul.empty": "Keine Folge-Fristen f\u00fcr dieses Ereignis hinterlegt.",
"deadlines.overhaul.trigger.label": "Trigger-Ereignis",
"deadlines.overhaul.trigger.date": "Trigger-Datum:",
"deadlines.overhaul.followups.label": "Folge-Fristen",
"deadlines.overhaul.group.mandatory": "Pflicht",
"deadlines.overhaul.group.recommended": "Empfohlen",
"deadlines.overhaul.group.optional": "Kann (auf Antrag)",
"deadlines.overhaul.group.conditional": "Bedingt",
"deadlines.overhaul.spawn.badge": "\u21f2 neues Verfahren",
"deadlines.overhaul.spawn.tooltip": "Diese Regel leitet ein neues Verfahren ein.",
"deadlines.overhaul.condition.badge": "Nur unter Bedingung",
"deadlines.overhaul.notes.summary": "Hinweis",
"deadlines.overhaul.edit_date.label": "\u270f Datum",
"deadlines.overhaul.edit_date.title": "Datum manuell anpassen",
"deadlines.overhaul.select_rule": "Frist ausw\u00e4hlen",
"deadlines.overhaul.footer.count": "{n} Fristen ausgew\u00e4hlt",
"deadlines.overhaul.footer.cta": "In Akte eintragen",
"deadlines.overhaul.nudge.no_project": "Tipp: W\u00e4hle oben eine Akte, um diese Fristen einzutragen.",
"deadlines.party.claimant": "Kl\u00e4gerseite",
"deadlines.party.defendant": "Beklagtenseite",
"deadlines.party.both": "Beide Seiten",
"deadlines.party.court": "Gericht",
// Fristenrechner overhaul Mode A \u2014 Direkt suchen (S3, design \u00a73.1).
"deadlines.overhaul.modes.label": "Modus",
"deadlines.overhaul.modes.search": "Direkt suchen",
"deadlines.overhaul.modes.wizard": "Gef\u00fchrt",
"deadlines.overhaul.wizard.coming_soon": "Gef\u00fchrter Modus kommt im n\u00e4chsten Slice.",
"deadlines.overhaul.modea.filters.label": "Filter",
"deadlines.overhaul.modea.filters.heading": "Filter (eingrenzen)",
"deadlines.overhaul.modea.axis.forum": "Forum:",
"deadlines.overhaul.modea.axis.proc": "Verfahren:",
"deadlines.overhaul.modea.axis.kind": "Was passierte:",
"deadlines.overhaul.modea.axis.party": "Partei:",
"deadlines.overhaul.modea.axis.inbox": "Eingangsweg:",
"deadlines.overhaul.modea.chip.all": "Alle",
"deadlines.overhaul.modea.inbox.summary": "Erweitert: Eingangsweg",
"deadlines.overhaul.modea.inbox.postal": "Postal",
"deadlines.overhaul.modea.search.label": "Suche",
"deadlines.overhaul.modea.search.placeholder": "Klageerhebung, Hinweisbeschluss, m\u00fcndliche Verhandlung\u2026",
"deadlines.overhaul.modea.results.label": "Ergebnisse",
"deadlines.overhaul.modea.results.heading": "Ergebnisse (klicken zum Einrasten als Trigger)",
"deadlines.overhaul.modea.results.count": "{n} Treffer",
"deadlines.overhaul.modea.row.followups": "{n} Folge-Fristen",
"deadlines.overhaul.modea.loading": "Wird geladen\u2026",
"deadlines.overhaul.modea.no_results": "Keine Treffer f\u00fcr diese Filter.",
"deadlines.overhaul.modea.no_proceedings": "Keine Verfahren in diesem Forum.",
"deadlines.overhaul.modea.search_error": "Suche fehlgeschlagen.",
"deadlines.overhaul.kind.filing": "Eingereicht",
"deadlines.overhaul.kind.hearing": "Termin",
"deadlines.overhaul.kind.decision": "Entscheidung",
"deadlines.overhaul.kind.order": "Verf\u00fcgung",
"deadlines.overhaul.kind.missed": "Frist vers\u00e4umt",
// Fristenrechner overhaul Mode B \u2014 gef\u00fchrter Wizard (S4, design \u00a73.2).
"deadlines.overhaul.wizard.heading": "Gef\u00fchrter Modus",
"deadlines.overhaul.wizard.hint": "Beantworte die Fragen oben nach unten \u2014 der Wizard landet auf einem Trigger-Ereignis und zeigt die Folge-Fristen.",
"deadlines.overhaul.wizard.r1.label": "Was ist passiert?",
"deadlines.overhaul.wizard.r2.label": "Vor welchem Gericht?",
"deadlines.overhaul.wizard.r3.label": "In welchem Verfahren?",
"deadlines.overhaul.wizard.r3.empty": "Kein Verfahren mit diesem Ereignistyp im gew\u00e4hlten Forum.",
"deadlines.overhaul.wizard.r4.label": "Welches Schriftst\u00fcck / welcher Termin?",
"deadlines.overhaul.wizard.r4.empty": "Keine Ereignisse zu dieser Auswahl.",
"deadlines.overhaul.wizard.r5.label": "Welche Seite vertreten Sie?",
"deadlines.overhaul.wizard.r5.probing": "Pr\u00fcfe, ob die Folge-Fristen seitenabh\u00e4ngig sind\u2026",
"deadlines.overhaul.wizard.badge.filter": "Filter",
"deadlines.overhaul.wizard.badge.qualifier": "Qualifier",
"deadlines.overhaul.wizard.edit": "\u00e4ndern",
"deadlines.overhaul.wizard.anno.from_project": "aus Akte",
"deadlines.overhaul.wizard.anno.implicit": "implizit",
// Office labels (shared)
"office.munich": "M\u00fcnchen",
"office.duesseldorf": "D\u00fcsseldorf",
@@ -3120,6 +3194,9 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.procedural_events.list.heading": "Verfahrensschritte verwalten",
"admin.procedural_events.list.new": "+ Neuer Verfahrensschritt",
"admin.procedural_events.col.code": "Code (Verfahrensschritt)",
// t-paliad-321: 3-segment proceeding-type code column (joined
// server-side); disambiguates same-named rules across proceedings.
"admin.procedural_events.col.proceeding": "Verfahren",
"admin.procedural_events.edit.title": "Verfahrensschritt bearbeiten — Paliad",
"admin.procedural_events.edit.breadcrumb":"← Verfahrensschritte verwalten",
"admin.procedural_events.edit.field.code": "Code (Verfahrensschritt-Identifikator)",
@@ -4119,6 +4196,80 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.save.error": "Import failed.",
"deadlines.save.skip_court_set": "Court-set entries with no date will be skipped.",
// Fristenrechner overhaul — shared result view (S2, design §4).
"deadlines.overhaul.loading": "Loading follow-up deadlines…",
"deadlines.overhaul.load_error": "Could not load follow-up deadlines.",
"deadlines.overhaul.empty": "No follow-up deadlines configured for this event.",
"deadlines.overhaul.trigger.label": "Trigger event",
"deadlines.overhaul.trigger.date": "Trigger date:",
"deadlines.overhaul.followups.label": "Follow-up deadlines",
"deadlines.overhaul.group.mandatory": "Mandatory",
"deadlines.overhaul.group.recommended": "Recommended",
"deadlines.overhaul.group.optional": "Optional",
"deadlines.overhaul.group.conditional": "Conditional",
"deadlines.overhaul.spawn.badge": "⇲ new proceeding",
"deadlines.overhaul.spawn.tooltip": "This rule initiates a new proceeding.",
"deadlines.overhaul.condition.badge": "Conditional",
"deadlines.overhaul.notes.summary": "Note",
"deadlines.overhaul.edit_date.label": "✏ Date",
"deadlines.overhaul.edit_date.title": "Edit date manually",
"deadlines.overhaul.select_rule": "Select deadline",
"deadlines.overhaul.footer.count": "{n} deadlines selected",
"deadlines.overhaul.footer.cta": "Add to project",
"deadlines.overhaul.nudge.no_project": "Tip: pick a project above to import these deadlines.",
"deadlines.party.claimant": "Claimant",
"deadlines.party.defendant": "Defendant",
"deadlines.party.both": "Both parties",
"deadlines.party.court": "Court",
// Fristenrechner overhaul Mode A — Direct search (S3, design §3.1).
"deadlines.overhaul.modes.label": "Mode",
"deadlines.overhaul.modes.search": "Direct search",
"deadlines.overhaul.modes.wizard": "Guided",
"deadlines.overhaul.wizard.coming_soon": "Guided mode coming in the next slice.",
"deadlines.overhaul.modea.filters.label": "Filters",
"deadlines.overhaul.modea.filters.heading": "Filters (narrow)",
"deadlines.overhaul.modea.axis.forum": "Forum:",
"deadlines.overhaul.modea.axis.proc": "Proceeding:",
"deadlines.overhaul.modea.axis.kind": "What happened:",
"deadlines.overhaul.modea.axis.party": "Party:",
"deadlines.overhaul.modea.axis.inbox": "Inbox channel:",
"deadlines.overhaul.modea.chip.all": "All",
"deadlines.overhaul.modea.inbox.summary": "Advanced: Inbox channel",
"deadlines.overhaul.modea.inbox.postal": "Postal",
"deadlines.overhaul.modea.search.label": "Search",
"deadlines.overhaul.modea.search.placeholder": "Statement of Claim, decision notice, oral hearing…",
"deadlines.overhaul.modea.results.label": "Results",
"deadlines.overhaul.modea.results.heading": "Results (click to lock as trigger)",
"deadlines.overhaul.modea.results.count": "{n} hits",
"deadlines.overhaul.modea.row.followups": "{n} follow-ups",
"deadlines.overhaul.modea.loading": "Loading…",
"deadlines.overhaul.modea.no_results": "No hits for these filters.",
"deadlines.overhaul.modea.no_proceedings": "No proceedings in this forum.",
"deadlines.overhaul.modea.search_error": "Search failed.",
"deadlines.overhaul.kind.filing": "Filed",
"deadlines.overhaul.kind.hearing": "Hearing",
"deadlines.overhaul.kind.decision": "Decision",
"deadlines.overhaul.kind.order": "Order",
"deadlines.overhaul.kind.missed": "Missed deadline",
// Fristenrechner overhaul Mode B — guided wizard (S4, design §3.2).
"deadlines.overhaul.wizard.heading": "Guided mode",
"deadlines.overhaul.wizard.hint": "Answer top-down — the wizard lands on a trigger event and shows the follow-up deadlines.",
"deadlines.overhaul.wizard.r1.label": "What happened?",
"deadlines.overhaul.wizard.r2.label": "Before which forum?",
"deadlines.overhaul.wizard.r3.label": "In which proceeding?",
"deadlines.overhaul.wizard.r3.empty": "No proceeding with this event kind in the chosen forum.",
"deadlines.overhaul.wizard.r4.label": "Which document / which hearing?",
"deadlines.overhaul.wizard.r4.empty": "No events for this selection.",
"deadlines.overhaul.wizard.r5.label": "Which party do you represent?",
"deadlines.overhaul.wizard.r5.probing": "Checking whether follow-ups depend on the side…",
"deadlines.overhaul.wizard.badge.filter": "Filter",
"deadlines.overhaul.wizard.badge.qualifier": "Qualifier",
"deadlines.overhaul.wizard.edit": "edit",
"deadlines.overhaul.wizard.anno.from_project": "from project",
"deadlines.overhaul.wizard.anno.implicit": "implicit",
// Office labels (shared)
"office.munich": "Munich",
"office.duesseldorf": "D\u00fcsseldorf",
@@ -6188,6 +6339,8 @@ const translations: Record<Lang, Record<string, string>> = {
"admin.procedural_events.list.heading": "Manage procedural events",
"admin.procedural_events.list.new": "+ New procedural event",
"admin.procedural_events.col.code": "Code (procedural event)",
// t-paliad-321: 3-segment proceeding-type code column.
"admin.procedural_events.col.proceeding": "Proceeding",
"admin.procedural_events.edit.title": "Edit procedural event — Paliad",
"admin.procedural_events.edit.breadcrumb":"← Manage procedural events",
"admin.procedural_events.edit.field.code": "Code (procedural-event identifier)",

View File

@@ -1317,6 +1317,26 @@ function paintSectionList(): void {
for (const sec of sections) {
list.appendChild(renderSectionRow(sec, lang, activeID === sec.id));
}
// t-paliad-318 Slice F — "+ Abschnitt hinzufügen" trailing
// affordance + "Reihenfolge speichern" affordance (only visible
// after a manual reorder; surfaced by paintReorderControls when
// pendingReorder is set).
let trailer = document.getElementById("submission-draft-sections-trailer");
if (!trailer) {
trailer = document.createElement("div");
trailer.id = "submission-draft-sections-trailer";
trailer.className = "submission-draft-sections-trailer";
wrap.appendChild(trailer);
}
trailer.innerHTML = "";
const addBtn = document.createElement("button");
addBtn.type = "button";
addBtn.className = "btn-small btn-secondary";
addBtn.textContent = isEN() ? "+ Add section" : "+ Abschnitt hinzufügen";
addBtn.addEventListener("click", () => openAddSectionForm(trailer!));
trailer.appendChild(addBtn);
}
function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: boolean): HTMLLIElement {
@@ -1325,9 +1345,29 @@ function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: bo
li.dataset.sectionId = sec.id;
if (!sec.included) li.classList.add("submission-draft-section--excluded");
// t-paliad-318 Slice F — drag-and-drop reorder. Native HTML5 DnD,
// no external library. The drag handle is the only draggable
// affordance so clicks inside the editor area don't accidentally
// trigger a drag.
li.draggable = false; // overridden via the handle below
li.addEventListener("dragover", (ev) => onSectionDragOver(ev, li));
li.addEventListener("drop", (ev) => onSectionDrop(ev, li));
li.addEventListener("dragleave", () => li.classList.remove("submission-draft-section--drop-target"));
const head = document.createElement("header");
head.className = "submission-draft-section-head";
// Drag handle — making just this element draggable scoped the
// gesture so contentEditable selections still work.
const handle = document.createElement("span");
handle.className = "submission-draft-section-handle";
handle.draggable = true;
handle.title = isEN() ? "Drag to reorder" : "Zum Sortieren ziehen";
handle.textContent = "⋮⋮";
handle.addEventListener("dragstart", (ev) => onSectionDragStart(ev, sec.id));
handle.addEventListener("dragend", () => onSectionDragEnd(li));
head.appendChild(handle);
const title = document.createElement("h3");
title.className = "submission-draft-section-title";
title.textContent = (lang === "en" ? sec.label_en : sec.label_de) || sec.section_key;
@@ -1356,6 +1396,16 @@ function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: bo
toggle.addEventListener("click", () => onSectionToggleIncluded(sec));
head.appendChild(toggle);
// t-paliad-318 Slice F — per-section delete. Removes the row.
// Confirmation guard prevents accidental loss of typed prose.
const del = document.createElement("button");
del.type = "button";
del.className = "btn-small btn-link-danger submission-draft-section-delete";
del.textContent = isEN() ? "Delete" : "Entfernen";
del.title = isEN() ? "Remove this section from the draft" : "Abschnitt aus dem Entwurf entfernen";
del.addEventListener("click", () => onSectionDelete(sec));
head.appendChild(del);
li.appendChild(head);
// Toolbar — Slice D rich-prose affordances: B/I + H1/H2/H3 +
@@ -1614,6 +1664,214 @@ async function onSectionToggleIncluded(sec: SubmissionSectionJSON): Promise<void
await patchSection(sec.id, { included: !sec.included });
}
// ─────────────────────────────────────────────────────────────────────
// t-paliad-318 Slice F — reorder / delete / add
// ─────────────────────────────────────────────────────────────────────
let dragSourceID: string | null = null;
function onSectionDragStart(ev: DragEvent, sectionID: string): void {
if (!ev.dataTransfer) return;
dragSourceID = sectionID;
ev.dataTransfer.effectAllowed = "move";
ev.dataTransfer.setData("text/plain", sectionID);
const parentLi = (ev.target as HTMLElement).closest("li");
if (parentLi) parentLi.classList.add("submission-draft-section--dragging");
}
function onSectionDragOver(ev: DragEvent, li: HTMLLIElement): void {
ev.preventDefault();
if (ev.dataTransfer) ev.dataTransfer.dropEffect = "move";
if (dragSourceID && dragSourceID !== li.dataset.sectionId) {
li.classList.add("submission-draft-section--drop-target");
}
}
function onSectionDragEnd(li: HTMLLIElement): void {
li.classList.remove("submission-draft-section--dragging");
document.querySelectorAll(".submission-draft-section--drop-target").forEach((el) => {
el.classList.remove("submission-draft-section--drop-target");
});
dragSourceID = null;
}
async function onSectionDrop(ev: DragEvent, targetLi: HTMLLIElement): Promise<void> {
ev.preventDefault();
targetLi.classList.remove("submission-draft-section--drop-target");
const sourceID = dragSourceID;
dragSourceID = null;
document.querySelectorAll(".submission-draft-section--dragging").forEach((el) => {
el.classList.remove("submission-draft-section--dragging");
});
if (!sourceID || !state.view?.sections) return;
const targetID = targetLi.dataset.sectionId;
if (!targetID || sourceID === targetID) return;
const ids = state.view.sections.map(s => s.id);
const fromIdx = ids.indexOf(sourceID);
const toIdx = ids.indexOf(targetID);
if (fromIdx < 0 || toIdx < 0) return;
// Splice source out, insert at target position. "Drop on row X"
// semantics: source lands JUST BEFORE the target row.
ids.splice(fromIdx, 1);
const insertAt = ids.indexOf(targetID);
ids.splice(insertAt, 0, sourceID);
await reorderSections(ids);
}
async function reorderSections(ids: string[]): Promise<void> {
if (!state.view) return;
const draftID = state.view.draft.id;
try {
const res = await fetch(
`/api/submission-drafts/${draftID}/sections/reorder`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ section_order: ids }),
},
);
if (!res.ok) {
console.warn("reorder failed", res.status);
return;
}
const body = await res.json() as { sections?: SubmissionSectionJSON[] };
if (state.view && body.sections) state.view.sections = body.sections;
paintSectionList();
} catch (err) {
console.warn("reorder error", err);
}
}
async function onSectionDelete(sec: SubmissionSectionJSON): Promise<void> {
const label = isEN() ? sec.label_en : sec.label_de;
const confirmMsg = isEN()
? `Delete section "${label}"? This cannot be undone.`
: `Abschnitt "${label}" entfernen? Diese Aktion kann nicht rückgängig gemacht werden.`;
if (!confirm(confirmMsg)) return;
if (!state.view) return;
try {
const res = await fetch(
`/api/submission-drafts/${state.view.draft.id}/sections/${sec.id}`,
{ method: "DELETE", credentials: "include" },
);
if (!res.ok && res.status !== 204) {
console.warn("delete section failed", res.status);
return;
}
if (state.view.sections) {
state.view.sections = state.view.sections.filter(s => s.id !== sec.id);
}
paintSectionList();
} catch (err) {
console.warn("delete section error", err);
}
}
function openAddSectionForm(host: HTMLElement): void {
// If already open, close (toggle).
const existing = host.querySelector(".submission-draft-add-section");
if (existing) {
existing.remove();
return;
}
const form = document.createElement("form");
form.className = "submission-draft-add-section";
form.addEventListener("submit", (ev) => { ev.preventDefault(); void submitAddSection(form); });
const fields = [
{ name: "section_key", label: isEN() ? "Slug" : "Slug", required: true, placeholder: "berufungsantraege" },
{ name: "label_de", label: "Label (DE)", required: true, placeholder: "Berufungsanträge" },
{ name: "label_en", label: "Label (EN)", required: true, placeholder: "Appeal requests" },
];
for (const f of fields) {
const row = document.createElement("label");
row.className = "submission-draft-add-section-row";
const lab = document.createElement("span");
lab.textContent = f.label + (f.required ? " *" : "");
row.appendChild(lab);
const inp = document.createElement("input");
inp.type = "text";
inp.name = f.name;
inp.className = "entity-form-input";
inp.required = f.required;
inp.placeholder = f.placeholder;
row.appendChild(inp);
form.appendChild(row);
}
const kindRow = document.createElement("label");
kindRow.className = "submission-draft-add-section-row";
const kindLab = document.createElement("span");
kindLab.textContent = isEN() ? "Kind" : "Typ";
kindRow.appendChild(kindLab);
const kindSel = document.createElement("select");
kindSel.name = "kind";
kindSel.className = "entity-form-input";
for (const opt of ["prose", "requests", "evidence"]) {
const o = document.createElement("option");
o.value = opt;
o.textContent = opt;
kindSel.appendChild(o);
}
kindRow.appendChild(kindSel);
form.appendChild(kindRow);
const actions = document.createElement("div");
actions.className = "submission-draft-add-section-actions";
const ok = document.createElement("button");
ok.type = "submit";
ok.className = "btn-small btn-primary btn-cta-lime";
ok.textContent = isEN() ? "Add" : "Hinzufügen";
actions.appendChild(ok);
const cancel = document.createElement("button");
cancel.type = "button";
cancel.className = "btn-small btn-secondary";
cancel.textContent = isEN() ? "Cancel" : "Abbrechen";
cancel.addEventListener("click", () => form.remove());
actions.appendChild(cancel);
form.appendChild(actions);
host.appendChild(form);
setTimeout(() => (form.querySelector('input[name="section_key"]') as HTMLInputElement | null)?.focus(), 0);
}
async function submitAddSection(form: HTMLFormElement): Promise<void> {
if (!state.view) return;
const data = new FormData(form);
const payload = {
section_key: String(data.get("section_key") ?? "").trim(),
kind: String(data.get("kind") ?? "prose"),
label_de: String(data.get("label_de") ?? "").trim(),
label_en: String(data.get("label_en") ?? "").trim(),
};
try {
const res = await fetch(
`/api/submission-drafts/${state.view.draft.id}/sections`,
{
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!res.ok) {
const body = await res.json().catch(() => ({} as { error?: string }));
alert(body.error ?? `HTTP ${res.status}`);
return;
}
const created = await res.json() as SubmissionSectionJSON;
if (state.view.sections) state.view.sections.push(created);
form.remove();
paintSectionList();
} catch (err) {
alert(String(err));
}
}
// ─────────────────────────────────────────────────────────────────────
// t-paliad-315 Slice C — building-block picker modal
// ─────────────────────────────────────────────────────────────────────

View File

@@ -123,6 +123,15 @@ export function renderFristenrechner(): string {
</p>
</div>
{/* t-paliad-323 Slice S2 — overhaul result view mount root.
Hidden by default; the client module shows this and hides
the legacy panels when `?overhaul=1` is present in the
URL. Deep-linkable on its own via
`?overhaul=1&event=<code>&trigger_date=…`. Mode A (S3)
and Mode B wizard (S4) will land users on this surface
once they identify a trigger procedural_event. */}
<div className="fristen-overhaul-root" id="fristen-overhaul-root" hidden></div>
{/* m's 2026-05-08 18:08 Determinator redesign — Step 1: pick the
Akte (project) that scopes the rest of the flow. Filtered
list of visible projects + "Neue Akte anlegen" link +

View File

@@ -297,6 +297,7 @@ export type I18nKey =
| "admin.partner_units.subtitle"
| "admin.partner_units.title"
| "admin.procedural_events.col.code"
| "admin.procedural_events.col.proceeding"
| "admin.procedural_events.edit.breadcrumb"
| "admin.procedural_events.edit.field.code"
| "admin.procedural_events.edit.field.event_kind"
@@ -1376,6 +1377,70 @@ export type I18nKey =
| "deadlines.neu.title"
| "deadlines.notes.show"
| "deadlines.optional.badge"
| "deadlines.overhaul.condition.badge"
| "deadlines.overhaul.edit_date.label"
| "deadlines.overhaul.edit_date.title"
| "deadlines.overhaul.empty"
| "deadlines.overhaul.followups.label"
| "deadlines.overhaul.footer.count"
| "deadlines.overhaul.footer.cta"
| "deadlines.overhaul.group.conditional"
| "deadlines.overhaul.group.mandatory"
| "deadlines.overhaul.group.optional"
| "deadlines.overhaul.group.recommended"
| "deadlines.overhaul.kind.decision"
| "deadlines.overhaul.kind.filing"
| "deadlines.overhaul.kind.hearing"
| "deadlines.overhaul.kind.missed"
| "deadlines.overhaul.kind.order"
| "deadlines.overhaul.load_error"
| "deadlines.overhaul.loading"
| "deadlines.overhaul.modea.axis.forum"
| "deadlines.overhaul.modea.axis.inbox"
| "deadlines.overhaul.modea.axis.kind"
| "deadlines.overhaul.modea.axis.party"
| "deadlines.overhaul.modea.axis.proc"
| "deadlines.overhaul.modea.chip.all"
| "deadlines.overhaul.modea.filters.heading"
| "deadlines.overhaul.modea.filters.label"
| "deadlines.overhaul.modea.inbox.postal"
| "deadlines.overhaul.modea.inbox.summary"
| "deadlines.overhaul.modea.loading"
| "deadlines.overhaul.modea.no_proceedings"
| "deadlines.overhaul.modea.no_results"
| "deadlines.overhaul.modea.results.count"
| "deadlines.overhaul.modea.results.heading"
| "deadlines.overhaul.modea.results.label"
| "deadlines.overhaul.modea.row.followups"
| "deadlines.overhaul.modea.search.label"
| "deadlines.overhaul.modea.search.placeholder"
| "deadlines.overhaul.modea.search_error"
| "deadlines.overhaul.modes.label"
| "deadlines.overhaul.modes.search"
| "deadlines.overhaul.modes.wizard"
| "deadlines.overhaul.notes.summary"
| "deadlines.overhaul.nudge.no_project"
| "deadlines.overhaul.select_rule"
| "deadlines.overhaul.spawn.badge"
| "deadlines.overhaul.spawn.tooltip"
| "deadlines.overhaul.trigger.date"
| "deadlines.overhaul.trigger.label"
| "deadlines.overhaul.wizard.anno.from_project"
| "deadlines.overhaul.wizard.anno.implicit"
| "deadlines.overhaul.wizard.badge.filter"
| "deadlines.overhaul.wizard.badge.qualifier"
| "deadlines.overhaul.wizard.coming_soon"
| "deadlines.overhaul.wizard.edit"
| "deadlines.overhaul.wizard.heading"
| "deadlines.overhaul.wizard.hint"
| "deadlines.overhaul.wizard.r1.label"
| "deadlines.overhaul.wizard.r2.label"
| "deadlines.overhaul.wizard.r3.empty"
| "deadlines.overhaul.wizard.r3.label"
| "deadlines.overhaul.wizard.r4.empty"
| "deadlines.overhaul.wizard.r4.label"
| "deadlines.overhaul.wizard.r5.label"
| "deadlines.overhaul.wizard.r5.probing"
| "deadlines.party.both"
| "deadlines.party.both.label"
| "deadlines.party.claimant"

View File

@@ -67,6 +67,21 @@
--color-segment-active-fg: var(--color-accent-dark);
--color-segment-active-border: var(--color-accent);
/* Accent soft/strong tints — pale lime backdrops for nudges, hover
states, success messages and saturated "selected" chip pills.
Soft is the pale tint (footer, nudge, ok-msg, hover); strong is
the saturated lime pill (active chip, jurisdiction badge,
wizard active-row outline). Dark mode flips to alpha-on-midnight
so the lime cue stays visible without shouting. Consumed by
.fristen-overhaul-* / .fristen-mode-* / .fristen-wizard-*
(m/paliad#146 follow-up, t-paliad-326). */
--color-accent-soft-bg: #f7fbe6;
--color-accent-soft-fg: #3d501c;
--color-accent-soft-border: #d2e08b;
--color-accent-strong-bg: #d3edb7;
--color-accent-strong-fg: #38531a;
--color-accent-strong-border: #98b545;
/* Status palette — five buckets (red/amber/green/blue/neutral) shared
across dashboard cards, frist-due-chips, agenda urgency, termin
badges, login forms. Light values match the existing pastel-on-dark
@@ -87,8 +102,14 @@
--status-blue-bg: #dbeafe;
--status-blue-fg: #1e40af;
--status-blue-fg-2: #2563eb;
--status-blue-border: #93c5fd;
--status-blue-soft-bg: #eef2ff;
--status-blue-soft-fg: #4338ca;
/* Purple bucket — added for "court" party stance and other neutral
institutional roles. Tailwind-style purple-100/purple-800/300. */
--status-purple-bg: #f0e2f7;
--status-purple-fg: #4f2c66;
--status-purple-border: #d8b4fe;
--status-neutral-bg: #f3f4f6;
--status-neutral-fg: #6b7280;
--status-neutral-fg-2: #475569;
@@ -188,6 +209,16 @@
--color-segment-active-fg: var(--color-accent-dark);
--color-segment-active-border: var(--color-accent);
/* Accent soft/strong tints — alpha-tinted lime on midnight so the
cue reads without flattening the surface. Mirrors the light-mode
block above (t-paliad-326). */
--color-accent-soft-bg: rgb(var(--hlc-lime-rgb) / 0.08);
--color-accent-soft-fg: #bef264;
--color-accent-soft-border: rgb(var(--hlc-lime-rgb) / 0.30);
--color-accent-strong-bg: rgb(var(--hlc-lime-rgb) / 0.22);
--color-accent-strong-fg: var(--hlc-lime);
--color-accent-strong-border: rgb(var(--hlc-lime-rgb) / 0.45);
--shadow: 0 1px 3px rgba(0, 0, 0, 0.4), 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.45);
--shadow-lg: 0 8px 32px rgba(0, 0, 0, 0.55);
@@ -211,8 +242,13 @@
--status-blue-bg: rgb(59 130 246 / 0.18);
--status-blue-fg: #93c5fd;
--status-blue-fg-2: #60a5fa;
--status-blue-border: rgb(59 130 246 / 0.35);
--status-blue-soft-bg: rgb(99 102 241 / 0.18);
--status-blue-soft-fg: #a5b4fc;
/* Purple bucket — alpha-tinted on midnight, bright fg for AA. */
--status-purple-bg: rgb(168 85 247 / 0.18);
--status-purple-fg: #d8b4fe;
--status-purple-border: rgb(168 85 247 / 0.35);
--status-neutral-bg: rgb(var(--hlc-cream-rgb) / 0.08);
--status-neutral-fg: rgb(var(--hlc-cream-rgb) / 0.66);
--status-neutral-fg-2: rgb(var(--hlc-cream-rgb) / 0.55);
@@ -6294,6 +6330,71 @@ dialog.modal::backdrop {
background: var(--color-bg-elev-2, var(--color-bg-elev-1));
}
/* t-paliad-318 Slice F — drag-and-drop reorder + add / delete affordances. */
.submission-draft-section-handle {
cursor: grab;
user-select: none;
color: var(--color-text-muted);
font-weight: 600;
padding: 0 0.35rem;
margin-right: 0.4rem;
border-radius: 3px;
}
.submission-draft-section-handle:hover {
background: var(--color-bg-subtle, var(--color-bg-elev-2));
}
.submission-draft-section-handle:active {
cursor: grabbing;
}
.submission-draft-section--dragging {
opacity: 0.5;
}
.submission-draft-section--drop-target {
border-top: 2px solid var(--color-accent-fg, var(--color-text));
}
.submission-draft-section-delete {
margin-left: 0.35rem;
}
.submission-draft-sections-trailer {
margin-top: 0.6rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.submission-draft-add-section {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding: 0.6rem 0.7rem;
border: 1px dashed var(--color-border);
border-radius: 4px;
background: var(--color-bg-elev-1);
}
.submission-draft-add-section-row {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.submission-draft-add-section-row > span {
font-size: 0.85em;
color: var(--color-text-muted);
}
.submission-draft-add-section-actions {
display: flex;
gap: 0.4rem;
margin-top: 0.2rem;
}
/* t-paliad-315 Slice C — building-block picker modal */
.submission-draft-section-bb-btn {
margin-left: auto;
@@ -18821,3 +18922,800 @@ dialog.quick-add-sheet::backdrop {
gap: 0.5rem;
}
}
/* === Fristenrechner overhaul (t-paliad-323 Slice S2) =================
*
* Result-view surface mounted under `?overhaul=1`. Sticky trigger card
* on top, four priority groups of follow-up rules, write-back footer
* conditional on `?project=<uuid>`. See
* docs/design-fristenrechner-overhaul-2026-05-26.md §4.
* ==================================================================== */
.fristen-overhaul-root {
display: block;
margin-top: 1.5rem;
}
.fristen-overhaul-loading,
.fristen-overhaul-error,
.fristen-overhaul-empty,
.fristen-overhaul-nudge {
padding: 0.9rem 1.1rem;
border-radius: 0.6rem;
margin: 0.5rem 0;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
color: var(--color-text);
font-size: 0.95rem;
}
.fristen-overhaul-error {
background: var(--status-red-bg);
border-color: var(--status-red-border);
color: var(--status-red-fg);
}
.fristen-overhaul-nudge {
background: var(--color-accent-soft-bg);
border-color: var(--color-accent-soft-border);
color: var(--color-accent-soft-fg);
}
.fristen-overhaul-trigger {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.8rem;
padding: 1rem 1.2rem;
margin-bottom: 1.2rem;
box-shadow: var(--shadow);
}
.fristen-overhaul-trigger-header {
display: flex;
align-items: center;
gap: 0.7rem;
}
.fristen-overhaul-kind-icon {
font-size: 1.5rem;
line-height: 1;
}
.fristen-overhaul-trigger-title {
margin: 0;
font-size: 1.25rem;
color: var(--color-text);
}
.fristen-overhaul-trigger-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.5rem;
font-size: 0.9rem;
color: var(--color-text-muted);
}
.fristen-overhaul-trigger-code,
.fristen-overhaul-trigger-pt,
.fristen-overhaul-trigger-juris {
padding: 0.15rem 0.55rem;
border-radius: 0.4rem;
background: var(--color-surface-muted);
color: var(--color-text-muted);
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 0.8rem;
}
.fristen-overhaul-trigger-juris {
background: var(--color-accent-strong-bg);
color: var(--color-accent-strong-fg);
font-family: inherit;
font-weight: 600;
}
.fristen-overhaul-trigger-date {
display: flex;
align-items: center;
gap: 0.7rem;
margin-top: 0.8rem;
}
.fristen-overhaul-trigger-date-label {
font-size: 0.9rem;
color: var(--color-text-muted);
}
.fristen-overhaul-trigger-date-input {
padding: 0.35rem 0.55rem;
font-size: 0.95rem;
border: 1px solid var(--color-border-strong);
border-radius: 0.4rem;
background: var(--color-input-bg);
color: var(--color-text);
}
.fristen-overhaul-groups {
display: flex;
flex-direction: column;
gap: 1.1rem;
}
.fristen-overhaul-group {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.7rem;
padding: 0.9rem 1.1rem;
}
.fristen-overhaul-group--mandatory { border-left: 4px solid var(--color-accent); }
.fristen-overhaul-group--recommended { border-left: 4px solid var(--status-blue-border); }
.fristen-overhaul-group--optional { border-left: 4px solid var(--color-border-strong); }
.fristen-overhaul-group--conditional { border-left: 4px solid var(--status-amber-border); }
.fristen-overhaul-group-title {
margin: 0 0 0.6rem 0;
font-size: 1rem;
color: var(--color-text);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.fristen-overhaul-rule-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.fristen-overhaul-rule {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.7rem;
align-items: start;
padding: 0.5rem 0.6rem;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
}
.fristen-overhaul-rule.is-disabled {
opacity: 0.7;
}
.fristen-overhaul-rule-check {
display: flex;
align-items: center;
height: 1.4rem;
cursor: pointer;
}
.fristen-overhaul-rule-body {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.fristen-overhaul-rule-title-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem;
}
.fristen-overhaul-rule-title {
font-weight: 600;
color: var(--color-text);
}
.fristen-overhaul-rule-spawn,
.fristen-overhaul-rule-cond {
font-size: 0.75rem;
padding: 0.05rem 0.45rem;
border-radius: 0.35rem;
background: var(--status-amber-bg);
color: var(--status-amber-fg);
white-space: nowrap;
}
.fristen-overhaul-rule-cond {
background: var(--status-amber-bg);
color: var(--status-amber-fg-2);
}
.fristen-overhaul-rule-meta-row {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.fristen-overhaul-rule-duration {
color: var(--color-text);
}
.fristen-overhaul-rule-party {
padding: 0.05rem 0.45rem;
border-radius: 0.35rem;
font-size: 0.75rem;
background: var(--color-accent-soft-bg);
color: var(--color-accent-soft-fg);
}
.fristen-overhaul-rule-party--claimant { background: var(--status-blue-bg); color: var(--status-blue-fg); }
.fristen-overhaul-rule-party--defendant { background: var(--status-red-bg); color: var(--status-red-fg); }
.fristen-overhaul-rule-party--court { background: var(--status-purple-bg); color: var(--status-purple-fg); }
.fristen-overhaul-rule-source {
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
font-size: 0.8rem;
color: var(--color-text-muted);
}
a.fristen-overhaul-rule-source {
color: var(--color-accent-fg);
text-decoration: underline;
text-underline-offset: 2px;
}
.fristen-overhaul-rule-notes {
margin-top: 0.3rem;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.fristen-overhaul-rule-notes summary {
cursor: pointer;
color: var(--color-text-muted);
}
.fristen-overhaul-rule-date-cell {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.25rem;
font-size: 0.95rem;
min-width: 6.5rem;
}
.fristen-overhaul-rule-date {
font-weight: 600;
color: var(--color-text);
}
.fristen-overhaul-rule-date--unknown {
color: var(--color-text-subtle);
font-weight: 400;
}
.fristen-overhaul-rule-court-set {
color: var(--status-amber-fg);
font-style: italic;
font-size: 0.85rem;
}
.fristen-overhaul-rule-date-input {
padding: 0.2rem 0.4rem;
font-size: 0.95rem;
border: 1px solid var(--color-border-strong);
border-radius: 0.3rem;
background: var(--color-input-bg);
color: var(--color-text);
}
.fristen-overhaul-rule-edit-date {
border: 0;
background: transparent;
color: var(--color-accent-fg);
font-size: 0.8rem;
cursor: pointer;
padding: 0.1rem 0.3rem;
border-radius: 0.3rem;
}
.fristen-overhaul-rule-edit-date:hover {
background: var(--color-accent-soft-bg);
}
.fristen-overhaul-footer {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1.2rem;
padding: 0.9rem 1.1rem;
background: var(--color-accent-soft-bg);
border: 1px solid var(--color-accent-soft-border);
border-radius: 0.7rem;
}
.fristen-overhaul-footer-count {
font-size: 0.95rem;
color: var(--color-accent-soft-fg);
font-weight: 500;
}
.fristen-overhaul-footer-cta {
/* leans on btn-primary / btn-cta-lime classes from global */
}
.fristen-overhaul-msg {
margin-top: 0.8rem;
padding: 0.6rem 0.9rem;
font-size: 0.9rem;
border-radius: 0.4rem;
}
.fristen-overhaul-msg.form-msg-ok { background: var(--status-green-bg); color: var(--status-green-fg); }
.fristen-overhaul-msg.form-msg-error { background: var(--status-red-bg); color: var(--status-red-fg); }
@media (max-width: 600px) {
.fristen-overhaul-rule {
grid-template-columns: auto 1fr;
}
.fristen-overhaul-rule-date-cell {
grid-column: 1 / -1;
flex-direction: row;
justify-content: flex-end;
align-items: center;
min-width: 0;
}
}
/* === Fristenrechner overhaul Mode A + mode tabs (t-paliad-323 S3) ===
*
* Mode tab pair + filter strip + search box + result list per
* docs/design-fristenrechner-overhaul-2026-05-26.md §3.1.
* Section-split visual hierarchy per m §11.Q3: filter strip on top
* ("Filter (eingrenzen)" header), result list below (clicking a row
* IS the qualifier commit).
* ==================================================================== */
.fristen-mode-tabs {
display: flex;
gap: 0.4rem;
margin: 0 0 1rem 0;
border-bottom: 2px solid var(--color-border);
}
.fristen-mode-tab {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 1.1rem;
background: transparent;
border: 0;
border-bottom: 3px solid transparent;
margin-bottom: -2px;
color: var(--color-text-muted);
font-size: 1rem;
font-weight: 500;
cursor: pointer;
border-radius: 0.4rem 0.4rem 0 0;
}
.fristen-mode-tab:hover {
background: var(--color-overlay-faint);
color: var(--color-text);
}
.fristen-mode-tab.is-active {
color: var(--color-text);
border-bottom-color: var(--color-accent);
background: var(--color-accent-soft-bg);
}
.fristen-mode-tab-icon {
font-size: 1.1rem;
}
.fristen-mode-tab-label {
font-weight: 500;
}
.fristen-mode-a-root {
display: flex;
flex-direction: column;
gap: 1rem;
}
.fristen-mode-a-filters {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: 0.7rem;
padding: 0.8rem 1rem;
}
.fristen-mode-a-filters-header {
font-size: 0.8rem;
letter-spacing: 0.05em;
color: var(--color-text-muted);
text-transform: uppercase;
margin-bottom: 0.6rem;
font-weight: 600;
}
.fristen-mode-a-chip-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
margin-bottom: 0.4rem;
}
.fristen-mode-a-axis-label {
font-size: 0.85rem;
color: var(--color-text-muted);
min-width: 7rem;
font-weight: 500;
}
.fristen-mode-a-chips {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.fristen-mode-a-chip {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.25rem 0.65rem;
border: 1px solid var(--color-border-strong);
background: var(--color-surface);
color: var(--color-text);
border-radius: 1rem;
font-size: 0.85rem;
cursor: pointer;
font-family: inherit;
}
.fristen-mode-a-chip:hover {
border-color: var(--color-accent-soft-border);
background: var(--color-accent-soft-bg);
}
.fristen-mode-a-chip.is-active {
background: var(--color-accent-strong-bg);
border-color: var(--color-accent-strong-border);
color: var(--color-accent-strong-fg);
font-weight: 600;
}
.fristen-mode-a-chip-icon {
font-size: 0.95rem;
}
.fristen-mode-a-chip-loading,
.fristen-mode-a-chip-empty {
color: var(--color-text-subtle);
font-size: 0.85rem;
font-style: italic;
}
.fristen-mode-a-inbox {
margin-top: 0.5rem;
padding-top: 0.4rem;
border-top: 1px dashed var(--color-border);
}
.fristen-mode-a-inbox-summary {
cursor: pointer;
color: var(--color-text-muted);
font-size: 0.85rem;
margin-bottom: 0.3rem;
user-select: none;
}
.fristen-mode-a-inbox-summary:hover {
color: var(--color-text);
}
.fristen-mode-a-search {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.7rem;
padding: 0.6rem 0.9rem;
}
.fristen-mode-a-search-input-wrap {
display: flex;
align-items: center;
gap: 0.6rem;
}
.fristen-mode-a-search-icon {
color: var(--color-text-subtle);
}
.fristen-mode-a-search-input {
flex: 1 1 auto;
border: 0;
outline: none;
font-size: 1rem;
background: transparent;
padding: 0.3rem 0;
color: var(--color-text);
}
.fristen-mode-a-results {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.7rem;
padding: 0.7rem 0.9rem;
}
.fristen-mode-a-results-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.fristen-mode-a-results-title {
font-weight: 600;
color: var(--color-text);
}
.fristen-mode-a-results-count {
font-size: 0.85rem;
color: var(--color-text-subtle);
}
.fristen-mode-a-result-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.4rem;
max-height: 60vh;
overflow-y: auto;
}
.fristen-mode-a-result {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.7rem;
align-items: center;
padding: 0.55rem 0.7rem;
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
cursor: pointer;
}
.fristen-mode-a-result:hover,
.fristen-mode-a-result:focus {
background: var(--color-accent-soft-bg);
border-color: var(--color-accent-soft-border);
outline: none;
}
.fristen-mode-a-result-icon {
font-size: 1.3rem;
}
.fristen-mode-a-result-body {
min-width: 0;
}
.fristen-mode-a-result-title {
font-weight: 600;
color: var(--color-text);
}
.fristen-mode-a-result-meta {
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
font-size: 0.8rem;
color: var(--color-text-muted);
margin-top: 0.2rem;
}
.fristen-mode-a-result-pt {
font-family: ui-monospace, "SFMono-Regular", Menlo, monospace;
padding: 0.05rem 0.45rem;
background: var(--color-surface-muted);
border-radius: 0.3rem;
}
.fristen-mode-a-result-pt-name {
color: var(--color-text-muted);
}
.fristen-mode-a-result-juris {
padding: 0.05rem 0.45rem;
background: var(--color-accent-strong-bg);
color: var(--color-accent-strong-fg);
border-radius: 0.3rem;
font-weight: 600;
}
.fristen-mode-a-result-followups {
color: var(--color-accent-fg);
font-weight: 500;
}
.fristen-mode-a-result-cta {
color: var(--color-accent-fg);
font-size: 1.2rem;
}
.fristen-mode-a-result-loading,
.fristen-mode-a-result-empty,
.fristen-mode-a-result-error {
list-style: none;
padding: 0.7rem 0.5rem;
color: var(--color-text-subtle);
font-style: italic;
font-size: 0.9rem;
}
.fristen-mode-a-result-error {
color: var(--status-red-fg);
}
@media (max-width: 600px) {
.fristen-mode-a-axis-label {
min-width: 0;
width: 100%;
}
.fristen-mode-a-result {
grid-template-columns: auto 1fr;
}
.fristen-mode-a-result-cta {
grid-column: 1 / -1;
text-align: right;
}
}
/* === Fristenrechner overhaul Mode B — wizard (t-paliad-323 S4) ======
*
* 3-5 row stack landing on a procedural_event. Row badge "Filter" vs
* "Qualifier" per m §11.Q3; "aus Akte" / "implizit" annotations per
* §11.Q10 (preserve compatible downstream picks on back-nav).
* ==================================================================== */
.fristen-wizard-root {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 0.7rem;
padding: 1rem 1.1rem;
}
.fristen-wizard-header {
margin-bottom: 0.7rem;
}
.fristen-wizard-title {
margin: 0 0 0.25rem 0;
font-size: 1.15rem;
color: var(--color-text);
}
.fristen-wizard-hint {
margin: 0;
font-size: 0.85rem;
color: var(--color-text-muted);
}
.fristen-wizard-rows {
display: flex;
flex-direction: column;
gap: 0.55rem;
}
.fristen-wizard-row {
background: var(--color-surface-2);
border: 1px solid var(--color-border);
border-radius: 0.6rem;
padding: 0.55rem 0.7rem;
}
.fristen-wizard-row.is-active {
border-color: var(--color-accent-strong-border);
box-shadow: 0 0 0 2px rgb(var(--hlc-lime-rgb) / 0.15);
}
.fristen-wizard-row.is-from-project {
background: var(--color-accent-soft-bg);
}
.fristen-wizard-row.is-implicit {
opacity: 0.85;
}
.fristen-wizard-row-header {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.fristen-wizard-row-n {
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.6rem;
height: 1.6rem;
border-radius: 50%;
background: var(--color-accent);
color: var(--color-accent-dark);
font-weight: 600;
font-size: 0.85rem;
}
.fristen-wizard-row-badge {
font-size: 0.7rem;
padding: 0.05rem 0.45rem;
border-radius: 0.35rem;
letter-spacing: 0.04em;
text-transform: uppercase;
font-weight: 600;
}
.fristen-wizard-row-badge--filter { background: var(--status-blue-bg); color: var(--status-blue-fg); }
.fristen-wizard-row-badge--qualifier { background: var(--status-amber-bg); color: var(--status-amber-fg-2); }
.fristen-wizard-row-label {
font-weight: 500;
color: var(--color-text);
}
.fristen-wizard-row-anno {
font-size: 0.75rem;
color: var(--color-accent-soft-fg);
background: var(--color-accent-soft-bg);
padding: 0.05rem 0.4rem;
border-radius: 0.3rem;
}
.fristen-wizard-row-answer {
margin-left: auto;
color: var(--color-text);
font-weight: 500;
}
.fristen-wizard-row-edit {
background: transparent;
border: 0;
color: var(--color-accent-fg);
cursor: pointer;
font-size: 0.85rem;
padding: 0.15rem 0.45rem;
border-radius: 0.3rem;
}
.fristen-wizard-row-edit:hover {
background: var(--color-accent-soft-bg);
}
.fristen-wizard-row-body {
margin-top: 0.55rem;
}
.fristen-wizard-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.fristen-wizard-empty,
.fristen-wizard-probe {
font-size: 0.85rem;
color: var(--color-text-subtle);
font-style: italic;
}
@media (max-width: 600px) {
.fristen-wizard-row-answer {
margin-left: 0;
width: 100%;
}
}

View File

@@ -314,15 +314,28 @@ CREATE TRIGGER deadline_rules_unified_update
DO $$
DECLARE
v_snapshot_count int;
v_sr_count int;
v_view_count int;
v_dr_table_exists int;
v_rule_id_col int;
BEGIN
SELECT COUNT(*) INTO v_snapshot_count FROM paliad.deadline_rules_pre_140;
-- B.2 dual-write was implemented only for the active+published lifecycle
-- (the scope of the read paths and B.4's pre-flip drift check). Archived
-- + draft rows in deadline_rules were never replicated to sequencing_rules
-- (they had no production read path). Snapshot includes them all (CREATE
-- TABLE AS is unfiltered), so we compare on the same filter B.2 actually
-- maintained. Drafts/archived rows are preserved in paliad.deadline_rules_pre_140
-- for forensic + future-backfill use.
SELECT COUNT(*) INTO v_snapshot_count
FROM paliad.deadline_rules_pre_140
WHERE is_active = true AND lifecycle_state = 'published';
SELECT COUNT(*) INTO v_sr_count
FROM paliad.sequencing_rules
WHERE is_active = true AND lifecycle_state = 'published';
SELECT COUNT(*) INTO v_view_count FROM paliad.deadline_rules_unified;
IF v_snapshot_count <> v_view_count THEN
RAISE EXCEPTION '[mig 140] FAILED POST: snapshot has % rows, view has % rows — drift between final state and snapshot',
v_snapshot_count, v_view_count;
IF v_snapshot_count <> v_sr_count THEN
RAISE EXCEPTION '[mig 140] FAILED POST: snapshot active+published has % rows, sequencing_rules active+published has % rows — dual-write drift',
v_snapshot_count, v_sr_count;
END IF;
SELECT COUNT(*) INTO v_dr_table_exists
@@ -339,8 +352,8 @@ BEGIN
RAISE EXCEPTION '[mig 140] FAILED POST: paliad.deadlines.rule_id column still exists after DROP';
END IF;
RAISE NOTICE '[mig 140] OK — deadline_rules dropped, snapshot=% rows, view=% rows, INSTEAD OF triggers active',
v_snapshot_count, v_view_count;
RAISE NOTICE '[mig 140] OK — deadline_rules dropped, snapshot=% rows, sequencing_rules=% rows, view (filtered)=% rows, INSTEAD OF triggers active',
v_snapshot_count, v_sr_count, v_view_count;
END $$;
-- ---------------------------------------------------------------

View File

@@ -0,0 +1,31 @@
-- 151_dedupe_null_procedural_events (down) — t-paliad-319 / m/paliad#144
--
-- Best-effort restore from paliad.procedural_events_pre_151 and
-- paliad.sequencing_rules_pre_151. Re-points the reparented
-- sequencing_rules back at their original procedural_event_id and
-- reactivates the archived duplicates with the lifecycle_state +
-- is_active they had before the up migration.
--
-- Catastrophic-recovery path only; the normal revert is to leave the
-- dedupe in place (it is purely cosmetic).
-- 1. Re-point sequencing_rules.procedural_event_id back to its
-- pre-mig-151 value. The snapshot row is keyed by sr.id so the
-- join is 1:1 and idempotent.
UPDATE paliad.sequencing_rules sr
SET procedural_event_id = s.original_procedural_event_id,
updated_at = now()
FROM paliad.sequencing_rules_pre_151 s
WHERE sr.id = s.id;
-- 2. Reactivate the archived duplicates with their snapshot lifecycle.
UPDATE paliad.procedural_events pe
SET is_active = s.is_active,
lifecycle_state = s.lifecycle_state,
updated_at = now()
FROM paliad.procedural_events_pre_151 s
WHERE pe.id = s.id;
-- 3. Drop the snapshot tables — the data is back in place.
DROP TABLE IF EXISTS paliad.sequencing_rules_pre_151;
DROP TABLE IF EXISTS paliad.procedural_events_pre_151;

View File

@@ -0,0 +1,229 @@
-- 151_dedupe_null_procedural_events — t-paliad-319 / m/paliad#144
--
-- Purpose: ~14 paliad.procedural_events rows with synthetic null.<8hex>
-- codes (minted by mig 136 from the legacy paliad.deadline_rules rows
-- whose submission_code was NULL) share user-visible names. The
-- /admin/procedural-events list shows multiple entries for the same legal
-- concept (worst offender: "Mängelbeseitigung / Zahlung" × 6). This
-- migration consolidates every name-group onto a single canonical row,
-- reparents the sequencing_rules pointing at the duplicates, and archives
-- the duplicates without deleting them.
--
-- Scope verified live before write (Supabase MCP, 2026-05-26):
-- * 5 name-groups, 14 duplicate rows total (1 canonical + 15 dups per
-- group). Every duplicate has exactly 1 sequencing_rule pointing at it.
-- * 0 paliad.deadlines reference any duplicate.
-- * 0 procedural_events.draft_of references any duplicate.
-- * No audit trigger on procedural_events or sequencing_rules — only
-- the INSTEAD OF triggers on deadline_rules_unified (mig 140), which
-- do not fire on direct table writes. No set_config('paliad.audit_reason')
-- needed.
--
-- Canonical selection: ROW_NUMBER() OVER (PARTITION BY name ORDER BY
-- created_at, id::text). Every duplicate in current data shares the same
-- created_at (mig 136 bulk insert), so the deterministic tiebreaker is
-- the UUID's lexicographic order.
--
-- Hard constraints honoured:
-- * No deletions. Duplicates flip to is_active=false +
-- lifecycle_state='archived'. The rows stay in the table for audit.
-- * Reparent sequencing_rules.procedural_event_id duplicate → canonical
-- BEFORE archiving, so no FK ever points at an archived PE.
-- * Snapshot the affected procedural_events + sequencing_rules into
-- paliad.procedural_events_pre_151 / paliad.sequencing_rules_pre_151
-- in the same TX, mirroring precedent (migs 091/093/095/098/140).
--
-- Down: best-effort restore from the snapshots. See .down.sql.
-- ----------------------------------------------------------------
-- 1. Build the dedupe mapping (duplicate_id → canonical_id) in a
-- TEMP table used by every subsequent step.
-- ----------------------------------------------------------------
CREATE TEMP TABLE tmp_pe_dedupe ON COMMIT DROP AS
WITH dupe_names AS (
SELECT name
FROM paliad.procedural_events
WHERE code LIKE 'null.%'
GROUP BY name
HAVING COUNT(*) > 1
),
ranked AS (
SELECT pe.id,
pe.code,
pe.name,
pe.created_at,
ROW_NUMBER() OVER (
PARTITION BY pe.name
ORDER BY pe.created_at, pe.id::text
) AS rn
FROM paliad.procedural_events pe
WHERE pe.code LIKE 'null.%'
AND pe.name IN (SELECT name FROM dupe_names)
),
canonicals AS (
SELECT name,
id AS canonical_id,
code AS canonical_code
FROM ranked
WHERE rn = 1
)
SELECT r.id AS duplicate_id,
r.code AS duplicate_code,
r.name,
c.canonical_id,
c.canonical_code
FROM ranked r
JOIN canonicals c ON c.name = r.name
WHERE r.rn > 1;
-- ----------------------------------------------------------------
-- 2. Snapshot. Captures the rows that change so .down has a clean
-- source of truth; mirrors the pre_091/093/095/098/140 precedent.
-- ----------------------------------------------------------------
CREATE TABLE paliad.procedural_events_pre_151 AS
SELECT pe.*
FROM paliad.procedural_events pe
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
COMMENT ON TABLE paliad.procedural_events_pre_151 IS
'Snapshot (mig 151, t-paliad-319) of the null.* procedural_events '
'duplicates that were archived in favour of their canonical name-mate. '
'Read-only forensic + revert source. Mirrors precedent pre_091/093/'
'095/098/140.';
CREATE TABLE paliad.sequencing_rules_pre_151 AS
SELECT sr.id,
sr.procedural_event_id AS original_procedural_event_id
FROM paliad.sequencing_rules sr
WHERE sr.procedural_event_id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
COMMENT ON TABLE paliad.sequencing_rules_pre_151 IS
'Snapshot (mig 151, t-paliad-319) of sequencing_rules.procedural_event_id '
'before reparenting from null.* duplicates onto their canonical PE. '
'Read-only forensic + revert source.';
-- ----------------------------------------------------------------
-- 3. Audit log — per-row NOTICE so the migration output captures
-- exactly which duplicate folded into which canonical, including
-- the sr_count for the duplicate (always 1 in current data, but
-- the RAISE keeps the audit honest if the scope grows later).
-- ----------------------------------------------------------------
DO $$
DECLARE
rec record;
v_dup_count int;
v_grp_count int;
BEGIN
SELECT COUNT(*), COUNT(DISTINCT name)
INTO v_dup_count, v_grp_count
FROM tmp_pe_dedupe;
RAISE NOTICE '[mig 151] dedupe scope: % duplicate rows across % name-groups',
v_dup_count, v_grp_count;
FOR rec IN
SELECT d.duplicate_id,
d.duplicate_code,
d.name,
d.canonical_id,
d.canonical_code,
(SELECT COUNT(*)
FROM paliad.sequencing_rules sr
WHERE sr.procedural_event_id = d.duplicate_id) AS sr_count
FROM tmp_pe_dedupe d
ORDER BY d.name, d.duplicate_id
LOOP
RAISE NOTICE '[mig 151] dup % (%) -> canonical % (%) — sr_count=%',
rec.duplicate_id, rec.duplicate_code,
rec.canonical_id, rec.canonical_code,
rec.sr_count;
RAISE NOTICE '[mig 151] name: %', rec.name;
END LOOP;
END $$;
-- ----------------------------------------------------------------
-- 4. Reparent sequencing_rules.procedural_event_id duplicate → canonical.
-- sequencing_rules_pe_proc_lifecycle_idx is non-unique, so collapsing
-- multiple sr onto one PE is by design.
-- ----------------------------------------------------------------
UPDATE paliad.sequencing_rules sr
SET procedural_event_id = d.canonical_id,
updated_at = now()
FROM tmp_pe_dedupe d
WHERE sr.procedural_event_id = d.duplicate_id;
-- ----------------------------------------------------------------
-- 5. Archive the duplicates. No deletion — audit trail preserved.
-- ----------------------------------------------------------------
UPDATE paliad.procedural_events pe
SET is_active = false,
lifecycle_state = 'archived',
updated_at = now()
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
-- ----------------------------------------------------------------
-- 6. POST assertions. Any failure rolls the migration back.
-- ----------------------------------------------------------------
DO $$
DECLARE
v_surviving_groups int;
v_expected_count int;
v_archived_count int;
v_orphan_sr int;
BEGIN
-- (a) Acceptance criterion 2: no name-group still has >1 active+
-- published null.* row.
SELECT COUNT(*) INTO v_surviving_groups
FROM (
SELECT name
FROM paliad.procedural_events
WHERE code LIKE 'null.%'
AND is_active = true
AND lifecycle_state = 'published'
GROUP BY name
HAVING COUNT(*) > 1
) s;
IF v_surviving_groups > 0 THEN
RAISE EXCEPTION
'[mig 151] FAILED POST: % name-groups still have >1 active+published null.* rows',
v_surviving_groups;
END IF;
-- (b) Every targeted duplicate is now archived.
SELECT COUNT(*) INTO v_expected_count FROM tmp_pe_dedupe;
SELECT COUNT(*) INTO v_archived_count
FROM paliad.procedural_events pe
WHERE pe.id IN (SELECT duplicate_id FROM tmp_pe_dedupe)
AND pe.is_active = false
AND pe.lifecycle_state = 'archived';
IF v_archived_count <> v_expected_count THEN
RAISE EXCEPTION
'[mig 151] FAILED POST: archived %/% duplicates',
v_archived_count, v_expected_count;
END IF;
-- (c) Acceptance criterion 4: no sequencing_rule still points at
-- an archived duplicate.
SELECT COUNT(*) INTO v_orphan_sr
FROM paliad.sequencing_rules sr
WHERE sr.procedural_event_id IN (SELECT duplicate_id FROM tmp_pe_dedupe);
IF v_orphan_sr > 0 THEN
RAISE EXCEPTION
'[mig 151] FAILED POST: % sequencing_rules still point at archived PE duplicates',
v_orphan_sr;
END IF;
RAISE NOTICE '[mig 151] OK — archived % duplicates across % name-groups; 0 orphan sequencing_rules',
v_archived_count,
(SELECT COUNT(DISTINCT name) FROM tmp_pe_dedupe);
END $$;

View File

@@ -0,0 +1,17 @@
-- 152_dedupe_identical_sequencing_rule_clones (down) — t-paliad-321
--
-- Best-effort revert from paliad.sequencing_rules_pre_152. Flips the
-- archived rows back to is_active=true / lifecycle_state='published'.
-- Does NOT undo the deadlines.sequencing_rule_id reparent — that would
-- require remembering the previous pointer per row, which the snapshot
-- on sequencing_rules doesn't carry. In live data the reparent was a
-- no-op (zero deadlines pointed at duplicates), so this is fine.
UPDATE paliad.sequencing_rules sr
SET is_active = true,
lifecycle_state = 'published',
updated_at = now()
FROM paliad.sequencing_rules_pre_152 snap
WHERE sr.id = snap.id;
DROP TABLE IF EXISTS paliad.sequencing_rules_pre_152;

View File

@@ -0,0 +1,240 @@
-- 152_dedupe_identical_sequencing_rule_clones — t-paliad-321 / m/paliad#144 follow-up
--
-- Purpose: mig 151 archived 5 of 6 duplicate procedural_events for
-- "Mängelbeseitigung / Zahlung" and reparented their sequencing_rules
-- onto the canonical PE. The 6 sequencing_rules themselves remained
-- active. Because every one of them is a byte-for-byte clone (same
-- proceeding_type_id=NULL, rule_code=NULL, duration 14d, primary_party=NULL,
-- everything else NULL, lifecycle_state='published') and only sequence_order
-- differs, the admin shows six indistinguishable rows for one legal
-- concept. This mig archives 5 of the 6 keeping the lexicographically
-- lowest UUID as canonical.
--
-- Scope verified live before write (Supabase MCP, 2026-05-26):
-- * Exactly 1 clone-group surfaces by the full-signature query
-- below: 6 "Mängelbeseitigung / Zahlung" sequencing_rules with
-- all-NULL discriminators and (duration_value=14, duration_unit='days').
-- * 0 paliad.deadlines reference the 5 to-be-archived rows
-- (verified via deadlines.sequencing_rule_id JOIN; the column
-- formerly named deadlines.rule_id was dropped in mig 140 / B.4).
-- * Other name-groups in the live corpus — "Antrag auf
-- Patentänderung"×4, "Beginn des Hauptsacheverfahrens"×2,
-- "Berufungsbegründung-R.220.1"×2, "Berufungsschrift-R.220.1"×2 —
-- do NOT collapse under this signature because their
-- proceeding_type_id / rule_code / duration / primary_party
-- differ. They are legitimately distinct rules per proceeding;
-- this mig leaves them alone.
--
-- Hard constraints honoured (mirrors mig 151):
-- * No deletions. Archived rows flip to is_active=false +
-- lifecycle_state='archived'. Rows stay in the table for audit.
-- * Reparent paliad.deadlines.sequencing_rule_id duplicate →
-- canonical BEFORE archiving, so no live deadline keeps pointing
-- at an archived sequencing_rule. (deadlines.rule_id column
-- dropped in mig 140; the back-link lives on sequencing_rule_id
-- now — same UUID semantics.)
-- * Snapshot the affected rows into paliad.sequencing_rules_pre_152
-- in the same TX, mirroring precedent (migs 091/093/095/098/140/151).
-- * set_config('paliad.audit_reason') is defensively called even
-- though no audit trigger fires on sequencing_rules today (mig 151
-- §comments documented this). Future audit trigger would inherit
-- the reason automatically.
--
-- Generic-shape rationale: the audit query below uses the FULL
-- signature paliadin specified — procedural_event_id, proceeding_type_id,
-- rule_code, duration_value, duration_unit, primary_party, condition_expr,
-- trigger_event_id, alt_*, anchor_alt, combine_op, parent_id, is_spawn,
-- spawn_*. A NOTICE surfaces every group BEFORE the archive step so an
-- operator running the deploy logs sees what's about to be touched.
-- If new groups appear after future seeds, this mig is safe to re-run
-- conceptually (it would archive any new clones) but only fires once
-- via the applied_migrations protocol.
-- ----------------------------------------------------------------
-- 1. Build the dedupe mapping (duplicate_id → canonical_id) into a
-- TEMP table used by every subsequent step.
-- ----------------------------------------------------------------
CREATE TEMP TABLE tmp_sr_dedupe ON COMMIT DROP AS
WITH ranked AS (
SELECT
id, procedural_event_id, proceeding_type_id, rule_code,
duration_value, duration_unit, primary_party,
condition_expr, trigger_event_id, alt_duration_value,
alt_duration_unit, alt_rule_code, anchor_alt, combine_op,
parent_id, is_spawn, spawn_label, spawn_proceeding_type_id,
created_at,
ROW_NUMBER() OVER (
PARTITION BY
procedural_event_id, proceeding_type_id, rule_code,
duration_value, duration_unit, primary_party,
condition_expr::text, trigger_event_id,
alt_duration_value, alt_duration_unit, alt_rule_code,
anchor_alt, combine_op, parent_id, is_spawn, spawn_label,
spawn_proceeding_type_id
ORDER BY created_at, id::text
) AS rn,
COUNT(*) OVER (
PARTITION BY
procedural_event_id, proceeding_type_id, rule_code,
duration_value, duration_unit, primary_party,
condition_expr::text, trigger_event_id,
alt_duration_value, alt_duration_unit, alt_rule_code,
anchor_alt, combine_op, parent_id, is_spawn, spawn_label,
spawn_proceeding_type_id
) AS grp_size
FROM paliad.sequencing_rules
WHERE is_active = true
AND lifecycle_state = 'published'
)
SELECT
r.id AS duplicate_id,
canon.id AS canonical_id,
r.procedural_event_id,
(SELECT name FROM paliad.procedural_events WHERE id = r.procedural_event_id) AS pe_name
FROM ranked r
JOIN ranked canon
ON canon.procedural_event_id IS NOT DISTINCT FROM r.procedural_event_id
AND canon.proceeding_type_id IS NOT DISTINCT FROM r.proceeding_type_id
AND canon.rule_code IS NOT DISTINCT FROM r.rule_code
AND canon.duration_value IS NOT DISTINCT FROM r.duration_value
AND canon.duration_unit IS NOT DISTINCT FROM r.duration_unit
AND canon.primary_party IS NOT DISTINCT FROM r.primary_party
AND canon.condition_expr::text IS NOT DISTINCT FROM r.condition_expr::text
AND canon.trigger_event_id IS NOT DISTINCT FROM r.trigger_event_id
AND canon.alt_duration_value IS NOT DISTINCT FROM r.alt_duration_value
AND canon.alt_duration_unit IS NOT DISTINCT FROM r.alt_duration_unit
AND canon.alt_rule_code IS NOT DISTINCT FROM r.alt_rule_code
AND canon.anchor_alt IS NOT DISTINCT FROM r.anchor_alt
AND canon.combine_op IS NOT DISTINCT FROM r.combine_op
AND canon.parent_id IS NOT DISTINCT FROM r.parent_id
AND canon.is_spawn IS NOT DISTINCT FROM r.is_spawn
AND canon.spawn_label IS NOT DISTINCT FROM r.spawn_label
AND canon.spawn_proceeding_type_id IS NOT DISTINCT FROM r.spawn_proceeding_type_id
AND canon.rn = 1
WHERE r.rn > 1 AND r.grp_size > 1;
-- ----------------------------------------------------------------
-- 2. Surface every clone-group as a NOTICE before archiving.
-- ----------------------------------------------------------------
DO $$
DECLARE
rec record;
total_to_archive int;
BEGIN
SELECT COUNT(*) INTO total_to_archive FROM tmp_sr_dedupe;
RAISE NOTICE '[mig 152] PRE: % sequencing_rules row(s) will be archived', total_to_archive;
FOR rec IN
SELECT pe_name, canonical_id, COUNT(*) AS dup_count, array_agg(duplicate_id::text ORDER BY duplicate_id::text) AS dup_ids
FROM tmp_sr_dedupe
GROUP BY pe_name, canonical_id
ORDER BY pe_name
LOOP
RAISE NOTICE '[mig 152] % canonical=% duplicates=% ids=%',
rec.pe_name, rec.canonical_id, rec.dup_count, rec.dup_ids;
END LOOP;
END $$;
-- ----------------------------------------------------------------
-- 3. Snapshot the rows about to be archived (only the duplicates;
-- the canonicals stay in the live table). Matches precedent.
-- ----------------------------------------------------------------
CREATE TABLE paliad.sequencing_rules_pre_152 AS
SELECT sr.*
FROM paliad.sequencing_rules sr
JOIN tmp_sr_dedupe d ON d.duplicate_id = sr.id;
COMMENT ON TABLE paliad.sequencing_rules_pre_152 IS
'Snapshot of paliad.sequencing_rules rows archived by mig 152 '
'(identical clones — Mängelbeseitigung / Zahlung × 5). Mirrors '
'precedent pre_091/093/095/098/140/151. Read-only revert source. '
't-paliad-321 / m/paliad#144 follow-up.';
-- ----------------------------------------------------------------
-- 4. Reparent paliad.deadlines.sequencing_rule_id duplicate → canonical
-- BEFORE archiving. Today's live data has 0 deadlines pointing at
-- any duplicate, but the statement is safe + defensive against a
-- race between drift-check and apply.
-- ----------------------------------------------------------------
UPDATE paliad.deadlines d
SET sequencing_rule_id = m.canonical_id,
procedural_event_id = (SELECT procedural_event_id
FROM paliad.sequencing_rules
WHERE id = m.canonical_id),
updated_at = now()
FROM tmp_sr_dedupe m
WHERE d.sequencing_rule_id = m.duplicate_id;
-- ----------------------------------------------------------------
-- 5. Defensive audit-reason. Sequencing_rules has no audit trigger
-- today (mig 151 §scope verified), but set_config is transactional
-- and a future audit trigger inherits the reason automatically.
-- ----------------------------------------------------------------
SELECT set_config('paliad.audit_reason',
'mig 152: archive identical sequencing_rule clones (mig 151 follow-up; t-paliad-321)',
true);
-- ----------------------------------------------------------------
-- 6. Archive the duplicates.
-- ----------------------------------------------------------------
UPDATE paliad.sequencing_rules
SET is_active = false,
lifecycle_state = 'archived',
updated_at = now()
WHERE id IN (SELECT duplicate_id FROM tmp_sr_dedupe);
-- ----------------------------------------------------------------
-- 7. POST assertions.
-- ----------------------------------------------------------------
DO $$
DECLARE
v_archived int;
v_remaining_dupes int;
v_orphan_deadlines int;
BEGIN
-- a. Did the expected number of rows get archived?
SELECT COUNT(*) INTO v_archived
FROM paliad.sequencing_rules
WHERE id IN (SELECT duplicate_id FROM tmp_sr_dedupe)
AND lifecycle_state = 'archived'
AND is_active = false;
IF v_archived <> (SELECT COUNT(*) FROM tmp_sr_dedupe) THEN
RAISE EXCEPTION '[mig 152] FAILED POST: expected % rows archived, got %',
(SELECT COUNT(*) FROM tmp_sr_dedupe), v_archived;
END IF;
-- b. No clone group of size > 1 should remain in active+published.
SELECT COUNT(*) INTO v_remaining_dupes FROM (
SELECT 1
FROM paliad.sequencing_rules
WHERE is_active = true AND lifecycle_state = 'published'
GROUP BY procedural_event_id, proceeding_type_id, rule_code,
duration_value, duration_unit, primary_party,
condition_expr::text, trigger_event_id,
alt_duration_value, alt_duration_unit, alt_rule_code,
anchor_alt, combine_op, parent_id, is_spawn, spawn_label,
spawn_proceeding_type_id
HAVING COUNT(*) > 1
) g;
IF v_remaining_dupes > 0 THEN
RAISE EXCEPTION '[mig 152] FAILED POST: % clone group(s) still active+published after archive', v_remaining_dupes;
END IF;
-- c. No deadline points at an archived sequencing_rule.
SELECT COUNT(*) INTO v_orphan_deadlines
FROM paliad.deadlines d
JOIN paliad.sequencing_rules sr ON sr.id = d.sequencing_rule_id
WHERE sr.lifecycle_state = 'archived';
IF v_orphan_deadlines > 0 THEN
RAISE EXCEPTION '[mig 152] FAILED POST: % live deadline(s) still point at an archived sequencing_rule', v_orphan_deadlines;
END IF;
RAISE NOTICE '[mig 152] OK — archived=%, remaining clone groups=0, orphan deadlines=0',
v_archived;
END $$;

View File

@@ -0,0 +1,53 @@
-- 153_proceeding_types_kind.down — t-paliad-325 / m/paliad#147
--
-- Best-effort rollback of mig 153. Restores the pre-mig state of
-- paliad.proceeding_types from the same-TX snapshot, drops the kind
-- column, drops the backstop trigger.
BEGIN;
SELECT set_config(
'paliad.audit_reason',
'mig 153 down: revert proceeding_types kind discriminator',
true
);
-- ----------------------------------------------------------------
-- 1. Drop the backstop trigger + function.
-- ----------------------------------------------------------------
DROP TRIGGER IF EXISTS projects_proceeding_type_kind_check
ON paliad.projects;
DROP FUNCTION IF EXISTS paliad.projects_proceeding_type_kind_check();
-- ----------------------------------------------------------------
-- 2. Restore is_active flags from the snapshot. We only touch rows
-- whose is_active value diverged from the snapshot — i.e. the 23
-- rows that mig 153 §4 deactivated.
-- ----------------------------------------------------------------
UPDATE paliad.proceeding_types pt
SET is_active = pre.is_active
FROM paliad.proceeding_types_pre_153 pre
WHERE pt.id = pre.id
AND pt.is_active IS DISTINCT FROM pre.is_active;
-- ----------------------------------------------------------------
-- 3. Drop the kind column (cascades the index).
-- ----------------------------------------------------------------
DROP INDEX IF EXISTS paliad.proceeding_types_kind_active_idx;
ALTER TABLE paliad.proceeding_types
DROP COLUMN IF EXISTS kind;
-- ----------------------------------------------------------------
-- 4. Drop the snapshot table.
-- (The CHECK constraint on the kind column is dropped implicitly
-- when the column is dropped.)
-- ----------------------------------------------------------------
DROP TABLE IF EXISTS paliad.proceeding_types_pre_153;
COMMIT;

View File

@@ -0,0 +1,201 @@
-- 153_proceeding_types_kind — t-paliad-325 / m/paliad#147
--
-- Purpose: tag every paliad.proceeding_types row with a structural
-- classification so the Mode B R3 wizard (Fristenrechner overhaul,
-- m/paliad#146), the projects.proceeding_type_id binding, and the
-- pkg/litigationplanner snapshot can filter to primary proceedings
-- only — separating self-contained matters from CFI phases,
-- in-proceeding side-actions, and cross-cutting RoP/admin rows.
--
-- Design: docs/design-proceeding-types-taxonomy-2026-05-26.md
-- §0§10 (m ratified 2026-05-27 09:52 via 11-question AskUserQuestion
-- batch; "proceed, sure" greenlight at 09:57).
--
-- This mig is purely additive: ALTER TABLE adds the kind column with
-- a safe DEFAULT, UPDATEs reclassify the 23 non-primary rows, and a
-- BEFORE INSERT/UPDATE trigger backstops the new
-- "projects.proceeding_type_id must point at kind='proceeding'"
-- invariant. The 23 rows being reclassified have zero downstream
-- consumers today (0 active sequencing_rules anchor, 0 spawn, 0
-- projects bind, 0 event_category_concepts reference) so no FK
-- reparenting is needed — verified via Supabase MCP 2026-05-27
-- before write.
--
-- Hard constraints honoured (mirrors precedent migs 091/093/095/098/
-- 140/151/152):
-- * No deletions. Non-primary rows flip is_active=false but stay in
-- the table for audit + future re-activation.
-- * Snapshot the affected proceeding_types into
-- paliad.proceeding_types_pre_153 in the same TX.
-- * set_config('paliad.audit_reason') is defensively called even
-- though no audit trigger fires on proceeding_types today; a
-- future audit trigger would inherit the reason automatically.
-- * Idempotent on re-apply — the ADD COLUMN uses IF NOT EXISTS
-- semantics through golang-migrate's tracker (mig only fires
-- once); the UPDATEs only touch rows that match the explicit ID
-- list from the ratified design §3.2 / §10.2.
BEGIN;
SELECT set_config(
'paliad.audit_reason',
'mig 153: proceeding_types kind discriminator (m/paliad#147)',
true
);
-- ----------------------------------------------------------------
-- 1. Snapshot the pre-mig state for audit + rollback safety.
-- Mirrors precedent: sequencing_rules_pre_151/_pre_152,
-- procedural_events_pre_151.
-- ----------------------------------------------------------------
CREATE TABLE paliad.proceeding_types_pre_153 AS
SELECT * FROM paliad.proceeding_types;
COMMENT ON TABLE paliad.proceeding_types_pre_153 IS
'Snapshot of paliad.proceeding_types taken in the same TX as '
'mig 153 (kind discriminator). Audit + rollback safety per the '
'precedent set by migs 091/093/095/098/140/151/152. Drop only '
'when the kind taxonomy has held in prod for at least one '
'release cycle and no rollback is anticipated.';
-- ----------------------------------------------------------------
-- 2. Add the kind column.
-- ----------------------------------------------------------------
ALTER TABLE paliad.proceeding_types
ADD COLUMN kind text NOT NULL DEFAULT 'proceeding'
CHECK (kind IN ('proceeding', 'phase', 'side_action', 'meta'));
COMMENT ON COLUMN paliad.proceeding_types.kind IS
'Structural classification — see docs/design-proceeding-types-taxonomy-2026-05-26.md §1. '
'proceeding = self-contained matter (own filing + deadline tree); '
'phase = stage inside a primary CFI proceeding; '
'side_action = application/order inside a proceeding; '
'meta = RoP mechanics, court admin, cross-cutting remedies.';
CREATE INDEX proceeding_types_kind_active_idx
ON paliad.proceeding_types(kind, is_active)
WHERE is_active = true;
-- ----------------------------------------------------------------
-- 3. Reclassify the 23 non-primary rows.
-- IDs per ratified design §3.2 / §10.2. m's Q2 carve-out keeps
-- upc.costs.cfi (176) as kind='proceeding' (defaults to that);
-- Q3.b keeps upc.pl.cfi (188) as kind='proceeding' (defaults).
-- ----------------------------------------------------------------
-- 3.1 Phases: 4 rows (Q2 carve-out drops upc.costs.cfi from the original 5).
UPDATE paliad.proceeding_types
SET kind = 'phase'
WHERE id IN (173, 174, 175, 185);
-- 3.2 Side-actions: 10 rows (§0.4 Group C).
UPDATE paliad.proceeding_types
SET kind = 'side_action'
WHERE id IN (178, 182, 177, 184, 165, 170, 180, 181, 187, 183);
-- 3.3 Meta / cross-cutting: 9 rows (§0.4 Group D incl. upc.reestablishment.rop).
UPDATE paliad.proceeding_types
SET kind = 'meta'
WHERE id IN (162, 161, 163, 168, 164, 166, 167, 186, 169);
-- 3.4 Defensive integrity check — every reclassified ID must have been
-- reached. If the live table drifted between design (2026-05-26)
-- and apply, this raises before the trigger ships.
DO $$
DECLARE
expected int := 23;
actual int;
BEGIN
SELECT COUNT(*) INTO actual
FROM paliad.proceeding_types
WHERE kind <> 'proceeding';
IF actual <> expected THEN
RAISE EXCEPTION
'[mig 153] expected % rows reclassified to non-proceeding kind, found % — '
'live IDs drifted from the design. Abort.',
expected, actual;
END IF;
RAISE NOTICE '[mig 153] reclassified % rows: 4 phase + 10 side_action + 9 meta', actual;
END $$;
-- ----------------------------------------------------------------
-- 4. Per m's Q9: deactivate the non-primary rows so the admin list
-- surfaces only primaries. The kind column carries the semantic
-- info; is_active controls UI visibility. Reversible — flip
-- is_active back on if a row gains corpus.
-- ----------------------------------------------------------------
UPDATE paliad.proceeding_types
SET is_active = false
WHERE kind IN ('phase', 'side_action', 'meta');
-- ----------------------------------------------------------------
-- 5. Backstop trigger on projects.proceeding_type_id (§3.3 + Q8).
-- Complements mig 088's category check; rejects any
-- INSERT/UPDATE that would bind a project to a non-proceeding
-- kind. Independent from the category trigger so each invariant
-- can be dropped in isolation.
-- ----------------------------------------------------------------
CREATE OR REPLACE FUNCTION paliad.projects_proceeding_type_kind_check()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
v_kind text;
BEGIN
IF NEW.proceeding_type_id IS NULL THEN
RETURN NEW;
END IF;
SELECT kind INTO v_kind
FROM paliad.proceeding_types
WHERE id = NEW.proceeding_type_id;
IF v_kind IS NULL THEN
-- FK should have caught this; defensive for any future FK relax.
RAISE EXCEPTION
'paliad.projects.proceeding_type_id = % does not resolve to a '
'proceeding_types row — FK constraint should have caught this.',
NEW.proceeding_type_id;
END IF;
IF v_kind <> 'proceeding' THEN
RAISE EXCEPTION
'paliad.projects.proceeding_type_id must reference a kind=''proceeding'' '
'proceeding_types row (got kind=''%''). '
'Verfahrenstyp muss ein primäres Verfahren sein (kind=''%''). '
'Phasen, Nebenanträge und RoP-Querschnittsregeln sind keine '
'wählbaren Projekt-Verfahrenstypen.',
v_kind, v_kind
USING ERRCODE = '23514';
END IF;
RETURN NEW;
END;
$$;
COMMENT ON FUNCTION paliad.projects_proceeding_type_kind_check() IS
'BEFORE INSERT/UPDATE trigger function enforcing the mig 153 '
'invariant: paliad.projects.proceeding_type_id may only '
'reference kind=''proceeding'' proceeding_types rows. NULL is '
'allowed. Complements mig 088''s category check.';
DROP TRIGGER IF EXISTS projects_proceeding_type_kind_check
ON paliad.projects;
CREATE TRIGGER projects_proceeding_type_kind_check
BEFORE INSERT OR UPDATE OF proceeding_type_id ON paliad.projects
FOR EACH ROW
EXECUTE FUNCTION paliad.projects_proceeding_type_kind_check();
COMMENT ON TRIGGER projects_proceeding_type_kind_check ON paliad.projects IS
'mig 153 (t-paliad-325 / m/paliad#147) runtime guard — rejects '
'any INSERT/UPDATE that would bind a project to a phase/'
'side_action/meta proceeding_types row. The Go service layer '
'also enforces this with a typed error; this trigger is the '
'defence-in-depth backstop.';
COMMIT;

View File

@@ -41,14 +41,22 @@ import (
// historical `submission_code` + `event_type` already on Rule's tags.
// The embedded *models.DeadlineRule carries every existing tag through
// json.Marshal unchanged; the wrapper only ADDS the two new keys.
//
// ProceedingTypeCode (t-paliad-321) is the joined paliad.proceeding_types.code
// for the row's proceeding_type_id. NULL on event-rooted rules. Lets the
// /admin/procedural-events list disambiguate same-named rules at a glance
// (e.g. "Berufungsbegründung" rows differ only by proceeding code).
type adminRuleResponse struct {
*models.DeadlineRule
Code *string `json:"code,omitempty"`
EventKind *string `json:"event_kind,omitempty"`
Code *string `json:"code,omitempty"`
EventKind *string `json:"event_kind,omitempty"`
ProceedingTypeCode *string `json:"proceeding_type_code,omitempty"`
}
// wrapRuleResponse builds the dual-emit wrapper from a service result.
// Same values, two keys per concept — no semantic change.
// Same values, two keys per concept — no semantic change. Pass a non-nil
// ptCode to populate the proceeding_type_code field; nil leaves it
// absent (e.g. on event-rooted rules with NULL proceeding_type_id).
func wrapRuleResponse(r *models.DeadlineRule) adminRuleResponse {
if r == nil {
return adminRuleResponse{}
@@ -61,11 +69,20 @@ func wrapRuleResponse(r *models.DeadlineRule) adminRuleResponse {
}
// wrapRuleListResponse maps a slice of service results into the
// dual-emit wrapper. Used by the LIST endpoint.
func wrapRuleListResponse(rows []models.DeadlineRule) []adminRuleResponse {
// dual-emit wrapper. Used by the LIST endpoint. ptCodes is an
// optional id → code lookup populated by handleAdminListRules from a
// single batch query against paliad.proceeding_types; nil leaves
// every row's proceeding_type_code empty (the LIST endpoint always
// passes a populated map; other callers don't need it).
func wrapRuleListResponse(rows []models.DeadlineRule, ptCodes map[int]string) []adminRuleResponse {
out := make([]adminRuleResponse, len(rows))
for i := range rows {
out[i] = wrapRuleResponse(&rows[i])
if ptCodes != nil && rows[i].ProceedingTypeID != nil {
if code, ok := ptCodes[*rows[i].ProceedingTypeID]; ok {
out[i].ProceedingTypeCode = &code
}
}
}
return out
}
@@ -128,8 +145,16 @@ func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
// t-paliad-321: batch-fetch proceeding_type.code for every rule
// row that carries a non-NULL proceeding_type_id, so the LIST
// response can show a Proceeding column without an N+1 join.
ptCodes, err := dbSvc.ruleEditor.LoadProceedingTypeCodes(r.Context(), rows)
if err != nil {
writeRuleEditorError(w, err)
return
}
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleListResponse(rows))
writeJSON(w, http.StatusOK, wrapRuleListResponse(rows, ptCodes))
}
// GET /admin/api/rules/{id}

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/google/uuid"
@@ -204,6 +205,15 @@ func handleFristenrechnerCalculateRule(w http.ResponseWriter, r *http.Request) {
// Returns 503 with an empty array when DATABASE_URL is unset so the page
// still renders (buttons are server-rendered from tsx and don't depend on
// this endpoint for existence, only for dynamic list updates).
//
// Optional query params (Fristenrechner overhaul S3, m/paliad#146):
// jurisdiction - "UPC" | "DE" | "EPA" | "DPMA". Narrows the chip
// pool to one jurisdiction. Empty = any.
// kind - "proceeding" | "phase" | "side_action" | "meta".
// Narrows to one structural kind from the taxonomy
// cleanup (m/paliad#147, mig 153). Mode A passes
// "proceeding" to exclude phase / side_action / meta
// rows. Empty = any.
func handleProceedingTypes(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.fristenrechner == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
@@ -211,7 +221,12 @@ func handleProceedingTypes(w http.ResponseWriter, r *http.Request) {
})
return
}
types, err := dbSvc.fristenrechner.ListFristenrechnerTypes(r.Context())
opts := services.ProceedingListOptions{
Jurisdiction: strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("jurisdiction"))),
Kind: strings.TrimSpace(r.URL.Query().Get("kind")),
EventKind: strings.TrimSpace(r.URL.Query().Get("event_kind")),
}
types, err := dbSvc.fristenrechner.ListProceedings(r.Context(), opts)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "konnte Verfahrenstypen nicht laden"})
return

View File

@@ -1,31 +0,0 @@
package handlers
import (
"net/http"
)
// GET /api/tools/fristenrechner/event-categories — returns the full
// decision-tree taxonomy for the v3 Pathway B / B1 cascade UI
// (t-paliad-133). Tree is small (~100 nodes) and mostly static; the
// frontend ETag-caches it via localStorage.
//
// Returns 503 if the DB-backed services aren't wired (DATABASE_URL
// unset).
func handleFristenrechnerEventCategories(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.eventCategory == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Decision-tree-Taxonomie vorübergehend nicht verfügbar (keine Datenbank).",
})
return
}
tree, err := dbSvc.eventCategory.Tree(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "Decision-tree fehlgeschlagen: " + err.Error(),
})
return
}
writeJSON(w, http.StatusOK, map[string]any{
"tree": tree,
})
}

View File

@@ -0,0 +1,65 @@
package handlers
import (
"errors"
"net/http"
"time"
"mgit.msbls.de/m/paliad/internal/services"
)
// GET /api/tools/fristenrechner/follow-ups — given a trigger event and
// a trigger date, return the immediate follow-up sequencing rules with
// their computed due dates (Fristenrechner overhaul S1, design §6.2).
//
// Query params:
// event - procedural_events.code OR procedural_events.id
// (uuid) OR sequencing_rules.id (uuid). Required.
// trigger_date - YYYY-MM-DD. Defaults to today when omitted, so the
// frontend can show a result preview before the user
// commits a date.
// party - "claimant" | "defendant" | "court" | "both".
// Optional; narrows follow-ups by primary_party
// (claimant/defendant filters keep "both" rules
// visible — they're bilateral procedural moves).
// court_id - paliad.courts.id (uuid); selects the holiday
// calendar for date adjustment. Optional.
func handleFristenrechnerFollowUps(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.fristenrechner == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Fristenrechner ist vorübergehend nicht verfügbar (keine Datenbank).",
})
return
}
q := r.URL.Query()
eventRef := q.Get("event")
if eventRef == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "event ist erforderlich (procedural_events.code oder id)",
})
return
}
triggerDate := q.Get("trigger_date")
if triggerDate == "" {
triggerDate = time.Now().Format("2006-01-02")
}
resp, err := dbSvc.fristenrechner.LookupFollowUps(
r.Context(),
eventRef,
triggerDate,
q.Get("party"),
q.Get("court_id"),
)
if err != nil {
if errors.Is(err, services.ErrUnknownProceduralEvent) {
writeJSON(w, http.StatusNotFound, map[string]string{
"error": "Unbekanntes Ereignis: " + eventRef,
})
return
}
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeJSON(w, http.StatusOK, resp)
}

View File

@@ -32,6 +32,10 @@ import (
// dpma). Trigger pills bypass this filter.
// limit - max cards (default 12, max 30; in browse
// modes default 200, max 500)
// kind - "events" switches to the events-shape
// response (Fristenrechner overhaul S1,
// design §6.1). The default concept-card
// shape is unchanged when kind is empty.
//
// Returns an empty cards array (not 400) when q is empty — that lets
// the frontend boot the search input without a server round-trip.
@@ -42,6 +46,10 @@ func handleFristenrechnerSearch(w http.ResponseWriter, r *http.Request) {
})
return
}
if r.URL.Query().Get("kind") == "events" {
handleFristenrechnerSearchEvents(w, r)
return
}
q := r.URL.Query().Get("q")
opts := services.SearchOptions{
Party: r.URL.Query().Get("party"),
@@ -60,6 +68,35 @@ func handleFristenrechnerSearch(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, resp)
}
// handleFristenrechnerSearchEvents serves the ?kind=events shape of
// /api/tools/fristenrechner/search (overhaul S1, design §6.1). Returns
// one hit per (procedural_event × proceeding_type) tuple, with a
// follow-up count and a trigram similarity score.
//
// Query params (additive to the legacy search params):
// q - free-text search against name / name_en / code
// jurisdiction - "UPC" | "DE" | "EPA" | "DPMA"
// proc - proceeding_type code
// event_kind - "filing" | "hearing" | "decision" | "order"
// party - primary_party of the anchor rule
// limit - max hits (default 50, max 200)
func handleFristenrechnerSearchEvents(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query().Get("q")
opts := services.EventSearchOptions{
Jurisdiction: strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("jurisdiction"))),
ProceedingTypeCode: r.URL.Query().Get("proc"),
EventKind: r.URL.Query().Get("event_kind"),
PrimaryParty: r.URL.Query().Get("party"),
Limit: parseLimit(r.URL.Query().Get("limit")),
}
resp, err := dbSvc.deadlineSearch.SearchEvents(r.Context(), q, opts)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "Ereignis-Suche fehlgeschlagen: " + err.Error()})
return
}
writeJSON(w, http.StatusOK, resp)
}
// parseCSV splits a comma-separated query-string value into a slice of
// trimmed non-empty entries. Empty input → nil.
func parseCSV(raw string) []string {

View File

@@ -307,7 +307,14 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/tools/event-trigger", handleEventTriggerCalculate)
protected.HandleFunc("GET /api/tools/courts", handleCourtsList)
protected.HandleFunc("GET /api/tools/fristenrechner/search", handleFristenrechnerSearch)
protected.HandleFunc("GET /api/tools/fristenrechner/event-categories", handleFristenrechnerEventCategories)
protected.HandleFunc("GET /api/tools/fristenrechner/follow-ups", handleFristenrechnerFollowUps)
// t-paliad-323 Slice S6: the cascade endpoint /api/tools/fristenrechner/
// event-categories is retired — the Fristenrechner overhaul Mode A
// + wizard surfaces don't read the event_categories taxonomy. The
// table itself stays for future tools (design doc §7). The
// EventCategoryService still backs the /search endpoint's legacy
// ?event_category_slug filter; that filter is dead-coded too but
// removing the service is a separate follow-up.
protected.HandleFunc("GET /downloads", handleDownloadsPage)
protected.HandleFunc("GET /glossary", handleGlossaryPage)
protected.HandleFunc("GET /api/glossary", handleGlossaryAPI)
@@ -432,6 +439,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// for inline editor autosave. URL keyed on draft_id + section_id;
// owner-scoped via SubmissionDraftService.Get.
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}/sections/{section_id}", handlePatchSubmissionSection)
// t-paliad-318 (m/paliad#141) Composer Slice F — add custom
// section, delete section, reorder.
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/sections", handleCreateSubmissionSection)
protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}/sections/{section_id}", handleDeleteSubmissionSection)
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/sections/reorder", handleReorderSubmissionSections)
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks
// library. Lawyer-facing picker + paste mechanic.
protected.HandleFunc("GET /api/submission-building-blocks", handleListBuildingBlocks)

View File

@@ -38,6 +38,8 @@ import (
"net/http"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
@@ -130,6 +132,188 @@ func handlePatchSubmissionSection(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, sectionJSONFromService(updated))
}
// ─────────────────────────────────────────────────────────────────────
// Slice F — add custom section / delete section / reorder
// ─────────────────────────────────────────────────────────────────────
type submissionSectionCreateInput struct {
SectionKey string `json:"section_key"`
Kind string `json:"kind"`
LabelDE string `json:"label_de"`
LabelEN string `json:"label_en"`
ContentMDDE string `json:"content_md_de,omitempty"`
ContentMDEN string `json:"content_md_en,omitempty"`
OrderIndex int `json:"order_index,omitempty"`
}
// handleCreateSubmissionSection backs POST /api/submission-drafts/{draft_id}/sections.
// Adds a new (custom) section to the draft. Owner-scoped via
// SubmissionDraftService.Get.
func handleCreateSubmissionSection(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
return
}
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
defer cancel()
if _, err := dbSvc.submissionDraft.Get(ctx, uid, draftID); err != nil {
writeSubmissionDraftServiceError(w, err)
return
}
var input submissionSectionCreateInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
created, err := dbSvc.submissionSection.Create(ctx, services.SectionCreateInput{
DraftID: draftID,
SectionKey: input.SectionKey,
Kind: input.Kind,
LabelDE: input.LabelDE,
LabelEN: input.LabelEN,
ContentMDDE: input.ContentMDDE,
ContentMDEN: input.ContentMDEN,
OrderIndex: input.OrderIndex,
Included: true,
})
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.StatusCreated, sectionJSONFromService(created))
}
// handleDeleteSubmissionSection backs DELETE /api/submission-drafts/{draft_id}/sections/{section_id}.
// Owner-scoped via SubmissionDraftService.Get + section-belongs-to-draft cross-check.
func handleDeleteSubmissionSection(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
return
}
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
if !ok {
return
}
sectionID, ok := parseUUIDPath(w, r, "section_id", "section id")
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
defer cancel()
draft, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
if err != nil {
writeSubmissionDraftServiceError(w, err)
return
}
sec, err := dbSvc.submissionSection.Get(ctx, sectionID)
if err != nil {
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
return
}
writeServiceError(w, err)
return
}
if sec.DraftID != draft.ID {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
return
}
if err := dbSvc.submissionSection.Delete(ctx, sectionID); err != nil {
if errors.Is(err, services.ErrSubmissionSectionNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusNoContent, nil)
}
type submissionSectionReorderInput struct {
SectionOrder []string `json:"section_order"`
}
// handleReorderSubmissionSections backs POST /api/submission-drafts/{draft_id}/sections/reorder.
// Accepts a sequence of section_ids; rewrites every row's order_index
// to (1, 2, 3, …) × 10 in the supplied order. Returns the refreshed
// section list.
func handleReorderSubmissionSections(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionDraft == nil || dbSvc.submissionSection == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission sections not configured"})
return
}
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), submissionSectionPatchTimeout)
defer cancel()
if _, err := dbSvc.submissionDraft.Get(ctx, uid, draftID); err != nil {
writeSubmissionDraftServiceError(w, err)
return
}
var input submissionSectionReorderInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
order := make([]uuid.UUID, 0, len(input.SectionOrder))
for _, raw := range input.SectionOrder {
id, err := uuid.Parse(raw)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid section id in order list"})
return
}
order = append(order, id)
}
rows, err := dbSvc.submissionSection.Reorder(ctx, draftID, order)
if err != nil {
writeServiceError(w, err)
return
}
out := make([]submissionSectionJSON, 0, len(rows))
for _, sec := range rows {
out = append(out, sectionJSONFromService(&sec))
}
writeJSON(w, http.StatusOK, map[string]any{"sections": out})
}
// sectionJSONFromService projects a services.SubmissionSection into the
// JSON shape the editor consumes — the same shape buildSubmissionDraftView
// emits under .sections[].

View File

@@ -82,13 +82,77 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
// specific surface (the wire shape FristenrechnerType is owned by the
// package but the SQL filter is paliad-side).
func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([]lp.FristenrechnerType, error) {
rows, err := s.rules.db.QueryxContext(ctx, `
SELECT code, name, name_en, jurisdiction
FROM paliad.proceeding_types
WHERE category = 'fristenrechner' AND is_active = true
ORDER BY sort_order`)
return s.ListProceedings(ctx, ProceedingListOptions{})
}
// ProceedingListOptions narrows ListProceedings. Empty values = no
// filter on that axis. Added for the Fristenrechner overhaul S3
// (m/paliad#146): Mode A's "Verfahren" filter chip strip needs to scope
// the proceeding pool by the user's Forum pick (jurisdiction) and by
// kind='proceeding' to exclude the phase / side_action / meta rows
// landed in the taxonomy cleanup (m/paliad#147, mig 153).
type ProceedingListOptions struct {
// Jurisdiction narrows to one jurisdiction code (UPC / DE / EPA /
// DPMA). Empty = any.
Jurisdiction string
// Kind narrows to one structural kind (proceeding / phase /
// side_action / meta). Mode A passes "proceeding" to exclude the
// phase / side_action / meta rows from the chip strip. Empty = any.
//
// Filter referenced before mig 153 lands the column → callers
// pre-mig get a "column kind does not exist" error from Postgres.
// Sequenced per docs/design-proceeding-types-taxonomy-2026-05-26.md
// §7 option (c): mig 153 merges to main before the S3 PR ships.
Kind string
// EventKind narrows to proceedings that have at least one published
// sequencing rule anchored on a procedural event of the requested
// kind ("filing" | "hearing" | "decision" | "order"). Powers the
// Fristenrechner overhaul Mode B R3 wizard row (§3.2): after R1
// picks an event_kind, R3 should only chip proceedings whose event
// roster contains at least one event of that kind. Empty = no
// event-kind narrowing.
EventKind string
}
// ListProceedings returns the proceeding_types chips the Fristenrechner
// overhaul Mode A renders in its filter strip. Filters apply
// progressively: pre-mig 153 Kind=="" is the safe default; post-mig 153
// Mode A passes Kind="proceeding" to exclude the phase / side_action /
// meta rows.
func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts ProceedingListOptions) ([]lp.FristenrechnerType, error) {
where := []string{
"category = 'fristenrechner'",
"is_active = true",
}
args := []any{}
add := func(clause string, val any) {
args = append(args, val)
where = append(where, fmt.Sprintf(clause, len(args)))
}
if opts.Jurisdiction != "" {
add("jurisdiction = $%d", opts.Jurisdiction)
}
if opts.Kind != "" {
add("kind = $%d", opts.Kind)
}
if opts.EventKind != "" {
add(`EXISTS (
SELECT 1 FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
WHERE sr.proceeding_type_id = paliad.proceeding_types.id
AND sr.is_active = true AND sr.lifecycle_state = 'published'
AND pe.is_active = true AND pe.lifecycle_state = 'published'
AND pe.event_kind = $%d
)`, opts.EventKind)
}
query := `SELECT code, name, name_en, jurisdiction
FROM paliad.proceeding_types
WHERE ` + strings.Join(where, " AND ") + `
ORDER BY sort_order`
rows, err := s.rules.db.QueryxContext(ctx, query, args...)
if err != nil {
return nil, fmt.Errorf("list fristenrechner types: %w", err)
return nil, fmt.Errorf("list proceedings: %w", err)
}
defer rows.Close()

View File

@@ -0,0 +1,404 @@
package services
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
)
// ErrUnknownProceduralEvent is returned by LookupFollowUps when the
// requested procedural_event cannot be resolved (unknown id / unknown
// code / not active+published). Distinct from ErrUnknownTriggerEvent
// (which lives on the legacy Pipeline C / paliad.trigger_events path).
var ErrUnknownProceduralEvent = errors.New("unknown procedural event")
// FollowUpsResponse is the wire shape for GET
// /api/tools/fristenrechner/follow-ups (Fristenrechner overhaul S1,
// design §6.2). Captures the locked trigger event + every immediate
// follow-up rule with its computed due date.
type FollowUpsResponse struct {
Trigger FollowUpTrigger `json:"trigger"`
TriggerDate string `json:"trigger_date"`
Party *string `json:"party,omitempty"`
FollowUps []FollowUpRule `json:"follow_ups"`
}
// FollowUpTrigger is the locked trigger event identity returned by
// LookupFollowUps.
type FollowUpTrigger struct {
ID uuid.UUID `json:"id"`
Code string `json:"code"`
NameDE string `json:"name_de"`
NameEN string `json:"name_en"`
EventKind *string `json:"event_kind,omitempty"`
ProceedingType EventSearchPT `json:"proceeding_type"`
AnchorRuleID uuid.UUID `json:"anchor_rule_id"`
}
// FollowUpRule is one follow-up deadline returned by LookupFollowUps.
// Carries the rule metadata + the computed due date (or the
// "wird vom Gericht bestimmt" / "abhängig von …" marker for rules whose
// date is undefined).
type FollowUpRule struct {
RuleID uuid.UUID `json:"rule_id"`
EventCode string `json:"event_code"`
TitleDE string `json:"title_de"`
TitleEN string `json:"title_en"`
Priority string `json:"priority"`
PrimaryParty *string `json:"primary_party,omitempty"`
DurationValue *int `json:"duration_value,omitempty"`
DurationUnit *string `json:"duration_unit,omitempty"`
Timing *string `json:"timing,omitempty"`
DueDate string `json:"due_date,omitempty"`
OriginalDueDate string `json:"original_due_date,omitempty"`
WasAdjusted bool `json:"was_adjusted,omitempty"`
IsCourtSet bool `json:"is_court_set"`
IsSpawn bool `json:"is_spawn"`
IsBilateral bool `json:"is_bilateral"`
HasCondition bool `json:"has_condition"`
RuleCode *string `json:"rule_code,omitempty"`
LegalSource *string `json:"legal_source,omitempty"`
LegalSourceDisplay *string `json:"legal_source_display,omitempty"`
LegalSourceURL *string `json:"legal_source_url,omitempty"`
NotesDE *string `json:"notes_de,omitempty"`
NotesEN *string `json:"notes_en,omitempty"`
SpawnLabel *string `json:"spawn_label,omitempty"`
SpawnProceedingCode *string `json:"spawn_proceeding_code,omitempty"`
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
}
// LookupFollowUps returns the follow-up rules anchored on a single
// procedural_event, with computed dates run through the holiday-aware
// litigationplanner.CalculateRule. Identifies the anchor by either the
// procedural_event.id (uuid) or its code; resolves the anchor rule
// (the sequencing_rule with procedural_event_id matching), then walks
// one hop down via parent_id to collect immediate follow-ups.
//
// When party is non-empty, follow-ups are filtered to rules whose
// primary_party matches OR is "both" (so a defendant filter still
// returns bilateral procedural moves like Vertraulichkeitsantrag-
// Erwiderung).
func (s *FristenrechnerService) LookupFollowUps(
ctx context.Context,
eventRef string,
triggerDateStr string,
party string,
courtID string,
) (*FollowUpsResponse, error) {
if eventRef == "" {
return nil, fmt.Errorf("eventRef required")
}
if triggerDateStr == "" {
return nil, fmt.Errorf("triggerDate required")
}
anchor, err := s.resolveTriggerEvent(ctx, eventRef)
if err != nil {
return nil, err
}
resp := &FollowUpsResponse{
Trigger: anchor.Trigger,
TriggerDate: triggerDateStr,
FollowUps: []FollowUpRule{},
}
if party != "" {
p := party
resp.Party = &p
}
// Pull the proceeding_type metadata once so we can pass it
// downstream to populate the trigger card and to seed the
// CalculateRule lookup (which uses RuleID anyway).
rows, err := s.queryFollowUpRows(ctx, anchor.AnchorRuleID, party)
if err != nil {
return nil, err
}
for _, r := range rows {
fr := FollowUpRule{
RuleID: r.RuleID,
EventCode: r.EventCode,
TitleDE: r.NameDE,
TitleEN: r.NameEN,
Priority: r.Priority,
IsCourtSet: r.IsCourtSet,
IsSpawn: r.IsSpawn,
IsBilateral: r.IsBilateral,
HasCondition: r.HasCondition,
}
if r.PrimaryParty.Valid {
v := r.PrimaryParty.String
fr.PrimaryParty = &v
}
if r.DurationValue.Valid {
v := int(r.DurationValue.Int32)
fr.DurationValue = &v
}
if r.DurationUnit.Valid {
v := r.DurationUnit.String
fr.DurationUnit = &v
}
if r.Timing.Valid {
v := r.Timing.String
fr.Timing = &v
}
if r.RuleCode.Valid {
v := r.RuleCode.String
fr.RuleCode = &v
}
if r.LegalSource.Valid {
v := r.LegalSource.String
fr.LegalSource = &v
display := lp.FormatLegalSourceDisplay(v)
if display != "" {
fr.LegalSourceDisplay = &display
}
url := lp.BuildLegalSourceURL(v)
if url != "" {
fr.LegalSourceURL = &url
}
}
if r.NotesDE.Valid {
v := r.NotesDE.String
fr.NotesDE = &v
}
if r.NotesEN.Valid {
v := r.NotesEN.String
fr.NotesEN = &v
}
if r.SpawnLabel.Valid {
v := r.SpawnLabel.String
fr.SpawnLabel = &v
}
if r.SpawnProceedingCode.Valid {
v := r.SpawnProceedingCode.String
fr.SpawnProceedingCode = &v
}
if r.ConceptID != nil {
fr.ConceptID = r.ConceptID
}
// Skip date computation for court-set / spawn rules — they don't
// project a calendar date here.
if !r.IsCourtSet && !r.IsSpawn {
calc, err := s.CalculateRule(ctx, lp.CalcRuleParams{
RuleID: r.RuleID.String(),
TriggerDate: triggerDateStr,
CourtID: courtID,
})
if err == nil {
fr.DueDate = calc.DueDate
fr.OriginalDueDate = calc.OriginalDate
fr.WasAdjusted = calc.WasAdjusted
}
// On error: leave the date fields empty — the frontend
// already handles missing dates as "abhängig von ..." style
// markers and a single bad rule shouldn't 500 the whole
// follow-up list.
}
resp.FollowUps = append(resp.FollowUps, fr)
}
return resp, nil
}
// anchorResolution carries the resolver output: the trigger card metadata
// plus the anchor rule id (the sequencing_rule.id whose
// procedural_event_id equals the trigger event).
type anchorResolution struct {
Trigger FollowUpTrigger
AnchorRuleID uuid.UUID
}
// resolveTriggerEvent looks up the trigger event by either uuid or code.
// Returns ErrUnknownTriggerEvent when no published+active anchor row
// matches.
func (s *FristenrechnerService) resolveTriggerEvent(ctx context.Context, ref string) (*anchorResolution, error) {
// Try uuid first; fall back to code lookup.
type row struct {
EventID uuid.UUID `db:"event_id"`
Code string `db:"code"`
NameDE string `db:"name_de"`
NameEN string `db:"name_en"`
EventKind sql.NullString `db:"event_kind"`
AnchorRuleID uuid.UUID `db:"anchor_rule_id"`
PTID int `db:"pt_id"`
PTCode string `db:"pt_code"`
PTNameDE string `db:"pt_name_de"`
PTNameEN string `db:"pt_name_en"`
PTJurisdiction sql.NullString `db:"pt_jurisdiction"`
}
var r row
queryBase := `
SELECT pe.id AS event_id,
pe.code,
pe.name AS name_de,
pe.name_en,
pe.event_kind,
sr.id AS anchor_rule_id,
pt.id AS pt_id,
pt.code AS pt_code,
pt.name AS pt_name_de,
pt.name_en AS pt_name_en,
pt.jurisdiction AS pt_jurisdiction
FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
WHERE sr.is_active = true
AND sr.lifecycle_state = 'published'
AND pe.is_active = true
AND pe.lifecycle_state = 'published'
AND pt.is_active = true
AND %s
ORDER BY pt.sort_order
LIMIT 1`
if id, err := uuid.Parse(ref); err == nil {
// Treat as a procedural_event id OR a sequencing_rule id (the
// frontend may pass either — search returns event id but a
// concept-card-derived flow may pass the rule id).
err := s.rules.db.GetContext(ctx, &r, fmt.Sprintf(queryBase, "(pe.id = $1 OR sr.id = $1)"), id)
if err == nil {
goto found
}
if !errors.Is(err, sql.ErrNoRows) {
return nil, fmt.Errorf("resolve trigger event by id: %w", err)
}
// fall through to code lookup
}
{
err := s.rules.db.GetContext(ctx, &r, fmt.Sprintf(queryBase, "pe.code = $1"), ref)
if err == nil {
goto found
}
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrUnknownProceduralEvent
}
return nil, fmt.Errorf("resolve trigger event by code: %w", err)
}
found:
res := &anchorResolution{
AnchorRuleID: r.AnchorRuleID,
Trigger: FollowUpTrigger{
ID: r.EventID,
Code: r.Code,
NameDE: r.NameDE,
NameEN: r.NameEN,
AnchorRuleID: r.AnchorRuleID,
ProceedingType: EventSearchPT{
ID: r.PTID,
Code: r.PTCode,
NameDE: r.PTNameDE,
NameEN: r.PTNameEN,
},
},
}
if r.EventKind.Valid {
v := r.EventKind.String
res.Trigger.EventKind = &v
}
if r.PTJurisdiction.Valid {
v := r.PTJurisdiction.String
res.Trigger.ProceedingType.Jurisdiction = &v
}
return res, nil
}
// followUpRow is the joined SELECT shape for follow-up rules.
type followUpRow struct {
RuleID uuid.UUID `db:"rule_id"`
EventCode string `db:"event_code"`
NameDE string `db:"name_de"`
NameEN string `db:"name_en"`
Priority string `db:"priority"`
PrimaryParty sql.NullString `db:"primary_party"`
DurationValue sql.NullInt32 `db:"duration_value"`
DurationUnit sql.NullString `db:"duration_unit"`
Timing sql.NullString `db:"timing"`
IsCourtSet bool `db:"is_court_set"`
IsSpawn bool `db:"is_spawn"`
IsBilateral bool `db:"is_bilateral"`
HasCondition bool `db:"has_condition"`
RuleCode sql.NullString `db:"rule_code"`
LegalSource sql.NullString `db:"legal_source"`
NotesDE sql.NullString `db:"notes_de"`
NotesEN sql.NullString `db:"notes_en"`
SpawnLabel sql.NullString `db:"spawn_label"`
SpawnProceedingCode sql.NullString `db:"spawn_proceeding_code"`
ConceptID *uuid.UUID `db:"concept_id"`
SequenceOrder int `db:"sequence_order"`
}
// queryFollowUpRows pulls the immediate-children rules of an anchor.
// Party filter is inclusive of "both" so bilateral moves stay visible
// when the user picks claimant or defendant.
func (s *FristenrechnerService) queryFollowUpRows(
ctx context.Context,
anchorRuleID uuid.UUID,
party string,
) ([]followUpRow, error) {
where := []string{
"sr.parent_id = $1",
"sr.is_active = true",
"sr.lifecycle_state = 'published'",
"pe.is_active = true",
"pe.lifecycle_state = 'published'",
}
args := []any{anchorRuleID}
if party == "claimant" || party == "defendant" {
args = append(args, party)
where = append(where, fmt.Sprintf(
"(sr.primary_party = $%d OR sr.primary_party = 'both' OR sr.primary_party IS NULL)",
len(args)))
} else if party != "" {
// "court" / "both" — exact match
args = append(args, party)
where = append(where, fmt.Sprintf("sr.primary_party = $%d", len(args)))
}
query := `
SELECT sr.id AS rule_id,
pe.code AS event_code,
pe.name AS name_de,
pe.name_en,
sr.priority,
sr.primary_party,
sr.duration_value,
sr.duration_unit,
sr.timing,
sr.is_court_set,
sr.is_spawn,
sr.is_bilateral,
(sr.condition_expr IS NOT NULL) AS has_condition,
sr.rule_code,
ls.citation AS legal_source,
sr.deadline_notes AS notes_de,
sr.deadline_notes_en AS notes_en,
sr.spawn_label,
spt.code AS spawn_proceeding_code,
pe.concept_id,
sr.sequence_order
FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
LEFT JOIN paliad.legal_sources ls ON ls.id = pe.legal_source_id
LEFT JOIN paliad.proceeding_types spt ON spt.id = sr.spawn_proceeding_type_id
WHERE ` + strings.Join(where, "\n AND ") + `
ORDER BY sr.sequence_order, pe.code`
var rows []followUpRow
if err := s.rules.db.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, fmt.Errorf("load follow-up rows: %w", err)
}
return rows, nil
}

View File

@@ -0,0 +1,205 @@
package services
import (
"context"
"os"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// TestSearchEvents covers the ?kind=events response shape for the
// Fristenrechner overhaul S1 (design §6.1). Verified against live data:
// "Klageerhebung" must return upc.inf.cfi.soc (the canonical SoC
// procedural event) as the top hit, with the proceeding metadata
// populated and a non-zero follow_up_count.
//
// Skipped when TEST_DATABASE_URL is unset, mirroring the other live-DB
// tests in this package.
func TestSearchEvents(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
svc := NewDeadlineSearchService(pool)
t.Run("Klageerhebung returns upc.inf.cfi.soc with follow-ups", func(t *testing.T) {
resp, err := svc.SearchEvents(ctx, "Klageerhebung", EventSearchOptions{Limit: 30})
if err != nil {
t.Fatalf("search events: %v", err)
}
if len(resp.Events) == 0 {
t.Fatalf("no events returned for Klageerhebung")
}
var soc *EventSearchHit
for i := range resp.Events {
if resp.Events[i].Code == "upc.inf.cfi.soc" {
soc = &resp.Events[i]
break
}
}
if soc == nil {
t.Fatalf("upc.inf.cfi.soc not in event hits (got %d hits)", len(resp.Events))
}
if soc.NameDE == "" {
t.Errorf("expected name_de populated, got empty")
}
if soc.ProceedingType.Code != "upc.inf.cfi" {
t.Errorf("expected proceeding upc.inf.cfi, got %q", soc.ProceedingType.Code)
}
if soc.FollowUpCount <= 0 {
t.Errorf("expected follow_up_count > 0 for SoC, got %d", soc.FollowUpCount)
}
if soc.EventKind == nil || *soc.EventKind != "filing" {
gotKind := "<nil>"
if soc.EventKind != nil {
gotKind = *soc.EventKind
}
t.Errorf("expected event_kind=filing, got %q", gotKind)
}
})
t.Run("jurisdiction filter narrows to UPC", func(t *testing.T) {
resp, err := svc.SearchEvents(ctx, "", EventSearchOptions{
Jurisdiction: "UPC",
Limit: 200,
})
if err != nil {
t.Fatalf("search events UPC: %v", err)
}
if len(resp.Events) == 0 {
t.Fatalf("expected UPC events, got 0")
}
for _, e := range resp.Events {
if e.ProceedingType.Jurisdiction == nil || *e.ProceedingType.Jurisdiction != "UPC" {
gotJ := "<nil>"
if e.ProceedingType.Jurisdiction != nil {
gotJ = *e.ProceedingType.Jurisdiction
}
t.Errorf("non-UPC event leaked: %s (jurisdiction=%q)", e.Code, gotJ)
}
}
})
t.Run("event_kind=filing narrows by kind", func(t *testing.T) {
resp, err := svc.SearchEvents(ctx, "", EventSearchOptions{
EventKind: "filing",
Limit: 200,
})
if err != nil {
t.Fatalf("search events filing: %v", err)
}
if len(resp.Events) == 0 {
t.Fatalf("expected filing events, got 0")
}
for _, e := range resp.Events {
if e.EventKind == nil || *e.EventKind != "filing" {
gotKind := "<nil>"
if e.EventKind != nil {
gotKind = *e.EventKind
}
t.Errorf("non-filing event leaked: %s (event_kind=%q)", e.Code, gotKind)
}
}
})
}
// TestLookupFollowUps covers the GET /api/tools/fristenrechner/follow-ups
// endpoint contract (overhaul S1, design §6.2). Verified against live
// data: looking up upc.inf.cfi.soc returns the four canonical follow-up
// rules (Klageerwiderung, CCR, Einspruch, Vertraulichkeits-Erwiderung),
// each with a computed due date or court-set marker.
func TestLookupFollowUps(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
holidays := NewHolidayService(pool)
courts := NewCourtService(pool)
rules := NewDeadlineRuleService(pool)
fr := NewFristenrechnerService(rules, holidays, courts)
t.Run("SoC returns follow-ups with computed dates", func(t *testing.T) {
resp, err := fr.LookupFollowUps(ctx, "upc.inf.cfi.soc", "2026-05-20", "", "")
if err != nil {
t.Fatalf("lookup follow-ups: %v", err)
}
if resp.Trigger.Code != "upc.inf.cfi.soc" {
t.Errorf("trigger code = %q, want upc.inf.cfi.soc", resp.Trigger.Code)
}
if len(resp.FollowUps) == 0 {
t.Fatalf("expected follow-ups, got 0")
}
// At least the Klageerwiderung (sod) should be present and have a date.
var sod *FollowUpRule
for i := range resp.FollowUps {
if resp.FollowUps[i].EventCode == "upc.inf.cfi.sod" {
sod = &resp.FollowUps[i]
break
}
}
if sod == nil {
t.Fatalf("Klageerwiderung (upc.inf.cfi.sod) not in follow-ups")
}
if sod.DueDate == "" {
t.Errorf("expected due_date populated for sod, got empty")
}
if sod.Priority != "mandatory" {
t.Errorf("expected priority=mandatory for sod, got %q", sod.Priority)
}
// 3 months after 2026-05-20 (then weekend-adjusted) — sanity check
// only that something resembling 2026-08 came back.
if len(sod.DueDate) < 7 || sod.DueDate[:7] != "2026-08" {
t.Errorf("expected due_date in 2026-08, got %q", sod.DueDate)
}
})
t.Run("party=defendant narrows but keeps bilateral rules", func(t *testing.T) {
resp, err := fr.LookupFollowUps(ctx, "upc.inf.cfi.soc", "2026-05-20", "defendant", "")
if err != nil {
t.Fatalf("lookup follow-ups (defendant): %v", err)
}
if len(resp.FollowUps) == 0 {
t.Fatalf("expected defendant follow-ups, got 0")
}
for _, r := range resp.FollowUps {
if r.PrimaryParty == nil {
continue
}
p := *r.PrimaryParty
if p == "claimant" {
t.Errorf("claimant-only rule leaked under defendant filter: %s", r.EventCode)
}
}
})
t.Run("unknown event returns ErrUnknownProceduralEvent", func(t *testing.T) {
_, err := fr.LookupFollowUps(ctx, "no.such.event", "2026-05-20", "", "")
if err != ErrUnknownProceduralEvent {
t.Errorf("expected ErrUnknownProceduralEvent, got %v", err)
}
})
}

View File

@@ -0,0 +1,156 @@
package services
import (
"context"
"os"
"testing"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
// TestListProceedings covers the proceeding chip-pool query that powers
// the Fristenrechner overhaul Mode A "Verfahren" filter strip (S3,
// design §3.1). The legacy callers go through ListFristenrechnerTypes
// (no filters) — that path stays green here. The new ListProceedings
// API accepts Jurisdiction + Kind filters; the Kind filter requires
// mig 153 to have landed, so this test skips the Kind=proceeding case
// when the column doesn't yet exist.
func TestListProceedings(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
rules := NewDeadlineRuleService(pool)
holidays := NewHolidayService(pool)
courts := NewCourtService(pool)
fr := NewFristenrechnerService(rules, holidays, courts)
t.Run("no filters returns the legacy fristenrechner set", func(t *testing.T) {
got, err := fr.ListProceedings(ctx, ProceedingListOptions{})
if err != nil {
t.Fatalf("list proceedings: %v", err)
}
if len(got) == 0 {
t.Fatalf("expected non-empty proceeding list")
}
// Sanity — upc.inf.cfi should always be in the active set.
found := false
for _, p := range got {
if p.Code == "upc.inf.cfi" {
found = true
break
}
}
if !found {
t.Errorf("upc.inf.cfi not in proceedings list")
}
})
t.Run("jurisdiction=UPC narrows to UPC-only", func(t *testing.T) {
got, err := fr.ListProceedings(ctx, ProceedingListOptions{Jurisdiction: "UPC"})
if err != nil {
t.Fatalf("list proceedings UPC: %v", err)
}
if len(got) == 0 {
t.Fatalf("expected UPC proceedings")
}
for _, p := range got {
if p.Group != "UPC" {
t.Errorf("non-UPC proceeding leaked: %s (group=%q)", p.Code, p.Group)
}
}
})
t.Run("jurisdiction=DE returns DE proceedings", func(t *testing.T) {
got, err := fr.ListProceedings(ctx, ProceedingListOptions{Jurisdiction: "DE"})
if err != nil {
t.Fatalf("list proceedings DE: %v", err)
}
if len(got) == 0 {
t.Fatalf("expected DE proceedings")
}
for _, p := range got {
if p.Group != "DE" {
t.Errorf("non-DE proceeding leaked: %s (group=%q)", p.Code, p.Group)
}
}
})
t.Run("ListFristenrechnerTypes legacy alias still works", func(t *testing.T) {
got, err := fr.ListFristenrechnerTypes(ctx)
if err != nil {
t.Fatalf("list fristenrechner types: %v", err)
}
if len(got) == 0 {
t.Fatalf("expected non-empty types")
}
})
t.Run("kind=proceeding narrows to primary proceedings only", func(t *testing.T) {
got, err := fr.ListProceedings(ctx, ProceedingListOptions{Kind: "proceeding"})
if err != nil {
t.Fatalf("list proceedings kind=proceeding: %v", err)
}
if len(got) == 0 {
t.Fatalf("expected non-empty primary-proceeding list")
}
// upc.inf.cfi is unambiguously a primary proceeding — must
// survive the filter.
found := false
for _, p := range got {
if p.Code == "upc.inf.cfi" {
found = true
break
}
}
if !found {
t.Errorf("upc.inf.cfi missing from kind=proceeding list")
}
// upc.cfi.interim is the canonical phase row (per mig 153 +
// taxonomy doc §0.4 Group B) — must NOT appear.
for _, p := range got {
if p.Code == "upc.cfi.interim" {
t.Errorf("phase row upc.cfi.interim leaked into kind=proceeding")
}
}
})
t.Run("event_kind=filing narrows to proceedings with filing events", func(t *testing.T) {
got, err := fr.ListProceedings(ctx, ProceedingListOptions{
Jurisdiction: "UPC",
Kind: "proceeding",
EventKind: "filing",
})
if err != nil {
t.Fatalf("list proceedings UPC+filing: %v", err)
}
if len(got) == 0 {
t.Fatalf("expected UPC proceedings with filing events")
}
// upc.inf.cfi has at least one rule anchored on a filing event
// (Klageerhebung, SoD, etc.) — must survive.
found := false
for _, p := range got {
if p.Code == "upc.inf.cfi" {
found = true
break
}
}
if !found {
t.Errorf("upc.inf.cfi missing from UPC + event_kind=filing list")
}
})
}

View File

@@ -0,0 +1,257 @@
package services
import (
"context"
"database/sql"
"fmt"
"strings"
"github.com/google/uuid"
)
// EventSearchHit is one ranked hit in the events-shape search response.
// Returned by FristenrechnerService.SearchEvents.
//
// One hit per (procedural_event, proceeding_type) tuple: a single event
// can appear in multiple proceedings (the data carries handful of
// procedural_event rows whose code is null.* and that are anchored by
// rules in different proceedings — those legacy stragglers surface as
// multiple hits, one per proceeding context).
type EventSearchHit struct {
EventID uuid.UUID `json:"id"`
Code string `json:"code"`
NameDE string `json:"name_de"`
NameEN string `json:"name_en"`
EventKind *string `json:"event_kind,omitempty"`
Description *string `json:"description,omitempty"`
PrimaryParty *string `json:"primary_party,omitempty"`
ProceedingType EventSearchPT `json:"proceeding_type"`
AnchorRuleID uuid.UUID `json:"anchor_rule_id"`
FollowUpCount int `json:"follow_up_count"`
ConceptID *uuid.UUID `json:"concept_id,omitempty"`
Score float64 `json:"score"`
}
// EventSearchPT is the proceeding-type slice embedded in an EventSearchHit.
type EventSearchPT struct {
ID int `json:"id"`
Code string `json:"code"`
NameDE string `json:"name_de"`
NameEN string `json:"name_en"`
Jurisdiction *string `json:"jurisdiction,omitempty"`
}
// EventSearchOptions is the filter set for SearchEvents. Empty values
// mean "no narrowing on this axis".
type EventSearchOptions struct {
// Jurisdiction filters by proceeding_types.jurisdiction
// ("UPC" | "DE" | "EPA" | "DPMA"). Empty = any.
Jurisdiction string
// ProceedingTypeCode narrows to one proceeding. Empty = any.
ProceedingTypeCode string
// EventKind filters by procedural_events.event_kind
// ("filing" | "hearing" | "decision" | "order"). Empty = any.
EventKind string
// PrimaryParty narrows by the anchor rule's primary_party
// ("claimant" | "defendant" | "court" | "both"). Empty = any.
PrimaryParty string
// Limit caps the result set; defaults to 50, max 200.
Limit int
}
// EventSearchResponse is the wire shape for ?kind=events on the
// /api/tools/fristenrechner/search endpoint (design §6.1).
type EventSearchResponse struct {
Query string `json:"query"`
Filters EventSearchFilters `json:"filters"`
Events []EventSearchHit `json:"events"`
Total int `json:"total"`
}
// EventSearchFilters is the filter echo returned to the client.
type EventSearchFilters struct {
Jurisdiction *string `json:"jurisdiction"`
ProceedingTypeCode *string `json:"proceeding_type_code"`
EventKind *string `json:"event_kind"`
PrimaryParty *string `json:"primary_party"`
}
// SearchEvents implements the ?kind=events response shape (Fristenrechner
// overhaul S1, design §6.1). Returns one hit per (procedural_event ×
// proceeding_type) tuple, ranked by trigram similarity against name /
// name_en / code. Empty q returns the unranked catalog filtered by the
// supplied facets.
func (s *DeadlineSearchService) SearchEvents(ctx context.Context, q string, opts EventSearchOptions) (*EventSearchResponse, error) {
limit := opts.Limit
if limit <= 0 {
limit = 50
}
if limit > 200 {
limit = 200
}
qNorm := normalizeQuery(q)
resp := &EventSearchResponse{
Query: q,
Filters: buildEventFilters(opts),
Events: []EventSearchHit{},
}
where := []string{
"sr.is_active = true",
"sr.lifecycle_state = 'published'",
"pe.is_active = true",
"pe.lifecycle_state = 'published'",
"pt.is_active = true",
}
args := []any{}
add := func(clause string, val any) {
args = append(args, val)
where = append(where, fmt.Sprintf(clause, len(args)))
}
if opts.Jurisdiction != "" {
add("pt.jurisdiction = $%d", opts.Jurisdiction)
}
if opts.ProceedingTypeCode != "" {
add("pt.code = $%d", opts.ProceedingTypeCode)
}
if opts.EventKind != "" {
add("pe.event_kind = $%d", opts.EventKind)
}
if opts.PrimaryParty != "" {
add("sr.primary_party = $%d", opts.PrimaryParty)
}
// Trigram score over (name || name_en || code). Empty query collapses
// the score to 0 — keeps the SQL identical regardless of input mode.
scoreExpr := "0::float8"
if qNorm != "" {
args = append(args, qNorm)
scoreExpr = fmt.Sprintf(
`GREATEST(similarity(pe.name, $%[1]d), similarity(pe.name_en, $%[1]d), similarity(pe.code, $%[1]d))`,
len(args))
// Drop hits with zero similarity so a typo doesn't return the
// whole catalog ranked at 0.
where = append(where, fmt.Sprintf(
`(pe.name %% $%[1]d OR pe.name_en %% $%[1]d OR pe.code %% $%[1]d)`,
len(args)))
}
// follow_up_count: rules whose parent_id points at this anchor rule.
// Computed via correlated subquery; cheap at the 231-row scale.
query := `
SELECT pe.id AS event_id,
pe.code,
pe.name AS name_de,
pe.name_en,
pe.event_kind,
pe.description,
sr.primary_party,
pe.concept_id,
sr.id AS anchor_rule_id,
pt.id AS pt_id,
pt.code AS pt_code,
pt.name AS pt_name_de,
pt.name_en AS pt_name_en,
pt.jurisdiction AS pt_jurisdiction,
(SELECT COUNT(*)::int
FROM paliad.sequencing_rules child
WHERE child.parent_id = sr.id
AND child.is_active = true
AND child.lifecycle_state = 'published') AS follow_up_count,
` + scoreExpr + ` AS score
FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
WHERE ` + strings.Join(where, "\n AND ") + `
ORDER BY score DESC, pt.sort_order, pe.code
LIMIT $` + fmt.Sprintf("%d", len(args)+1)
args = append(args, limit)
type row struct {
EventID uuid.UUID `db:"event_id"`
Code string `db:"code"`
NameDE string `db:"name_de"`
NameEN string `db:"name_en"`
EventKind sql.NullString `db:"event_kind"`
Description sql.NullString `db:"description"`
PrimaryParty sql.NullString `db:"primary_party"`
ConceptID *uuid.UUID `db:"concept_id"`
AnchorRuleID uuid.UUID `db:"anchor_rule_id"`
PTID int `db:"pt_id"`
PTCode string `db:"pt_code"`
PTNameDE string `db:"pt_name_de"`
PTNameEN string `db:"pt_name_en"`
PTJurisdiction sql.NullString `db:"pt_jurisdiction"`
FollowUpCount int `db:"follow_up_count"`
Score float64 `db:"score"`
}
var rows []row
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, fmt.Errorf("search events: %w", err)
}
hits := make([]EventSearchHit, 0, len(rows))
for _, r := range rows {
hit := EventSearchHit{
EventID: r.EventID,
Code: r.Code,
NameDE: r.NameDE,
NameEN: r.NameEN,
AnchorRuleID: r.AnchorRuleID,
FollowUpCount: r.FollowUpCount,
ConceptID: r.ConceptID,
Score: r.Score,
ProceedingType: EventSearchPT{
ID: r.PTID,
Code: r.PTCode,
NameDE: r.PTNameDE,
NameEN: r.PTNameEN,
},
}
if r.EventKind.Valid {
v := r.EventKind.String
hit.EventKind = &v
}
if r.Description.Valid {
v := r.Description.String
hit.Description = &v
}
if r.PrimaryParty.Valid {
v := r.PrimaryParty.String
hit.PrimaryParty = &v
}
if r.PTJurisdiction.Valid {
v := r.PTJurisdiction.String
hit.ProceedingType.Jurisdiction = &v
}
hits = append(hits, hit)
}
resp.Events = hits
resp.Total = len(hits)
return resp, nil
}
func buildEventFilters(opts EventSearchOptions) EventSearchFilters {
f := EventSearchFilters{}
if opts.Jurisdiction != "" {
v := opts.Jurisdiction
f.Jurisdiction = &v
}
if opts.ProceedingTypeCode != "" {
v := opts.ProceedingTypeCode
f.ProceedingTypeCode = &v
}
if opts.EventKind != "" {
v := opts.EventKind
f.EventKind = &v
}
if opts.PrimaryParty != "" {
v := opts.PrimaryParty
f.PrimaryParty = &v
}
return f
}

View File

@@ -58,6 +58,14 @@ var (
// surface this as a 400 with a bilingual friendly message; the
// matching DB trigger (mig 088) is the defence-in-depth backstop.
ErrInvalidProceedingTypeCategory = errors.New("proceeding_type_id must reference a fristenrechner-category proceeding_types row")
// ErrInvalidProceedingTypeKind signals that the caller supplied a
// proceeding_type_id pointing at a non-primary row — i.e. a
// phase/side_action/meta row, or an inactive row. Mig 153
// (t-paliad-325, design §1) carved the taxonomy so only
// kind='proceeding' AND is_active=true rows may bind to a
// project. Handlers surface this as a 400; the matching DB
// trigger (mig 153) is the defence-in-depth backstop.
ErrInvalidProceedingTypeKind = errors.New("proceeding_type_id must reference an active kind='proceeding' proceeding_types row")
)
// ProjectType values enumerated on the projects.type CHECK constraint.
@@ -1165,29 +1173,47 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
return s.GetByID(ctx, userID, id)
}
// validateProceedingTypeCategory enforces the Phase 3 Slice 5 invariant
// (t-paliad-186, design §3.F + m's Q2 ruling): a project may only bind
// to a fristenrechner-category proceeding_types row. NULL passes
// through; the matching DB trigger (mig 088) is the defence-in-depth
// backstop should this slip somehow.
// validateProceedingTypeCategory enforces the project-binding invariants
// on paliad.projects.proceeding_type_id:
//
// Surfaces ErrInvalidProceedingTypeCategory so handlers can map to a
// 400 with a bilingual user-facing message.
// 1. Phase 3 Slice 5 (t-paliad-186, design §3.F): row must be
// category='fristenrechner'. DB-side backstop: mig 088 trigger.
// Surfaces ErrInvalidProceedingTypeCategory.
//
// 2. Mig 153 (t-paliad-325, design §1 + m's Q8): row must be
// kind='proceeding' AND is_active=true. DB-side backstop: mig 153
// trigger. Surfaces ErrInvalidProceedingTypeKind. Rejects phase /
// side_action / meta rows and any deactivated row.
//
// NULL passes through. The Go layer fires first so handlers get typed
// errors; the DB triggers catch any writer that bypasses the service.
func (s *ProjectService) validateProceedingTypeCategory(ctx context.Context, ptID *int) error {
if ptID == nil {
return nil
}
var category sql.NullString
if err := s.db.GetContext(ctx, &category,
`SELECT category FROM paliad.proceeding_types WHERE id = $1`, *ptID); err != nil {
var row struct {
Category sql.NullString `db:"category"`
Kind sql.NullString `db:"kind"`
IsActive bool `db:"is_active"`
}
if err := s.db.GetContext(ctx, &row,
`SELECT category, kind, is_active FROM paliad.proceeding_types WHERE id = $1`, *ptID); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return fmt.Errorf("%w: proceeding_type_id=%d not found", ErrInvalidInput, *ptID)
}
return fmt.Errorf("lookup proceeding_type category: %w", err)
return fmt.Errorf("lookup proceeding_type: %w", err)
}
if !category.Valid || category.String != "fristenrechner" {
if !row.Category.Valid || row.Category.String != "fristenrechner" {
return fmt.Errorf("%w: proceeding_type_id=%d has category=%q",
ErrInvalidProceedingTypeCategory, *ptID, category.String)
ErrInvalidProceedingTypeCategory, *ptID, row.Category.String)
}
if !row.Kind.Valid || row.Kind.String != "proceeding" {
return fmt.Errorf("%w: proceeding_type_id=%d has kind=%q",
ErrInvalidProceedingTypeKind, *ptID, row.Kind.String)
}
if !row.IsActive {
return fmt.Errorf("%w: proceeding_type_id=%d is inactive",
ErrInvalidProceedingTypeKind, *ptID)
}
return nil
}

View File

@@ -163,6 +163,162 @@ func TestProjectService_ProceedingTypeCategoryGuard(t *testing.T) {
}
}
// TestProjectService_ProceedingTypeKindGuard exercises the mig 153
// (t-paliad-325 / m/paliad#147) "kind='proceeding' only" invariant on
// paliad.projects.proceeding_type_id from three angles:
//
// 1. ProjectService.Create returns ErrInvalidProceedingTypeKind when
// handed an id pointing at a kind='phase' / 'side_action' / 'meta'
// row (the Go service guard fires before the DB trigger).
//
// 2. ProjectService.Create returns ErrInvalidProceedingTypeKind when
// handed an id pointing at a row with is_active=false (mig 153 §4
// deactivated all non-primary rows so this is the same set of IDs;
// the test still independently asserts the is_active branch by
// re-activating a phase row inside the test and confirming the kind
// check still fires).
//
// 3. The mig 153 backstop trigger rejects a raw INSERT that bypasses
// the Go service layer (defence-in-depth). Bypasses mig 088's
// category trigger by also picking a fristenrechner-category row.
//
// 4. Passing a kind='proceeding' active id (upc.inf.cfi) still
// succeeds — proves the new guard doesn't break the happy path.
//
// Skipped when TEST_DATABASE_URL is unset, mirroring the rest of this
// file.
func TestProjectService_ProceedingTypeKindGuard(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
// A row that is fristenrechner-category but kind != 'proceeding'.
// Picks the first phase row by id (deterministic). Falls back to any
// non-proceeding kind if no phase rows are present (post-data-drift
// hardening).
var phaseID int
if err := pool.GetContext(ctx, &phaseID,
`SELECT id FROM paliad.proceeding_types
WHERE category = 'fristenrechner' AND kind <> 'proceeding'
ORDER BY (kind = 'phase') DESC, id
LIMIT 1`); err != nil {
t.Fatalf("look up non-proceeding kind id: %v", err)
}
// A primary id for the happy-path case + raw-INSERT control.
var proceedingID int
if err := pool.GetContext(ctx, &proceedingID,
`SELECT id FROM paliad.proceeding_types
WHERE category = 'fristenrechner' AND kind = 'proceeding'
AND is_active = true AND code = $1`,
CodeUPCInfringement); err != nil {
t.Fatalf("look up %s id: %v", CodeUPCInfringement, err)
}
users := NewUserService(pool)
svc := NewProjectService(pool, users)
userID := uuid.New()
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE created_by = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
cleanup()
defer cleanup()
if _, err := pool.ExecContext(ctx,
`INSERT INTO auth.users (id, email) VALUES ($1, 'mig153-guard-test@hlc.com')`,
userID); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, role, lang)
VALUES ($1, 'mig153-guard-test@hlc.com', 'Mig153 Guard', 'munich', 'associate', 'de')`,
userID); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
// 1. Non-proceeding kind id → ErrInvalidProceedingTypeKind from the
// service guard. (The row is also is_active=false post-mig-153,
// but the kind check fires first.)
_, err = svc.Create(ctx, userID, CreateProjectInput{
Type: ProjectTypeProject,
Title: "Mig 153 — non-proceeding-kind reject",
ProceedingTypeID: &phaseID,
})
if err == nil {
t.Error("Create with kind!=proceeding proceeding_type_id should fail, but succeeded")
} else if !errors.Is(err, ErrInvalidProceedingTypeKind) {
t.Errorf("expected ErrInvalidProceedingTypeKind, got %v", err)
}
// 2. Re-activate the phase row in a savepoint so the kind check
// still fires (proves the kind branch isn't shadowed by the
// is_active branch).
if _, err := pool.ExecContext(ctx,
`UPDATE paliad.proceeding_types SET is_active = true WHERE id = $1`, phaseID); err != nil {
t.Fatalf("re-activate phase row: %v", err)
}
t.Cleanup(func() {
pool.ExecContext(ctx,
`UPDATE paliad.proceeding_types SET is_active = false WHERE id = $1`, phaseID)
})
_, err = svc.Create(ctx, userID, CreateProjectInput{
Type: ProjectTypeProject,
Title: "Mig 153 — active phase row still rejects on kind",
ProceedingTypeID: &phaseID,
})
if err == nil {
t.Error("Create with active kind=phase row should still fail on kind check; got nil")
} else if !errors.Is(err, ErrInvalidProceedingTypeKind) {
t.Errorf("expected ErrInvalidProceedingTypeKind, got %v", err)
}
// 3. mig 153 trigger — raw INSERT bypassing Go service must raise.
// We use the active phase row (still re-activated from step 2)
// so we don't trip mig 088's category check first. Both triggers
// are independent; mig 153's must fire on a category=fristenrechner
// kind!=proceeding row.
rawID := uuid.New()
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, rawID)
_, err = pool.ExecContext(ctx,
`INSERT INTO paliad.projects
(id, type, parent_id, path, title, status, created_by,
proceeding_type_id, metadata, created_at, updated_at)
VALUES ($1, 'project', NULL, $1::text, 'Mig 153 — trigger bypass', 'active', $2,
$3, '{}'::jsonb, now(), now())`,
rawID, userID, phaseID)
if err == nil {
t.Error("raw INSERT with kind!=proceeding proceeding_type_id should have raised; got nil")
}
// 4. Happy path: kind='proceeding' active id → success.
created, err := svc.Create(ctx, userID, CreateProjectInput{
Type: ProjectTypeProject,
Title: "Mig 153 — primary proceeding accept",
ProceedingTypeID: &proceedingID,
})
if err != nil {
t.Fatalf("Create with kind=proceeding proceeding_type_id: %v", err)
}
if created.ProceedingTypeID == nil || *created.ProceedingTypeID != proceedingID {
t.Errorf("created proceeding_type_id = %v, want %d", created.ProceedingTypeID, proceedingID)
}
}
// TestProjectService_InstanceLevel_Roundtrip covers the Phase 3 Slice 8
// (t-paliad-189) instance_level data path: Create + Update both accept
// the four allowed shapes (first / appeal / cassation / NULL) and reject

View File

@@ -11,6 +11,7 @@ import (
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/models"
lp "mgit.msbls.de/m/paliad/pkg/litigationplanner"
@@ -677,6 +678,42 @@ func (s *RuleEditorService) ListRules(ctx context.Context, f ListRulesFilter) ([
return rows, nil
}
// LoadProceedingTypeCodes returns an id → code map for every distinct
// non-NULL proceeding_type_id present in rows. Single SELECT against
// paliad.proceeding_types (firm-wide reference data, no RLS). Used by
// /admin/api/procedural-events to enrich the LIST response with a
// proceeding_type_code field so the admin UI can disambiguate
// same-named rules at a glance (t-paliad-321).
func (s *RuleEditorService) LoadProceedingTypeCodes(ctx context.Context, rows []models.DeadlineRule) (map[int]string, error) {
seen := map[int]bool{}
var ids []int
for _, r := range rows {
if r.ProceedingTypeID != nil && !seen[*r.ProceedingTypeID] {
seen[*r.ProceedingTypeID] = true
ids = append(ids, *r.ProceedingTypeID)
}
}
if len(ids) == 0 {
return nil, nil
}
type pair struct {
ID int `db:"id"`
Code string `db:"code"`
}
var pairs []pair
if err := s.db.SelectContext(ctx, &pairs,
`SELECT id, code FROM paliad.proceeding_types WHERE id = ANY($1)`,
pq.Array(ids),
); err != nil {
return nil, fmt.Errorf("load proceeding_type codes: %w", err)
}
out := make(map[int]string, len(pairs))
for _, p := range pairs {
out[p.ID] = p.Code
}
return out, nil
}
// GetByID returns a single rule. Exported so the handler can call it
// directly without round-tripping through ListRules.
func (s *RuleEditorService) GetByID(ctx context.Context, id uuid.UUID) (*models.DeadlineRule, error) {

View File

@@ -178,6 +178,130 @@ func (s *SectionService) Update(ctx context.Context, sectionID uuid.UUID, patch
return &sec, nil
}
// SectionCreateInput is the payload for adding a new (lawyer-custom)
// section to a draft (t-paliad-318 Slice F).
type SectionCreateInput struct {
DraftID uuid.UUID
SectionKey string
Kind string
LabelDE string
LabelEN string
ContentMDDE string
ContentMDEN string
OrderIndex int // 0 = append at end
Included bool // defaults to true if not specified at the handler
}
// Create inserts a new section row for the draft. The section_key
// must not already exist on this draft (UNIQUE constraint at the DB
// catches collisions and surfaces as ErrInvalidInput).
//
// OrderIndex=0 means "auto-assign at the end" — the service queries
// the current max(order_index) and increments. Non-zero values insert
// at the requested position; the caller is responsible for any
// subsequent Reorder if they intend to push existing rows down.
func (s *SectionService) Create(ctx context.Context, in SectionCreateInput) (*SubmissionSection, error) {
in.SectionKey = strings.TrimSpace(in.SectionKey)
in.LabelDE = strings.TrimSpace(in.LabelDE)
in.LabelEN = strings.TrimSpace(in.LabelEN)
if in.SectionKey == "" || in.LabelDE == "" || in.LabelEN == "" {
return nil, ErrInvalidInput
}
switch in.Kind {
case "prose", "requests", "evidence":
default:
return nil, ErrInvalidInput
}
if in.OrderIndex == 0 {
var maxOrder int
err := s.db.GetContext(ctx, &maxOrder,
`SELECT COALESCE(MAX(order_index), 0) FROM paliad.submission_sections WHERE draft_id = $1`,
in.DraftID)
if err != nil {
return nil, fmt.Errorf("max order_index: %w", err)
}
in.OrderIndex = maxOrder + 1
}
var sec SubmissionSection
err := s.db.GetContext(ctx, &sec,
`INSERT INTO paliad.submission_sections
(draft_id, section_key, order_index, kind,
label_de, label_en, included,
content_md_de, content_md_en)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING `+sectionColumns,
in.DraftID, in.SectionKey, in.OrderIndex, in.Kind,
in.LabelDE, in.LabelEN, in.Included,
in.ContentMDDE, in.ContentMDEN)
if err != nil {
// UNIQUE (draft_id, section_key) collision → invalid input.
if strings.Contains(err.Error(), "unique") || strings.Contains(err.Error(), "23505") {
return nil, fmt.Errorf("%w: section_key already exists on this draft", ErrInvalidInput)
}
return nil, fmt.Errorf("create submission section: %w", err)
}
return &sec, nil
}
// Delete removes one section row by id. Owner-scope is the caller's
// responsibility (the handler runs SubmissionDraftService.Get first).
func (s *SectionService) Delete(ctx context.Context, sectionID uuid.UUID) error {
res, err := s.db.ExecContext(ctx,
`DELETE FROM paliad.submission_sections WHERE id = $1`,
sectionID)
if err != nil {
return fmt.Errorf("delete submission section: %w", err)
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrSubmissionSectionNotFound
}
return nil
}
// Reorder updates the order_index of every section row for the draft
// according to the supplied ID sequence. Transactional — partial
// failures roll back. Any section_id present on the draft but not in
// the sequence keeps its previous order_index, then sorts last by
// updated_at (so a partial reorder doesn't lose rows the caller
// forgot to mention).
func (s *SectionService) Reorder(ctx context.Context, draftID uuid.UUID, order []uuid.UUID) ([]SubmissionSection, error) {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return nil, fmt.Errorf("reorder tx: %w", err)
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
// Each id in order gets order_index 10, 20, 30, ... (gaps so a
// future single-row insert doesn't trigger a full reflow). Ids
// not present on the draft are silently ignored.
for i, sectionID := range order {
idx := (i + 1) * 10
_, err := tx.ExecContext(ctx,
`UPDATE paliad.submission_sections
SET order_index = $1
WHERE id = $2 AND draft_id = $3`,
idx, sectionID, draftID)
if err != nil {
return nil, fmt.Errorf("reorder update: %w", err)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit reorder: %w", err)
}
committed = true
return s.ListForDraft(ctx, draftID)
}
// SeedFromSpec inserts one row per BaseSectionSpec.Default into
// submission_sections for the given draft. Runs inside the caller's
// transaction (the SubmissionDraftService.Create path wraps the

View File

@@ -0,0 +1,152 @@
package services
// Live-DB tests for Slice F section service additions (Create + Delete
// + Reorder). Gated on TEST_DATABASE_URL, mirroring Slice A's pattern.
import (
"context"
"os"
"testing"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
_ "github.com/lib/pq"
"mgit.msbls.de/m/paliad/internal/db"
)
func TestSectionService_SliceF(t *testing.T) {
url := os.Getenv("TEST_DATABASE_URL")
if url == "" {
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
}
if err := db.ApplyMigrations(url); err != nil {
t.Fatalf("apply migrations: %v", err)
}
pool, err := sqlx.Connect("postgres", url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
ctx := context.Background()
bases := NewBaseService(pool)
sections := NewSectionService(pool)
// Seed user + draft so we have a draft_id to attach sections to.
userID := uuid.New()
cleanup := func() {
pool.ExecContext(ctx, `DELETE FROM paliad.submission_sections WHERE draft_id IN (SELECT id FROM paliad.submission_drafts WHERE user_id = $1)`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
}
cleanup()
defer cleanup()
email := "slice-f-" + userID.String()[:8] + "@hlc.com"
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); err != nil {
t.Fatalf("seed auth.users: %v", err)
}
if _, err := pool.ExecContext(ctx,
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
VALUES ($1, $2, 'Slice F User', 'munich', 'standard', 'de')`,
userID, email); err != nil {
t.Fatalf("seed paliad.users: %v", err)
}
users := NewUserService(pool)
projects := NewProjectService(pool, users)
parties := NewPartyService(pool, projects)
vars := NewSubmissionVarsService(pool, projects, parties, users)
renderer := NewSubmissionRenderer()
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
drafts.AttachComposer(bases, sections, "HLC")
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
if err != nil {
t.Fatalf("Create draft: %v", err)
}
initial, err := sections.ListForDraft(ctx, d.ID)
if err != nil {
t.Fatalf("ListForDraft initial: %v", err)
}
if len(initial) != 10 {
t.Fatalf("expected 10 seeded sections; got %d", len(initial))
}
t.Run("Create custom section", func(t *testing.T) {
created, err := sections.Create(ctx, SectionCreateInput{
DraftID: d.ID,
SectionKey: "berufungsantraege",
Kind: "requests",
LabelDE: "Berufungsanträge",
LabelEN: "Appeal requests",
Included: true,
})
if err != nil {
t.Fatalf("Create: %v", err)
}
if created.OrderIndex <= 10 {
t.Errorf("auto-assigned order_index should be > existing max; got %d", created.OrderIndex)
}
// Slug collision must surface as ErrInvalidInput.
_, err = sections.Create(ctx, SectionCreateInput{
DraftID: d.ID, SectionKey: "berufungsantraege",
Kind: "prose", LabelDE: "x", LabelEN: "x", Included: true,
})
if err == nil {
t.Errorf("expected unique-key collision error; got nil")
}
})
t.Run("Delete section", func(t *testing.T) {
// Grab one of the seeded rows to delete.
current, _ := sections.ListForDraft(ctx, d.ID)
var victimID uuid.UUID
for _, s := range current {
if s.SectionKey == "exhibits" {
victimID = s.ID
break
}
}
if victimID == uuid.Nil {
t.Fatalf("expected exhibits section to exist")
}
if err := sections.Delete(ctx, victimID); err != nil {
t.Fatalf("Delete: %v", err)
}
// Second delete returns not-found.
if err := sections.Delete(ctx, victimID); err == nil {
t.Errorf("expected ErrSubmissionSectionNotFound on second delete")
}
})
t.Run("Reorder sections", func(t *testing.T) {
current, _ := sections.ListForDraft(ctx, d.ID)
if len(current) < 3 {
t.Skipf("need at least 3 sections to test reorder; got %d", len(current))
}
// Reverse the order list.
ids := make([]uuid.UUID, 0, len(current))
for i := len(current) - 1; i >= 0; i-- {
ids = append(ids, current[i].ID)
}
reordered, err := sections.Reorder(ctx, d.ID, ids)
if err != nil {
t.Fatalf("Reorder: %v", err)
}
// Verify the first ID in our list now has the lowest order_index.
if reordered[0].ID != ids[0] {
t.Errorf("first ID after reorder = %s; want %s", reordered[0].ID, ids[0])
}
// Order indices should be ascending.
prev := 0
for _, s := range reordered {
if s.OrderIndex <= prev {
t.Errorf("non-ascending order_index after reorder: %d (prev=%d) at %s", s.OrderIndex, prev, s.SectionKey)
}
prev = s.OrderIndex
}
})
}