Compare commits

...

140 Commits

Author SHA1 Message Date
mAi
05247d7bd7 docs(exports): canonicalize deadline manuscript generator + filter optionals (t-paliad-348)
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
m had a one-off /tmp/paliad-deadline-export.py (work/head delegation
#2572) that dumped every published sequencing_rules row. Output
showed 37 entries on upc.inf.cfi including optional rules
(Lodging of translations, Review of CMO, ...) which fights the
engine's IncludeOptional=false default and m's "naked proceeding
with options but not always displayed" mental model.

Move to exports/gen-deadline-list.py as the canonical re-runnable
script and add a SQL-level priority filter that matches the
engine. Default suppresses priority='optional'; --include-optional
opts back in for an exhaustive catalog dump.

- DSN overridable via PALIAD_DEADLINE_EXPORT_DSN env var.
- argparse-driven: --include-optional / -o OUT / --generated-for LABEL.
- Header explains the mode so the PA reader knows what's suppressed.
- Regenerated exports/upc-deadlines-2026-05-28.md: now 178 rules across
  25 proceedings (vs the unfiltered run). upc.inf.cfi section drops
  from ~37 to 28 mandatory + conditional rules - the optional ones
  are gone; trigger_event_id mandatory rules stay in the catalog
  (they're a real PA-knowable surface; runtime anchor state is what
  decides whether they project into a timeline, separate concern).

Run:
    uv run exports/gen-deadline-list.py [--include-optional]

(m/paliad#153)
2026-05-28 11:02:03 +02:00
mAi
a81581878e fix(builder): port engine semantics into Builder triplet calc surface (t-paliad-348)
The Litigation Builder triplet renders /api/tools/fristenrechner output
verbatim and never applied the pre-existing filterByDetailMode pass that
the legacy /tools/verfahrensablauf page uses. With the engine fix
(3c840c0 — pkg/litigationplanner default IncludeOptional=false + trigger
event semantic anchoring) already in main, optional rules are dropped
server-side but rules with an unsatisfied trigger_event_id surface as
IsConditional. Without filterByDetailMode those still rendered as
"abhängig von ..." cards on the triplet, polluting m's "naked
proceeding with options but not always displayed" mental model.

upc.inf.cfi went from 7 mandatory backbone events to 29 visible cards
(22 conditional noise — Lodging of translations, Mängelbeseitigung,
Antrag auf Verweisung, Wiedereinsetzung, ...). Live BEFORE/AFTER
captured in exports/screenshots/.

Fix layers:

- Go handler (internal/handlers/fristenrechner.go): accept
  includeOptional + triggerEventAnchors from request body and
  forward to services.CalcOptions. Default zero values match the
  engine defaults (suppress optionals + no fabricated dates for
  trigger_event_id rules), so the wire is unchanged when callers
  don't set them.

- TS calc surface (frontend/src/client/views/verfahrensablauf-core.ts):
  add the same two fields to CalcParams + forward in the fetch body;
  surface rulesAwaitingAnchor on DeadlineResponse mirroring
  Timeline.RulesAwaitingAnchor.

- Builder triplet (frontend/src/client/builder.ts hydrateTriplet):
  apply filterByDetailMode(detailgrad) before renderColumnsBody, with
  detailgrad sourced from the proceeding row. "selected" (default)
  drops conditional + optional rules; "all_options" passes
  includeOptional=true so the engine returns the optional rules the
  user can opt into.

- Legacy /tools/verfahrensablauf (frontend/src/client/verfahrensablauf.ts):
  pass includeOptional based on detailMode + a small hasOptionalOptIn
  helper so per-rule rule:<uuid>=true deviations still surface their
  optional rule even in "selected" mode (the engine has no rule:<uuid>
  awareness; without the opt-in the user's pick would silently no-op).

Tests:
- frontend/src/client/views/verfahrensablauf-core.test.ts: pin the
  fetch body shape - includeOptional=true and triggerEventAnchors={...}
  round-trip through the request; empty/default values are omitted so
  the wire stays minimal.

bun build + bun test (269 pass) + go vet + go test
./internal/handlers/... ./pkg/litigationplanner/... all clean.

(m/paliad#153)
2026-05-28 11:01:49 +02:00
mAi
fcdfba209d Merge: t-paliad-346 B3 — event-triggered mode + universal search (m/paliad#153)
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-28 10:11:05 +02:00
mAi
3e93e94d10 feat(builder): B3 — event-triggered mode + universal search (m/paliad#153)
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
PRD §2.2 + §3.1: the page-header search box drives a typed dropdown
returning grouped event / scenario / project hits, and the "Ereignis"
entry mode is enabled. Picking an event creates a scratch scenario
with one triplet anchored on that event's proceeding type, with the
event card auto-anchored (lime band + "━━━━ DU BIST HIER ━━━━" divider
above the next-coming events).

Backend: new GET /api/builder/search reuses
DeadlineSearchService.SearchEvents for the events corpus (UPC v1),
filters owned scenarios by ILIKE on name, and reuses ProjectService.List
for the Akten group (team-RLS via visibilityPredicate). Each group is
capped independently (default 8 events / 5 scenarios / 5 projects, max
30). Missing services degrade gracefully — empty group, not 503.

Frontend: builder-search.ts owns the dropdown (debounced 180ms,
arrow-key navigation, Enter to pick, abort on next query). builder.ts
gains mode state ("cold" | "event" | "akte"), wires the mode bar +
search input, and runs applyAnchorHighlight after triplet hydration —
the helper finds the .fr-col-item with the picked rule_id, adds the
.builder-anchor-card lime band, and inserts a full-width
.builder-anchor-divider after the anchor's row in the columns grid
via JS row-index math (the grid is row-major with 3 header cells
+ 3-cells-per-row body).

Filter pill reset: setMode() clears the search input and closes the
dropdown when switching entry modes. Forum/proc/party/kind chips are
not yet rendered separately (they live in the search dropdown today);
the reset hook attaches there too when those land in a follow-up.

Verification:
  - bun build (frontend bundles + i18n scan clean)
  - go vet ./... + go test ./... (all packages pass)
  - Playwright: mode switch focuses search, debounced fetch fires,
    typed result groups render with N · M · K pluralization, event
    pick creates scratch scenario + adds proceeding, anchor card
    + DU BIST HIER divider render in the columns grid (screenshots
    confirmed visually)
2026-05-28 10:10:33 +02:00
mAi
28ea103260 Merge: t-paliad-345 — surface proceeding_type id so Builder add-proceeding works (m/paliad#153)
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-28 09:50:57 +02:00
mAi
1c77cb6e67 fix(builder): surface proceeding_type id so add-proceeding POST works (t-paliad-345)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / build (pull_request) Has been cancelled
Paliad CI gate / test-go (pull_request) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / deploy (pull_request) Has been cancelled
The Litigation Builder's "+ Verfahren hinzufügen" silently failed in
prod after t-paliad-343 B2 shipped — clicking a Verfahren chip in the
picker did nothing, no visible error.

Root cause: the wire shape FristenrechnerType (the response of
/api/tools/proceeding-types) carried code+name+nameEN+group but not
id. Builder.ts mountAddProceedingPicker's callback POSTed
`{proceeding_type_id: meta.id}` to
/api/builder/scenarios/{id}/proceedings — meta.id was undefined,
JSON.stringify dropped the key, the server returned 400 ("invalid
input: proceeding_type_id is required"), and fetchJSON swallowed the
error to console. The user saw "nothing happens".

Fix:
- Add `ID int json:"id"` to lp.FristenrechnerType.
- SELECT id in FristenrechnerService.ListProceedings + Scan into the
  new field.
- Defensive guard in builder.ts openAddProceedingPicker — refuse to
  POST without a positive integer id and log a clear error, so a
  future wire-shape regression cannot recreate the silent-fail.
- Regression test in pkg/litigationplanner/types_wire_test.go pins the
  contract (id present in JSON, round-trips as integer).

Side-benefit: fristenrechner-wizard.ts:599-628 documented this exact
gap as a known limitation ("S5/follow-up can extend the wire shape to
include id"). That workaround can now be retired in a follow-up.

Refs m/paliad#153 (Litigation Builder)
2026-05-28 09:48:32 +02:00
mAi
1f6e586c63 Merge: t-paliad-344 — fix stale deadlines.rule_id refs + builder null-guards (m/paliad#154)
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-28 00:48:17 +02:00
mAi
a4b865d6bd fix(builder): initialise scenario sub-arrays + client null-guard (t-paliad-344)
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
GetScenarioDeep returned nil slices for proceedings/events/shares when
a scenario had zero rows, which Go's encoding/json serialises as `null`
rather than `[]`. The builder's renderCanvas then unconditionally calls
`state.active.proceedings.filter(...)` on a null and dies with
`procedures.js:101 TypeError: Cannot read properties of null
(reading 'filter')` — every cold-open scenario crashed the page before
the empty canvas could render.

Backend (root cause): initialise Proceedings / Events / Shares to empty
slices in BuilderScenarioDeep before SelectContext, so the wire shape
is always arrays. Existing rows still load via SelectContext, which
truncates the placeholder and refills from the DB.

Frontend (defence in depth): on loadScenario(), normalise each of the
three arrays to `[]` if the server response is not an array. Catches a
future regression (or an older deployed build) without re-introducing
the same crash class.

bun build clean, go vet + go test ./... green.
2026-05-28 00:47:19 +02:00
mAi
a905911cf4 fix(deadlines): restore /api/events deadline rail after mig 140 column drop (t-paliad-344)
Two SELECTs still referenced paliad.deadlines.rule_id after mig 140
(Slice B.4) dropped that column in favour of sequencing_rule_id:

  - internal/services/deadline_service.go:268 — DeadlineService.
    ListVisibleForUser. Powers /api/events?type=deadline (dashboard
    deadline rail, /deadlines page, every status bucket). Threw
    `pq: column f.rule_id does not exist` on every request → 500
    for any authenticated user hitting the dashboard.

  - internal/services/projection_service.go:1250 — collectActualsForOverrides.
    Same column on `paliad.deadlines d`. Logged once per projection
    pass (`ERROR service: projection: deadlines: ...`); aliased the
    rename to `rule_id` so the receiving struct tag still scans.

Live container logs confirmed the failure mode — a 60-row burst of
`pq: column f.rule_id does not exist at position 3:36 (42703)` starting
the minute the post-B0 container came up (mig 140 had applied to the
DB but the SELECT still used the dropped name). EXPLAIN against the
live schema after the edit plans cleanly; the LEFT JOIN to
paliad.deadline_rules_unified on sequencing_rule_id was already correct
(only the SELECT projection was stale).

Root cause: mig 140 commit (1129bab) renamed the JOIN to
`f.sequencing_rule_id` but left the SELECT clause on the older name.
The model tag is already `db:"sequencing_rule_id" json:"rule_id"`, so
the wire shape is unchanged — only the column reference flips.

bun build clean, go vet ./... clean, go test ./... green.
2026-05-28 00:47:08 +02:00
mAi
88c03e922f Merge: t-paliad-343 B2 — multi-triplet + spawn + per-event state (m/paliad#153)
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-28 00:29:50 +02:00
mAi
6bcac2dd20 Merge: t-paliad-343 B1 — Litigation Builder shell + cold-open (m/paliad#153) 2026-05-28 00:29:50 +02:00
mAi
46dc4ec94b feat(builder): B2 — multi-triplet stack + spawn nesting + per-event state (m/paliad#153)
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
Builds on B1 (commit 6c1d8cc). After this slice a user can compose a
multi-proceeding scenario kontextfrei: stack proceedings, flip
perspective per-triplet, toggle scenario flags, auto-spawn child
proceedings on flag transitions, and mark individual event cards as
planned / filed / skipped — all auto-saved to paliad.scenario_*.

PRD §7.1 B2 acceptance shipped:
  - Multi-triplet stack: top-level proceedings sorted by ordinal,
    child proceedings nested inline with a left lime border.
  - Per-triplet controls bar: perspective radio (none / claimant /
    defendant), Detailgrad pill (selected / all options), Entfernen
    action. Each control PATCHes the proceeding row and re-renders the
    affected triplet.
  - Per-triplet flag strip: every paliad.scenario_flag_catalog row
    rendered as a checkbox, bound to scenario_proceedings.scenario_flags.
    Active flags also surface as chips in the triplet header for quick
    legibility.
  - Spawn nesting: when `with_ccr` flips ON on upc.inf.cfi the builder
    auto-POSTs an upc.ccr.cfi child proceeding linked via
    parent_scenario_proceeding_id; flip OFF deletes the child (events
    cascade via the schema). The SPAWN_MAP table is data-driven so
    future spawn flags slot in.
  - 3-state event cards (planned / filed / skipped):
    overlayEventStates walks the rendered .fr-col-item nodes (the
    data-rule-id hook added to verfahrensablauf-core in this slice)
    and stamps each card with data-builder-state + per-state action
    buttons (File / Skip / Reset to planned). Filed cards prompt for
    a date; skipped cards prompt for an optional reason. POSTs or
    PATCHes paliad.scenario_events keyed by sequencing_rule_id.
  - Per-card optional horizon chip: stores horizon_optional on the
    scenario_event row, increment / decrement chip on every card.
    The full surface awaits a calc-engine "optionals available"
    counter (PRD §3.4 follow-up); the persistence layer + UX hook are
    in place so the wiring lands without another schema touch.
  - Page-header Stichtag drives default dates for every triplet (the
    triplet's per-stichtag override path is wired but the per-triplet
    Stichtag input is a B3+ affordance).

verfahrensablauf-core.renderColumnsBody now stamps data-rule-id (and
data-submission-code as a future hook) on every .fr-col-item root —
non-breaking enhancement; the legacy /tools/* pages don't read either
attribute. Verified by re-running the existing 57-test suite.

Backend: one new read-only endpoint
GET /api/builder/scenario-flag-catalog passes through
ScenarioFlagsService.ListCatalog so the builder doesn't need a
per-project round-trip to render flag toggles.

bun run build clean (3050 i18n keys), go vet ./... clean, go test ./...
clean, frontend bun test (verfahrensablauf-core suite) 57 / 57 pass.
2026-05-28 00:28:48 +02:00
mAi
6c1d8cc0cf feat(builder): B1 — Litigation Builder shell + cold-open mode (m/paliad#153)
Replaces cronus's U0-U4 catalog at /tools/procedures with a
persistence-backed builder shell on top of B0's API surface
(/api/builder/scenarios/*, t-paliad-340).

PRD §7.1 B1 acceptance shipped:
  - Page header: scenario picker, name action, Akte picker stub,
    Stichtag input, search input, save status indicator.
  - Entry-mode radio (cold-open active; event-triggered + akte
    rendered disabled for B3/B4 layout stability).
  - Empty canvas with "Neues Szenario starten" CTA and a 5-most-recent
    list rendered when the user has saved scenarios.
  - Side panel "Meine Szenarien" with the Aktiv bucket; clicking an
    item loads the scenario into the canvas.
  - Add-proceeding inline picker (Forum chip row → Verfahren chip row
    → Hinzufügen). UPC v1; other forums chipped but disabled.
  - First proceeding triplet renders end-to-end via
    verfahrensablauf-core.calculateDeadlines + renderColumnsBody (the
    existing 3-column proaktiv|court|reaktiv body, read-only in B1).
  - Auto-save with 500ms debounce on name + stichtag patches; save
    status flips idle → saving → saved/error in the page header.

New client modules under frontend/src/client/:
  - builder.ts       — orchestrator (URL state, fetch, auto-save loop,
                       canvas render, scenario-list re-paint).
  - builder-picker.ts — inline Forum/Verfahren popover for the
                       add-proceeding affordance.
  - builder-triplet.ts — single-triplet header + body wrapper.

procedures.tsx rewritten as the shell scaffolding (sidebar, page
header, mode radio, two-column body); procedures.ts now boots the
builder instead of toggling the 4-tab catalog.

Legacy U0-U4 modules (verfahrensablauf.ts, verfahrensablauf-state.ts,
VerfahrensablaufBody.tsx, procedures' tab toggle in client/procedures.ts,
fristenrechner-* mounts) are no longer reachable from /tools/procedures
but kept in the tree for the B6 cleanup sweep per PRD §7.4.

i18n.ts grew 60 keys × 2 langs under builder.*. global.css grew a
self-contained .builder-* block at the file tail.

bun run build, go vet ./..., and go test ./... all green.
2026-05-28 00:20:46 +02:00
mAi
0c857026a2 Merge: pkg/litigationplanner respect trigger_event_id + suppress optional from default (yoUPC#178 + #2568/#2570)
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-28 00:05:37 +02:00
mAi
3c840c0366 fix(litigationplanner): respect trigger_event_id + suppress optional from default (yoUPC#178 + #2568/#2570)
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 paired engine semantics fixes:

1. trigger_event_id is now the authoritative semantic anchor. When a
   rule carries trigger_event_id, the engine no longer falls back to
   the proceeding's trigger date — it resolves the anchor via
   CalcOptions.TriggerEventAnchors keyed by paliad.trigger_events.code.
   Missing anchor renders the rule as IsConditional (empty date) and
   propagates via courtSet so descendants also surface as conditional.
   Fixes the RoP.109.5 bug where the engine fabricated a date 2 weeks
   before the user's SoC instead of waiting for the oral_hearing date.

2. priority='optional' rules are suppressed from the default
   Calculate output. Callers (paliad /tools/procedures,
   youpc.org/deadlines) opt in via CalcOptions.IncludeOptional=true to
   restore the legacy "show optional applications" behaviour. The
   suppression cascades through skippedIDs so child rules drop too.

Wire shape additions:

  - CalcOptions.IncludeOptional bool
  - CalcOptions.TriggerEventAnchors map[string]string
  - Timeline.RulesAwaitingAnchor int (count of suppressed-by-missing-
    anchor rules, for caller telemetry / "N rules need an anchor" UX)

Existing before-court-set-anchor tests opt in to IncludeOptional=true
to preserve their non-optional-related test intent.

Refs: youpcorg/head delegations #2568 + #2570, m/paliad#153 (Litigation
Builder PRD path).
2026-05-28 00:04:30 +02:00
mAi
1b4b2e4758 Merge: submission-md placeholder underscores preserved
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-28 00:01:30 +02:00
mAi
b78a984a7c fix(submission-md): preserve {{...}} placeholders verbatim through inline scanner
The Markdown inline scanner (parseInlineSpans) treats _ and * as
italic delimiters. A placeholder like {{project.case_number}} fed
through the scanner had its underscores consumed as italic markers,
leaving {{project.casenumber}} in the composed OOXML. The v1
placeholder pass then looked up the wrong key, surfacing
[KEIN WERT: project.casenumber] in the preview. The form ↔ preview
highlighting also stopped working because data-var attributes
mismatched between the input (snake_case) and the rendered span
(stripped).

parseInlineSpans now detects {{ at the cursor and skips ahead to
the matching }}, copying the entire placeholder verbatim into the
current text run. Unmatched {{ falls through to the existing
character handling so legal prose with stray braces still renders.

Tests: regression test for underscored keys (single + multiple +
mixed-with-italics), direct guard on parseInlineSpans, and an
italic-around-placeholder structural test.
2026-05-28 00:01:30 +02:00
mAi
1844df3ae6 Merge: t-paliad-340 B0 — Scenario DB foundation (m/paliad#153)
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-27 23:53:49 +02:00
mAi
0f3c30a647 feat(scenario-builder): B0 schema foundation + minimal API (m/paliad#153)
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
t-paliad-340 — B0 of edison's 7-slice train (PRD §7.1). DB-only: schema
+ RLS land, dev-only test route exercises the surface, no user-facing
change. B1 wires the actual builder UI on top.

Migration 157 (additive on the legacy mig-145 scenarios table — 0 rows
in prod, safe to relax):
- paliad.scenarios gets owner_id / status / origin_project_id /
  promoted_project_id / stichtag / notes. spec drops NOT NULL and the
  scenarios_unique_per_scope constraint drops (the builder allows
  multiple scratch + Unbenanntes Szenario rows per user).
- New tables: scenario_proceedings, scenario_events, scenario_shares.
- paliad.projects.origin_scenario_id for the promote-to-project audit
  trail (the FK lands now; the wizard ships in B5).
- paliad.can_see_scenario(uuid) STABLE SECURITY DEFINER helper covering
  owner / share / global_admin / two legacy paths.
- Replacement RLS on scenarios + RLS on the three new tables; legacy
  service + handlers stay live and unchanged.

PRD §5.1 deviations called out in the migration header:
- proceeding_type_id is integer (live schema), not uuid (PRD draft).
- FK target is paliad.users, matching the rest of paliad's schema.

Go surface:
- ScenarioBuilderService — list/create/get-deep/patch scenarios,
  add/patch/delete proceedings, add/patch/delete events,
  add/delete shares. Writes wrap in transactions with set_config(
  paliad.audit_reason, ..., true) per event_choice_service.go pattern.
- /api/builder/scenarios/* — handlers register under a builder/
  prefix so the legacy /api/scenarios surface still works.
- /dev/scenario-builder — single-page HTML form gated to
  PaliadinOwnerEmail, exercises the B0 surface without Postman.
- Live-DB integration test (TEST_DATABASE_URL gated) covers
  create + list + deep-get + share + visibility negatives + patch.

Audit-first: every DDL block ran clean via BEGIN/ROLLBACK against
the live DB before commit; end-to-end sanity (insert chain + CHECK
constraints + CASCADE-on-delete) verified via the Supabase MCP.

bun build clean. go vet + go test -short ./... green.
2026-05-27 23:50:14 +02:00
mAi
2c2b93bc7c Merge: t-paliad-339 — PRD for Procedures Litigation Builder (m/paliad#153)
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-27 23:04:07 +02:00
mAi
661d87273c docs(plans): PRD — Procedures Litigation Builder (m/paliad#153)
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
PRD for the columnar litigation planner replacing today's 4-tab catalog
at /tools/procedures with a Litigation Builder backed by a new Scenario
DB. Captures 20 chip-picker decisions (5 batches via AskUserQuestion)
covering: unified-builder shape with 3 entry modes (cold-open /
event-triggered / Akte), separate paliad.scenarios table with
multi-proceeding constellations, auto-save + named-list, per-proceeding
flags + perspective + Detailgrad, 3-state event cards
(planned/filed/skipped), per-event-card optional horizon, vertical
stacked column-triplets with inline spawn children, universal search
(events + scenarios + Akten), 3-step promote-to-project wizard,
read-only team sharing, desktop v1 + mobile basic-read.

Includes data model deltas (4 new tables + 1 column on
paliad.projects), 6-slice migration plan from the current live U0-U4
catalog, and coder hand-off notes. Cross-proceeding peer triggers and
DE/EPA/DPMA full expansion deferred to v1.1.
2026-05-27 23:00:24 +02:00
mAi
ed3c5d1f32 Revert "Merge: t-paliad-338 T1 — workflow-tracker shell replaces catalog (m/paliad#152)"
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
This reverts commit 2e6427dca6, reversing
changes made to 9fe06094a8.
2026-05-27 22:09:06 +02:00
mAi
be570c2fd0 Revert "Merge: t-paliad-338 T3-T5 — workflow-tracker Akte/polish/cleanup (m/paliad#152)"
This reverts commit 6506d7d862, reversing
changes made to 2e6427dca6.
2026-05-27 22:09:06 +02:00
mAi
58692513a8 Revert "Merge: tracker filter pill dark-mode contrast (m/paliad#152)"
This reverts commit 702f786771, reversing
changes made to 6506d7d862.
2026-05-27 22:09:06 +02:00
mAi
702f786771 Merge: tracker filter pill dark-mode contrast (m/paliad#152)
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-27 22:03:00 +02:00
mAi
93c664c865 fix(procedures): tracker filter pill — dark-mode contrast (m/paliad#152)
Selected .tracker-pill.is-active used --color-accent-fg, which in dark
mode resolves to lime → lime text on lime background, unreadable.
Switch to --color-accent-dark (midnight in both modes) so the selected
pill has midnight text on lime in both light + dark. Same pattern as
the older .filter-pill.active rule.
2026-05-27 22:03:00 +02:00
mAi
6506d7d862 Merge: t-paliad-338 T3-T5 — workflow-tracker Akte/polish/cleanup (m/paliad#152)
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-27 22:00:03 +02:00
mAi
73f49c46ed chore(procedures): T5 — drop dead code from the U0-U4 catalog (m/paliad#152)
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 workflow tracker (T1-T4) replaces every consumer of the entry-mode
modules. Verified via grep that no non-deleted file imports the
following before removal:

Deleted (10 files):
  - client/fristenrechner-mode-a.ts (Mode A search panel)
  - client/fristenrechner-wizard.ts (Mode B guided wizard)
  - client/fristenrechner-wizard.test.ts
  - client/fristenrechner-result.ts (post-commit result-view)
  - client/fristenrechner-result.test.ts
  - client/verfahrensablauf.ts (Verfahrensablauf panel client)
  - client/views/event-card-choices.ts (per-card choice popover —
    only verfahrensablauf.ts consumed it)
  - client/views/verfahrensablauf-state.ts (URL + storage helpers —
    only verfahrensablauf.ts consumed it)
  - client/views/verfahrensablauf-state.test.ts
  - components/VerfahrensablaufBody.tsx (the 4-tab proceeding picker
    body — no consumer after T1)

Kept (still load-bearing):
  - client/views/verfahrensablauf-core.ts — procedures-tracker uses
    calculateDeadlines + CalculatedDeadline + escHtml + formatDate.
  - client/views/verfahrensablauf-core.test.ts
  - client/verfahrensablauf-detail-mode.ts — procedures-tracker uses
    filterByDetailMode under the per-proceeding "Alle Optionen"
    toggle (T4).
  - client/verfahrensablauf-detail-mode.test.ts

The .css classes (.fristen-wizard-*, .verfahrensablauf-*) still live
in global.css; they're cheap orphans (no selector match in the new
DOM) and a CSS housekeeping pass is outside this train's scope. The
i18n keys (deadlines.flag.*, deadlines.detail.*, deadlines.view.*,
deadlines.side.*) likewise stay — some are used dynamically via tDyn
on the tracker, others remain candidates for a future i18n sweep.

Frontend tests: 217 pass (264 → 217, the deltas are the 3 deleted
test files: fristenrechner-result, fristenrechner-wizard,
verfahrensablauf-state). Build + go vet clean.

t-paliad-338
2026-05-27 21:57:51 +02:00
mAi
c80723fc85 feat(procedures): T4 — appeal-target + Alle Optionen + cross-party + polish (m/paliad#152)
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 tracker layer per design §3.4 / §3.6 / §11 polish list:

- Per-proceeding "· Gewählt · / Alle Optionen" toggle (§3.4) lives in
  the card header next to the show/hide button. State persists in
  localStorage per proceeding code, so a page with multiple cards
  can keep one expanded without affecting siblings. Toggle drives the
  detail mode for filterByDetailMode + sets includeHidden=true on the
  calc, so previously-skipped conditional rules re-surface muted.
- Appeal-target chip group (§3.2 #3) renders below the header on
  proceedings with applies_to_target rules — today only upc.apl.unified.
  Endentscheidung / Kostenentscheidung / Anordnung / Schadensbemessung
  / Bucheinsicht. Picking a target re-fetches the calc with the
  appealTarget param so the timeline narrows to the matching subset.
- Cross-party muted treatment (§3.6) — when the find-header Partei
  pill is set, rows whose primary_party is the opposite side render
  with a "Gegen." badge and a muted style. Court / both / informational
  rows are never cross-party.
- "Unselected" + "hidden" styling — under "Alle Optionen" the rules
  that filterByDetailMode stamps __detailUnselected on render dotted
  italic, and previously-skipped (isHidden) rules render at reduced
  opacity. Honest preview of what the user is NOT considering.
- Cross-surface scenario-flag-changed listener — the tracker now reseeds
  its flags state when Mode B / Verfahrensablauf / Verlauf patches the
  same project's flags, so toggling there flows through here without a
  refresh.

Out of T4 (court-set choices_offered chip groups and the court-set date
override from appointments) — those need a follow-up backend pass to
surface the choicesOffered payload on TimelineEntry through the calc
response in a usable shape. The data field exists on CalculatedDeadline
but isn't yet wired to a paint route on the tracker.

t-paliad-338
2026-05-27 21:55:52 +02:00
mAi
1ed75c56e3 feat(procedures): T3 — Akte landing + actuals overlay (m/paliad#152)
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
Wires the workflow tracker to projects via ?project=<uuid>, per design
§6.4 + §11.Q5:

- loadAkte fetches /api/projects/{id}, /api/projects/{id}/timeline
  and /api/projects/{id}/scenario-flags in parallel:
    1. Project title + proceeding_type — pre-seeds the Verfahren pill.
    2. Timeline events → ActualsMap keyed by deadline_rule_id with
       status (done / overdue / open / court_set), due / completed
       date, and deadline / appointment ids.
    3. scenario_flags → seeds state.flags so the gating-flag checkboxes
       render in the persisted state. Per-rule rule:<uuid> flags stay
       out of the calc payload (they drive priority deviations via
       isRuleSelected, handled by the existing detail-mode filter).
- Auto-pin: the first render with no explicit ?event= pins the most
  recent status='done' deadline. URL pin (shared link) is preserved.
- Per-node overlay: each node carries the actuals badge — ✓ (done +
  strike-through), ⚠ (overdue + red wash), 📅 (open ≠ projected), ◇
  (open ≡ projected). Date column shows the actual date.
- Fork write-back: PATCH /api/projects/{id}/scenario-flags fires on
  every flag toggle so Mode B / Verlauf / dashboard re-render with the
  same scenario on next visit. Fire-and-forget; UI doesn't wait.
- Find-header summary chips: "Akte: <title>" alongside "Anker: <name>"
  + "{n} Verfahren".

Out of T3 (deferred):
- ?project= picker UI (today's user navigates here from /projects/{id}
  via deep-link).
- Per-rule rule:<uuid> flag write-back (priority deviations) — the
  detail-mode filter doesn't take an interactive toggle yet.
- Cross-surface scenario-flag-changed CustomEvent listener — patching
  fires the event, the tracker just doesn't yet re-render on incoming
  ones (T4 polish).

t-paliad-338
2026-05-27 21:51:42 +02:00
mAi
2e6427dca6 Merge: t-paliad-338 T1 — workflow-tracker shell replaces catalog (m/paliad#152)
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-27 21:46:57 +02:00
mAi
7945bfb364 feat(procedures): T2 — anchor pin + zoom + multi-proceeding scope (m/paliad#152)
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
Layers the anchor / focus interactivity on top of T1's shell per
design §6.1–§6.5:

- Click-to-pin (📌) on every node with a real rule_id sets the anchor.
  Clicking the already-anchored pin un-pins. URL state ?event=<id>.
- Anchored node renders with a "── DU BIST HIER ──" divider beneath
  its meta line + the lime left-band styling. The find-header summary
  surfaces "Anker: <name>" so the user can confirm where they are.
- Fokus chip (🔍) on the anchored node toggles zoom (?zoom=1). Zoom
  renders the anchor's parent chain as a breadcrumb at the top of the
  proceeding card and renders only the anchored subtree below. A
  "{n} weitere Schritte verborgen" footer reports what zoom hid.
- Multi-proceeding scope (§6.5): when an anchor is pinned and >1
  proceeding is visible, non-anchored proceedings auto-collapse to a
  one-line header card with a [zeigen] / [ausblenden] toggle. The
  user's explicit expansions persist for the current anchor; pinning
  a different node clears them.
- Auto-pinning from the search input (T1's single-hit behaviour) now
  routes through onAnchorChanged so the multi-proc scope kicks in
  consistently.

Anchor + zoom state writes through history.replaceState — sharable URL.
Un-pinning clears zoom and restores the full multi-proceeding view
automatically (lastAnchor tracking).

t-paliad-338
2026-05-27 21:46:54 +02:00
mAi
bfb38aab41 feat(procedures): T1 — workflow-tracker shell replaces catalog (m/paliad#152)
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
Direct-replace per m's Q7 divergent pick in atlas's design
(docs/design-procedures-workflow-tracker-2026-05-27.md §9): /tools/procedures
drops the 4-tab catalog (U0-U4 shipped this morning) for the single
canonical workflow-tracker shape.

T1 ships:
- Sticky find header — search input, forum / Verfahren / Partei pill
  rows, global Stichtag, live result summary.
- Per-proceeding timeline cards — one card per matched proceeding,
  rendered as a chained tree by parent_id with priority-styled bullets
  (mandatory solid, recommended muted, optional dotted, informational
  faded, court-set blue). Party badge per node.
- Cold-open default: the 6 curated proceedings from design §8 / §11.Q4
  (upc.inf.cfi, upc.rev.cfi, upc.apl.unified, de.inf.lg, epa.opp.opd,
  dpma.opp.dpma) render stacked with a hint above.
- Scenario-flag forks — per-proceeding "Optionen" strip on each card's
  header surfaces the applicable flags (with_ccr, with_amend, with_cci)
  derived from condition_expr or a fallback map. Tick re-runs the calc.
- URL state: ?q, ?forum, ?procs, ?party, ?trigger_date, ?event, ?flags.
  ?event= scroll-highlights the matching node (no zoom yet — T2 layers).
- Legacy ?mode= dropped silently on first state write so bookmarks
  self-clean. /tools/fristenrechner + /tools/verfahrensablauf 301s
  still resolve here.

Floor T1 honours: every catalog workflow it replaces — pick proceeding
(forum + Verfahren pills), search event (search input → auto-narrow +
?event= anchor), wizard narrowing (pills compose), Akte entry
(?project= read-only for T1; full overlay in T3).

Per-node fork placement (the design's stated final shape — checkbox on
the gating node itself, not a card-level strip) is a T2 refinement;
T1 keeps forks scoped per proceeding so they're not the global-page
strip m's bug #5 flagged.

Aux-proceedings inline-expandable (design §5) and the appeal-target
chip group are scoped to T4; the calculator currently doesn't surface
isSpawn / spawnProceedingCode through TimelineEntry to support them.

t-paliad-338
2026-05-27 21:42:31 +02:00
mAi
9fe06094a8 Merge: t-paliad-337 — workflow-tracker design for /tools/procedures (m/paliad#152)
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 workflow-tracker design after m's 21:01 grilling-round reframe (single timeline-with-forks, find=search+pills+result-timelines, aux inline, zoom from within full tree). 510-line doc, 2 rewrite iterations.

7 Qs answered in 2 batches (4+3). 5 on-recommendation, 2 divergent:
- Q3 (divergent): multi-proceeding anchor scope — auto-collapse other proceedings to header-only (new §6.5)
- Q7 (divergent): migration strategy — direct replace at T1, no feature flag (§9)

4-slice + cleanup train. T1 ships minimum-viable tracker visibly at /tools/procedures, replacing the catalog UI knuth shipped today.

Inventor parks. Head dispatches Sonnet coder (NOT atlas per project memory directive).
2026-05-27 21:14:11 +02:00
mAi
c8f310c62c design(procedures-tracker): fold m's 7 picks — §11 decisions + §6.5 multi-tl anchor + §9 direct-replace migration (t-paliad-337)
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
5 picks on-recommendation, 2 diverged:

Q3 (multi-proceeding anchor scope): m picked 'other timelines auto-collapse to header-only' over the recommended 'stay expanded'. Added §6.5 with the header-card render rule.

Q7 (migration cadence): m picked 'direct replace at T1, no flag' over the recommended flag-gated dev. §9 rewritten end-to-end: T1 ships the minimum-viable tracker visibly to users, replacing the catalog UI in the same PR. T2-T4 layer zoom + Akte + polish. T5 is cleanup-only.

The 5 on-rec picks: inline checkbox forks (Q1), sibling-collapse zoom (Q2), 6 curated defaults on cold open (Q4), latest-done-deadline Akte anchor (Q5), global Stichtag (Q6) — all locked as drafted in §1-§8.

Ready for review. Coder gate held; head decides T1 hire.
2026-05-27 21:13:35 +02:00
mAi
7554e86673 design(procedures-tracker): rewrite after m's reframe — single timeline-tree, inline forks, no view toggle (t-paliad-337)
m's grilling-round answers (2026-05-27 21:01):
1. One canonical view (full timeline/tree); zoom is an interaction on it, not a separate view.
2. Forks = everything: scenario flags + optionals + appeal-target + court-set picks.
3. Find = combined affordance: text + pills + result-timelines are one thing.
4. Aux proceedings inline as expandable child timelines.

Doc rewritten end-to-end. The first draft's three-view toggle + Konstellationen drawer + Querverweise drawer + split-pane/breadcrumb apparatus collapses into:
- Sticky find header (search + pill rows + Akte/date)
- N matched proceedings rendered as inline-forked timeline-trees
- Anchor pin + opt-in zoom mode (collapses siblings, breadcrumb back)
- Aux proceedings expand inline at the spawn point

7 open questions in 4+3 batches for AskUserQuestion. T1-T5 migration unchanged in spirit.
2026-05-27 21:05:41 +02:00
mAi
23b151c0f3 design(procedures-tracker): t-paliad-337 shift-1 — workflow-tracker layer for /tools/procedures (m/paliad#152)
m's reframe (2026-05-27 20:43): /tools/procedures should be a workflow
tracker, not a catalog browser. Pick any procedural event, see backward
(predecessors) + self (where I am) + forward (successors), with
scenario_flags as togglable predicates and alternative constellations
explorable.

This shift-1 doc covers:
- 4-tab UX redo (single-pane radio-revealed entry form to fix the
  pre-form-leak bug)
- Anchor visualisation (vertical waterfall with anchor at centre line)
- Three views — Anchor / Verfahren / Konstellationen — toggle preserves
  anchor + scenario state
- Forward walk (current constellation only by default, conditional
  reveal toggle, view-mode toggle reused from atlas P3)
- Backward walk (3 hops default, Akte mode overlays paliad.deadlines
  actuals onto template chain)
- Compound rules drawer (per-anchor Querverweise affordance — column
  shape owned by curie editorial workstream)
- Constellation viewer (inline per-flag preview drawer + full
  Constellation view for browse)
- Akte entry (anchor derives from latest completed deadline)
- Migration: T1-T5 flag-gated dev under ?tracker=1, then hard-cut

Coder gate held. 11 open questions for m staged for AskUserQuestion in
4+4+3 batches. Decisions append as §13 before the
TRACKER DESIGN READY FOR REVIEW signal.
2026-05-27 20:56:51 +02:00
mAi
1718ea2eae Merge: t-paliad-335 — unified /tools/procedures shipped U0-U4 (m/paliad#151)
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 all 5 slices in one shift, per cronus's design:

U0 60907e7 — skeleton: new /tools/procedures route + page shell + 4 entry tabs + filter strip with search box + tree+linear-drawer scaffold
U1 0568d34 — fold Mode A (Direkt suchen) — porting fristenrechner-mode-a.ts
U2 c8261da — fold Mode B (Geführt wizard) — porting fristenrechner-wizard.ts
U3 48a07ef — fold Verfahrensablauf tree + 3-way detail filter — porting verfahrensablauf.ts + detail-mode.ts
U4 39c8ef3 — hard-cut 301: /tools/fristenrechner + /tools/verfahrensablauf → /tools/procedures; retire dual surfaces

Net: -4,452 LoC across 20 files (consolidating + dropping legacy). bun build clean, 264 frontend tests pass, go vet + go test ./... clean.

Comment posted on m/paliad#151. Per-project scenario_flags binding from Phase 2 P0 still drives the unified tool's per-rule chips.
2026-05-27 20:35:44 +02:00
mAi
39c8ef343b feat(procedures): U4 hard-cut legacy URLs + retire dual surfaces (m/paliad#151)
Per m's Q11 divergence in the design (no 2-week dual-ship), this slice
flips /tools/fristenrechner and /tools/verfahrensablauf to permanent 301
redirects to /tools/procedures and deletes the legacy frontend pages.
Bookmarks resolve via Location preservation of query params; no
?legacy=1 escape, no in-product affordance pointed back at the retired
URLs after the merge.

Server:
- handleFristenrechnerPage + handleVerfahrensablaufPage now 301 to
  /tools/procedures, carrying any query string through unchanged.
- pillDrillURL in deadline_search_service.go retargets to
  /tools/procedures so freshly indexed search pills land on the new
  page directly (cached snapshots still work via the 301).

Frontend:
- Deleted src/fristenrechner.tsx, src/verfahrensablauf.tsx,
  src/client/fristenrechner.ts.
- src/client/verfahrensablauf.ts loses its DOMContentLoaded auto-boot
  and the now-unused initI18n / initSidebar imports; procedures.ts is
  the sole caller of initVerfahrensablauf().
- frontend/build.ts drops the legacy entrypoints and renderXxx HTML
  outputs.
- Sidebar.tsx, Header.tsx, index.tsx, paliadin-context.ts repointed
  to /tools/procedures.
- Unused nav.fristenrechner / nav.verfahrensablauf /
  tools.verfahrensablauf.* i18n keys removed.

Tests:
- verfahrensablauf_test.go rewritten to assert both legacy URLs return
  301 with the correct Location (query string preserved).
2026-05-27 20:34:54 +02:00
mAi
48a07ef4ef feat(procedures): U3 fold Verfahrensablauf tree + 3-way detail filter (m/paliad#151)
Mounts the full Verfahrensablauf wizard — proceeding picker, perspective
chooser, date inputs, scenario flag rows, detail-mode toggle, view
toggle, timeline-container — under the /tools/procedures "Verfahren
wählen" tab. Per-rule scenario_flags chips (P0 SSoT) and the
Aufnehmen/Entfernen affordances reach the unified page unchanged since
they're delegated handlers on the timeline-container.

Refactor steps:
- Extracted the wizard body markup into a shared TSX component
  (components/VerfahrensablaufBody) used by both verfahrensablauf.tsx
  (legacy) and procedures.tsx (unified). U4 will retire the legacy
  page; the shared component lets U3 ship without code duplication.
- Lifted the verfahrensablauf.ts DOMContentLoaded body into
  initVerfahrensablauf() and re-exported it. The legacy auto-boot
  stays in place but skips itself when #procedures-panel-proceeding
  is present, so the unified page imports the module without
  double-init. procedures.ts calls initVerfahrensablauf() the first
  time the proceeding tab activates, gated by a one-shot flag to
  preserve module-local selectedType / lastResponse across tab
  toggles.
2026-05-27 20:29:05 +02:00
mAi
bb3d7aabd7 Merge: hide archived from admin/procedural-events default view
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-27 20:27:45 +02:00
mAi
c8390dd02a fix(admin-rules-list): default lifecycle filter to 'published' (hide archived clutter)
m flagged 2026-05-27 20:26: archived rules (e.g. the 5 mig 152 Mängelbeseitigung clones) clutter the /admin/procedural-events default view. They were correctly archived by mig 152 but visually noisy alongside active rules.

Fix: default activeLifecycle = 'published'. The 'Alle' chip still exists for when the user wants to see drafts + archived; 'Archived' chip surfaces them on demand. Initial view shows only the active corpus.
2026-05-27 20:27:45 +02:00
mAi
c8261da492 feat(procedures): U2 fold Mode B (Geführt wizard) (m/paliad#151)
Mounts mountWizard() into #procedures-panel-wizard when the Geführt tab
activates. Same 5-row wizard, same backend (event search + follow-ups
probe) as the legacy /tools/fristenrechner. On R4 launchResult, the
wizard hands off to mountResultView which renders into the same
overhaul-root inside the panel.

The wizard renders into #fristen-overhaul-mode-host while Mode A and
the result view write into #fristen-overhaul-root. To keep those IDs
unique in the DOM — both modes look up via document.getElementById —
the host scaffold is no longer static on the search panel. The new
installOverhaulHost() helper tears down any existing host and installs
a fresh one inside the active tab's panel before each mount, so two
parallel hosts can't cross-wire when the user toggles between the
Direkt-suchen and Geführt tabs.

The U1/U2 placeholders are dropped from the panel markup since the
panels are populated dynamically now.
2026-05-27 20:23:23 +02:00
mAi
0568d340a7 feat(procedures): U1 fold Mode A (Direkt suchen) (m/paliad#151)
Mounts mountModeA() into #procedures-panel-search when the Direkt-suchen
tab activates. The legacy fristenrechner-mode-a code runs unchanged
inside a wrapper that reseeds the #fristen-overhaul-root /
#fristen-overhaul-mode-host scaffold on every tab activation, so
re-clicking the tab always restores a fresh Mode A surface even if the
previous interaction committed an event into the result view.

`?event=<code>` deep links still resolve: boot detects the param,
activates the search tab, and hands directly to mountResultView() —
the result lands inside the same root, the user sees the picked
event's follow-up rules with the Direkt-suchen tab as the visible
context.

Search-box-in-filter-strip composition with chip filters (m's Q3
divergence) lands later, after Mode B + Verfahrensablauf are folded —
the unified state machine pulls all three behind one search input.
2026-05-27 20:21:39 +02:00
mAi
60907e7153 feat(procedures): U0 skeleton — /tools/procedures page shell (m/paliad#151)
First slice of the unified procedural-events tool train. Ships only the
page chrome — route, sidebar/header, filter strip with search box, four
entry-mode tabs (Verfahren wählen / Direkt suchen / Geführt / Aus Akte),
and the host containers later slices mount their UI into. No data wiring.

Per m's decisions (design §11.5): URL is English (/tools/procedures, not
/tools/verfahren); all four tabs visible from boot (not a single-default
landing); search box lives in the top filter strip and will compose with
chip filters once U1+ wire them.

U1 fills #procedures-panel-search (Mode A), U2 fills -wizard (Mode B),
U3 fills -proceeding + #procedures-output-tree (Verfahrensablauf), U4
hard-cuts /tools/fristenrechner and /tools/verfahrensablauf to 301
redirects and drops the legacy pages.
2026-05-27 20:19:15 +02:00
mAi
66b08813c4 Merge: t-paliad-334 — unified procedural-events tool design (m/paliad#151)
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
cronus shipped 568-line design ratifying a single /tools/procedures page that folds Fristenrechner (Mode A + Mode B + result view) + /tools/verfahrensablauf into one surface with 4 entry tabs and tree+linear-drawer outputs.

12 m's decisions (9 on-recommendation, 3 divergent):
- Q (divergent): English URL '/tools/procedures' (over '/tools/verfahren')
- Q (divergent): all 4 entry tabs visible + search-in-filter-strip (over single default tab)
- Q (divergent): hard-cut 301 redirect (over 2-week dual-ship)

Stays separate (correctly different shape/audience):
- /admin/procedural-events (editorial write surface)
- /projects/{id} Verlauf (per-Akte actuals)
- SmartTimeline (internal projection)
- youpc.org/deadlines (cross-repo, embedded snapshot)

5-slice migration train U0-U4 (no DB mig — purely UX consolidation atop the shipped Phase 2 substrate).

Inventor parks. Head dispatches Sonnet coder per project memory directive (NOT cronus for impl).
2026-05-27 19:56:04 +02:00
mAi
0aaa523494 design: fold m's 12 decisions into unified procedural-events doc (m/paliad#151)
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
- §11.5 m's decisions section (9 on-rec + 3 divergent)
- diverged: Q2 /tools/procedures (English), Q3 all-tabs+search-in-filter-strip, Q11 hard cut no dual ship
- §11.5.1 changes triggered by divergences (URL rename, all-tabs behaviour, U4 rewrite)
- URL refs throughout body updated to /tools/procedures
- U4 slice rewritten to 301 hard-cut per Q11
2026-05-27 19:54:50 +02:00
mAi
d49ff55c41 design: unified procedural-events tool (m/paliad#151 shift-1, draft)
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
- audit of 6 surfaces with question→dimension matrix
- proposal: fold Fristenrechner + Verfahrensablauf into /tools/verfahren
- 4 entry paths converge on tree + linear output shapes
- mobile narrow-viewport rules + 3 worked personas
- 5-slice migration train (U0-U4), no DB migration
- 12 open questions in 3 batches for AskUserQuestion
2026-05-27 19:23:24 +02:00
mAi
ae1c0b861d Merge: fix admin-rules-edit URL parser regex (post B.6 rename hotfix)
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-27 17:58:49 +02:00
mAi
c8999e2a8b fix(admin-rules-edit): accept /admin/procedural-events/{id}/edit in URL parser
Slice B.6 / S6 renamed the canonical edit URL from /admin/rules/{id}/edit
to /admin/procedural-events/{id}/edit. The backend handler + 301 redirect
landed, but the client-side regex in admin-rules-edit.ts:110 was missed —
it still only matches the legacy /admin/rules/.../edit shape. Result:
visiting the canonical URL from the list page shows 'Ungültige
Verfahrensschritt-ID in der URL.' even though the rule exists.

Fix: regex accepts both '/admin/procedural-events/{id}/edit' (canonical)
and '/admin/rules/{id}/edit' (legacy, kept for stale tabs / bookmarks
during the deprecation window).

m flagged 2026-05-27 17:57 on rule cc439590 (RoP.262.2, upc.inf.cfi).
2026-05-27 17:58:49 +02:00
mAi
0365e84dd1 Merge: t-paliad-331 P2 + P4 partial — condition_expr validator + trigger_events partial deprecation (m/paliad#149)
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 the final two slices of the Phase 2 train.

P2 — condition_expr write-validator:
- New internal/services/condition_expr_validator.go (136 LoC) — locks the grammar to {flag:<str>} OR {op:'and'|'or', args:[<leaf>|<composite>]} per design §4.1
- RuleEditorService.Create + Update reject non-conforming expressions
- 166-LoC test coverage; all 18 existing condition_expr rows validate

P4 (partial) — trigger_events deprecation (mig 156):
- NULLs out the 2 hybrid rules' trigger_event_id (parent_id is the canonical edge per §2.1)
- Adds 'Deprecated: see m/paliad#149' header on the legacy /api/tools/event-deadlines route
- Does NOT drop paliad.trigger_events nor the 5 read sites — those are gated on the editorial reparenting of the 73 orphan globals (NULL proceeding_type_id, served only via trigger_event_id). Editorial work is m's, not coder scope.

Comment on m/paliad#149 (issuecomment-10436) enumerates the exact next steps for the eventual follow-up coder once editorial reparenting completes.

Phase 2 train: P0 + S1+S1a + P1 + P3 + P2 + P4 partial — ALL shipped. Final P4 step waits on editorial.
2026-05-27 15:27:12 +02:00
mAi
d6a5dedb2b feat(deadline-system): P4 (partial) — partial trigger_events deprecation (m/paliad#149)
Phase 2 P4 partial-scope (head approved 2026-05-27 15:24). The full
drop of paliad.trigger_events + the legacy route + 5 read sites is
gated on an editorial backfill that's not in coder scope — 73 active
sequencing_rules carry proceeding_type_id IS NULL and are addressed
ONLY via trigger_event_id today. Dropping anything would break those
73 orphans.

What this lands:

  1. Mig 156 — NULL out trigger_event_id on the 2 hybrid rules that
     carry BOTH parent_id AND trigger_event_id. Per design §2.1 /
     m's Q1, parent_id is the canonical predecessor link; the
     hybrid trigger_event_id was redundant. The 2 rules' parent_id
     chains keep the live edge. Live-DB verified post-apply: 0
     active hybrid rules remain.

  2. Deprecation + Link headers on POST /api/tools/event-deadlines
     per RFC 8594 / RFC 9745. The route stays functional so the 73
     orphans keep working until reparenting lands.

What this does NOT land (gated on editorial):

  - DROP TABLE paliad.trigger_events
  - DROP COLUMN paliad.sequencing_rules.trigger_event_id
  - Remove the legacy /api/tools/event-deadlines handler
  - Remove EventDeadlineService + ExportService::1680 sheet
  - Remove deadline_rule_service.go:226 label-fallback path
  - Remove event_type_service.go:40+414 reads (33 event_types still
    reference trigger_event_id)
  - Update cmd/gen-upc-snapshot/main.go:185-202 to skip trigger_events
  - Drop the sequencing_rules_trigger_event_id_fkey FK

All of the above lands in a follow-up mig once the orphan count
hits zero. Comment to follow on m/paliad#149 with the editorial-
backlog list.

Verified: live-DB pre/post hybrid count (0 active hybrids remain);
mig idempotent; go vet clean.

Design: docs/design-deadline-system-revision-2026-05-27.md §2.1
(parent_id canonical), §3.4 (legacy route fate), §4.3 (table fate),
§5 (slice train P5 row). t-paliad-331.
2026-05-27 15:25:53 +02:00
mAi
9940dd8216 feat(deadline-system): P2 — condition_expr write-validator (m/paliad#149)
Phase 2 P2 (design §4.1). Locks the condition_expr grammar to:

  CondExpr := { "flag": "<known_flag>" }
            | { "op": "and"|"or", "args": [<CondExpr>, ...] }

Where <known_flag> must exist in paliad.scenario_flag_catalog (today:
with_ccr / with_amend / with_cci; editorial adds via the catalog
table as needed).

Wire-time validation in RuleEditorService.Create and UpdateDraft —
the rule editor surfaces a 400 with a friendly message before the row
hits the DB. Empty / JSON null inputs pass through (the "no gate"
shape; stored as NULL column).

The validator:
  * walks the JSON tree once, collecting every leaf flag name
  * rejects mutually-exclusive shapes (leaf + composite in one node)
  * rejects empty args, bad op values, empty flag strings
  * does ONE batch lookup of the collected leaf names against the
    catalog (regardless of expression depth)

Tests:
  * 9 shape-only unit tests covering every reject path (no DB needed)
  * TestValidateConditionExpr_LiveCatalog covers 6 good shapes + 2
    unknown-flag cases against the live catalog
  * TestConditionExpr_AllLiveRowsValidate runs the validator over
    every active+published condition_expr in paliad.sequencing_rules
    to enforce the §4.1 invariant on every deploy (today's 18 rows
    all conform — verified via Supabase MCP pre-flight)

Live-DB tests skip cleanly when TEST_DATABASE_URL is unset (same
posture as sibling live tests in this package).

Design: docs/design-deadline-system-revision-2026-05-27.md §4.1
(grammar formalisation). t-paliad-331.
2026-05-27 15:22:53 +02:00
mAi
f6add95d0a Merge: t-paliad-331 P3 — Verfahrensablauf three-way detail filter (m/paliad#149)
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 m's headline UX (paliadin priority signal 14:58):

  'The new timeline filters for optional / mandatory / show only selected
   is what I am most waiting for. I want this to be consolidated for all
   our deadlines so we can simulate all proceedings.'

Three-way detail-level filter above the Verfahrensablauf result panel:
- Nur Pflicht — only priority='mandatory' rules
- Gewählt (default) — mandatory + recommended + every explicit per-rule override in projects.scenario_flags
- Alle Optionen — every rule, unselected ones rendered dotted-border + muted

State persists per-user via localStorage['verfahrensablauf:view_mode']. Per-rule Aufnehmen/Entfernen chips wire to projects.scenario_flags via the P0 SSoT (rule:<uuid> entries).

New files: verfahrensablauf-detail-mode.ts (125), verfahrensablauf-detail-mode.test.ts (96), filter wiring in verfahrensablauf.ts (+204) and views/verfahrensablauf-core.ts (+37). 63 LoC CSS (dotted-border treatment).

bun build clean, 264 frontend tests pass (8 new), go vet clean.

Ritchie continuing with P2 (condition_expr write-validator) then P4 (legacy deprecation).
2026-05-27 15:20:58 +02:00
mAi
480332a5f5 feat(deadline-system): P3 — three-way detail filter on Verfahrensablauf (m/paliad#149)
m's headline UX ask (2026-05-27 14:58, paliadin priority signal):

  "The new timeline filters for optional / mandatory / show only
   selected is what I am most waiting for. I want this to be
   consolidated for all our deadlines so we can simulate all
   proceedings."

Phase 2 P3. Adds a three-way detail-level filter above the result
panel on /tools/verfahrensablauf:

  ( ) Nur Pflicht     — only priority='mandatory' rules
  (•) Gewählt         — mandatory + recommended (default) + every
                         explicit per-rule override the user has set
                         in projects.scenario_flags
  ( ) Alle Optionen   — every rule, with unselected ones rendered
                         dotted-border + muted so the user sees what
                         they're NOT considering

State persists per-user via localStorage["verfahrensablauf:view_mode"].
The filter is pure client-side narrowing on the calc payload — flipping
the toggle re-renders instantly without a fresh backend call.

Per-rule selection (design §2.4a): every optional / recommended card
now carries an [Aufnehmen] / [Entfernen] chip. Clicking writes a
"rule:<uuid>" entry into the project's scenario_flags via the P0 SSoT
PATCH endpoint, recording only deviations from the priority default:

  recommended + entfernen → rule:<uuid> = false  (explicit deselect)
  optional    + aufnehmen → rule:<uuid> = true   (explicit select)
  flipping back to the default deletes the entry

Mandatory rules never expose the chip — they cannot be deselected.

Wire-shape change: CalculatedDeadline gains `ruleId` (the backend already
emits it as `ruleId` in TimelineEntry; only the frontend interface needed
to surface it).

Conditional handling: a conditional rule whose predicate doesn't fire
is treated as unselected in "Gewählt" mode (even when priority=
mandatory) — mandatory means "must be filed IF the predicate fires",
not "always render". The "Alle Optionen" view re-surfaces it so the
lawyer can see what scenario would unlock it.

Cross-surface coherence: hydrating ?project=<id> reads scenario_flags
from the SSoT and pre-fills the existing flag checkboxes (with_ccr /
with_amend / with_cci) so the page reflects the project's persisted
state on first paint. Every flag toggle + every per-rule chip click
PATCHes back. The page also listens for the scenario-flag-changed
CustomEvent fired by peer surfaces (Mode B Fristenrechner result-view)
and re-renders without a fresh fetch.

i18n: 5 new keys (deadlines.detail.{label,mandatory_only,selected,
all_options,optional_unselected_hint,aufnehmen,entfernen}) DE + EN.

CSS: dotted-border + muted treatment on .timeline-item-header--
unselected; .timeline-selection-chip with --add (lime accent) and
--remove (discreet muted) variants.

Tests: 8 new unit tests covering isRuleSelected (4 priority × 2 flag
state matrix) and filterByDetailMode (3 modes × default/override cases).

Verified: bun build clean, bun test 264/264 (8 new), go vet clean.

Design: docs/design-deadline-system-revision-2026-05-27.md §2.4a
(selection state model), §3.3a (view-mode toggle), §6 (Entry A spec).
t-paliad-331. Re-prioritised by m via paliadin 14:58.
2026-05-27 15:20:07 +02:00
mAi
97d90ce651 Merge: t-paliad-331 — Phase 2 slices P0 + S1+S1a + P1 (m/paliad#149)
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 three slices of the Phase 2 deadline-system revision train per design §5:

P0 — Scenario SSoT (mig 154):
- ALTER TABLE projects ADD COLUMN scenario_flags jsonb DEFAULT '{}'
- New paliad.scenario_flag_catalog table
- GET/PATCH /api/projects/{id}/scenario-flags endpoints
- Verfahrensablauf + result-view checkbox binding read+write through scenario_flags
- Per-rule selection state via 'rule:<uuid>' entries (generalises the with_ccr dropdown pattern, no new column on sequencing_rules)
- New scenario_flags_service.go (375 LoC), scenario-flags.ts client, i18n keys

S1+S1a — Cross-party display + spawn-only picker filter:
- FristenrechnerService.LookupFollowUps stops filtering by party server-side; returns all + primary_party
- UI groups: own-side checked-by-priority, cross-party annotated 'Gegenseitig' badge + unchecked
- SearchEvents SQL adds AND sr.is_spawn = false to filter spawn-only events as triggers
- lookup_events_test.go regression coverage

P1 — upc.apl re-split (m's Q5 divergence, mig 155):
- Reverts upc.apl.unified (id=160) back into upc.apl.merits / upc.apl.cost / upc.apl.order split
- Retargets 16 sequencing_rules to the appropriate split id
- Mig 155 applied to live DB per ritchie's report

Next per m's 14:58 priority signal: P3 (Entry A Verfahrensablauf tree UI with three-way view-mode toggle) — ritchie jumping straight there, then P2 + P4.
2026-05-27 15:12:52 +02:00
mAi
3a4e99cb92 feat(deadline-system): P1 — upc.apl re-split into merits/cost/order (m/paliad#149)
Phase 2 P1 / m's Q5 divergence (2026-05-27, verbatim):

  "Reverse the unification as suggested in 3. They are different
   proceedings, I only wanted the approach to be unified in the
   'determinator' — but they are actually different proceedings!"

Mig 155 reverts the mig-096 unification:

  Before: id=160 upc.apl.unified active (16 rules), id=11/19/20 inactive
  After:  id=11 upc.apl.merits (7 rules), id=19 upc.apl.cost (2 rules),
          id=20 upc.apl.order (7 rules) all active; id=160 inactive

The 16 rules under id=160 split cleanly by event_code prefix; all 10
parent_id edges among them are bucket-local (pre-flight audit), so
the tree shape survives the rebind unchanged.

Spawn FK retarget: pi.cfi.appeal_spawn flips from 11 (merits) → 20
(orders track) per design §3.1 — PI appeals land on orders, not
merits. The inf/rev/dmgs spawns keep target=11 (merits), now active.

Determinator routing layer (proceeding_mapping.go) keeps its single
"Berufung" front door per m's intent — only the data shape changes.

Pre-flight verified: 0 projects bound to id=160, 0 scenarios reference
upc.apl. Zero data migration on the project side.

Tests: lookup_events_test.go assertions on the three appeal_target
buckets updated to the new codes (endentscheidung → upc.apl.merits,
schadensbemessung → upc.apl.merits, bucheinsicht → upc.apl.order).
Same rule set, post-split coordinates.

Snapshot regen (pkg/litigationplanner/embedded/upc/) deferred: the
current snapshot only contains inf+rev so the apl re-split doesn't
shift its contents; regenerating would surface unrelated active PTs
and pollute this slice. Tracked as a follow-up.

Verified: go vet clean, go test ./internal/services/... -run
LookupEvents|proceeding_codes clean.

Design: docs/design-deadline-system-revision-2026-05-27.md §3.1
(re-split mig), §1.3 (spawn graph post-Q5). t-paliad-331.
2026-05-27 15:11:48 +02:00
mAi
3533d79a25 feat(deadline-system): S1+S1a — cross-party display + spawn-only picker (m/paliad#149)
Phase 2 S1 + S1a (pre-ratified from t-paliad-327, folded into the
Phase 2 train).

S1 — Cross-party display:
- FristenrechnerService.LookupFollowUps stops filtering by party
  server-side; queryFollowUpRows drops the perspective WHERE clause
  and returns every published+active child.
- Server now computes is_cross_party per row (true only when
  perspective ∈ {claimant,defendant} AND primary_party is the
  opposite side; NULL/both/court is never cross-party).
- FollowUpRule wire shape gains the boolean.
- Frontend renderRule adds a "Gegenseitig" badge + is-cross-party
  row class (muted styling, disabled checkbox affordance).
- defaultChecked returns false for cross-party rows.
- countSelected + submitWriteBack skip cross-party rows
  unconditionally — even if a user manually checks the box, they
  describe opposing-side filings and don't belong in our Akte set
  (design §2.4 write-back exclusion).
- i18n: deadlines.overhaul.crossparty.badge / .tooltip (DE+EN).
- CSS: .fristen-overhaul-rule-crossparty + .is-cross-party row
  modifier.

S1a — Spawn-only picker filter:
- SearchEvents WHERE now adds `sr.is_spawn = false` so spawn rules
  (e.g. appeal_spawn, the inf.cfi → upc.apl.merits hop) no longer
  surface as picker hits. Spawn rules are consequences, not
  triggers — a lawyer searching "Berufung" wants the appeal-tree
  root, not the inf.cfi spawn link.
- Terminal leaves (Duplik etc.) stay pickable per design §2.2's
  carve-out: their own anchor is non-spawn, so they surface and
  render an honest empty follow-up list.

Honest UX: hiding cross-party follow-ups lied about what the
workflow does next (cf. RoP.029.d falling off when perspective=
claimant on def_to_ccr — the workflow continues, just on the
defendant's docket). The fix makes the data legible without
contaminating the write-back path.

Verified: go vet clean, bun build clean, bun test 256/256,
go test ./internal/services/... -run LookupFollowUps... clean.

Design: docs/design-deadline-system-revision-2026-05-27.md §2.4
(cross-party) + §2.2 (spawn-only picker). t-paliad-331.
2026-05-27 15:07:01 +02:00
mAi
2a69f7fc6c Merge: t-paliad-332 — UPC vacations no longer block deadlines (align with paliad t-paliad-121)
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
brunel aligned the embedded snapshot calendar with paliad-side policy from t-paliad-121: UPC vacation rows stay in the data for informational annotation but no longer trigger IsNonWorkingDay → AdjustForNonWorkingDays leaves dates intact.

Changes:
- pkg/litigationplanner/embedded/upc/holidays.go: IsNonWorkingDay now returns true only on closure (not vacation). Vacations still surface via AdjustForNonWorkingDaysWithReason for labelling.
- pkg/litigationplanner/embedded/upc/holidays.json: regenerated from live paliad.holidays. Was 5 placeholders → now 55 holidays (33 vacation + 22 closures). Includes UPC Winter Vacation rows.
- pkg/litigationplanner/embedded/upc/meta.json: snapshot version bump.
- snapshot_test.go: +42 lines covering the vacation non-blocking behavior with regression cases.

Affects youpc.org/deadlines (consumes pkg/litigationplanner via Go module replace) — picks up automatically on rebuild.
2026-05-27 15:05:06 +02:00
mAi
39353d49ed fix(litigationplanner): UPC vacations no longer block deadlines (align with paliad t-paliad-121)
youpc.org/deadlines was rolling a deadline "from 2027-01-02 (UPC Winter
Vacation)" — i.e. across the UPC judicial vacation as if it were a
public holiday. Paliad-side t-paliad-121 already decided vacations are
informational only (the Court keeps running through them, RoP / UPC AC
decision-on-judicial-vacation 2023-05-26), and `HolidayService.Is
NonWorkingDay` in `internal/services/holidays.go` is correct. The
embedded snapshot consumed by youpc.org via Go-module replace had
drifted: `pkg/litigationplanner/embedded/upc/holidays.go:74` blocked on
both `isClosure()` AND `isVacation()`.

This commit aligns the embedded calendar with the paliad-side semantics
and ships a fresh holiday set so the existing 2026/2027 fix actually
takes effect downstream.

Code changes (`holidays.go`):

- `IsNonWorkingDay`: drop the `|| h.isVacation()` branch — only weekends
  and `isClosure()` rows trigger the roll. Godoc rewritten to mirror
  the paliad-side rationale (Court keeps operating, RoP cites,
  vacation rows kept for informational labels).
- `isClosure()`: accept both `"public_holiday"` and `"closure"`. Live
  paliad DB rows use the `public_holiday` value; the placeholder
  snapshot shipped with the original Slice C used `closure` as a
  hand-crafted synonym. Reconciles with
  `internal/services/holidays.go:132` which already does the same
  union. Required to make the regenerated JSON (full of
  `public_holiday`) keep blocking DE national holidays after the
  regeneration in this commit.
- Type-level godoc updated: `SnapshotHolidayCalendar` now documents
  vacation-is-informational, and the `AdjustForNonWorkingDaysWithReason`
  precedence note explains that `vacation` kind only fires when a
  vacation row overlaps a weekend or closure that's already doing the
  rolling.

Data refresh (`holidays.json`):

- Regenerated from paliad prod (postgres @ 100.99.98.201:11833,
  paliad schema). 55 rows for 2026 + 2027: 22 DE public_holiday +
  33 UPC vacation (25 Summer Vacation Jul 27–Aug 28, 8 Winter
  Vacation Dec 24/28–31 + Jan 4–6). The previous placeholder shipped
  only 5 rows (3 Sommerpause + Neujahr + Tag der Arbeit, no Winter
  Vacation at all) — which is why a date landing in late Dec / early
  Jan landed inside an unmodeled gap on the consumer side.
- `meta.json` bumped: version → `2026-05-27-1-holidays-only`,
  `holiday_count` 5 → 55, `source_db_label` flags that only
  holidays.json was refreshed (see friction note below).

Regression test (`snapshot_test.go::TestSnapshotHolidayCalendar`):

- 2026-08-04 (Tue, UPC Summer Vacation) — `IsNonWorkingDay` must be
  false; `AdjustForNonWorkingDays` must NOT mutate the date.
- 2027-01-02 (Sat, m's flagged scenario) — must roll forward through
  Sat/Sun, then STOP on Mon 2027-01-04 (UPC Winter Vacation, no longer
  blocking). Pre-fix this rolled all the way to Thu 2027-01-07.

Cross-repo: youpc.org imports `pkg/litigationplanner` via Go-module
replace; the regenerated snapshot ships on its next rebuild. No
separate youpc.org commit needed — paliad is the source of truth.

Friction note: `cmd/gen-upc-snapshot/main.go` itself is incompatible
with the current paliad schema. Migration 140 (`140_drop_deadline_rules`)
dropped `paliad.deadline_rules`, but the generator still SELECTs from
it (main.go ~L162). Running the tool against prod fails on the rules
step. I bypassed the broken path and generated `holidays.json` directly
from the DB via psql + jq (same JSON shape that `EmbeddedHoliday`
expects, nulls filtered for `omitempty`). The other snapshot files
(rules.json, proceeding_types.json, trigger_events.json, courts.json)
remain at their pre-existing placeholder state — re-flagged in
meta.json's `source_db_label`. Refitting the generator for the post-
mig-140 schema is a separate task.

go vet + go test ./... clean (256+ Go tests pass, including the new
regression cases).
2026-05-27 15:04:05 +02:00
mAi
d36cc9ee15 feat(deadline-system): P0 — per-project scenario_flags SSoT (m/paliad#149)
Phase 2 P0 of the deadline + procedural-events revision. Establishes
paliad.projects.scenario_flags (jsonb) + paliad.scenario_flag_catalog as
the single source of truth for per-project scenario state — replacing
the three fragmented stores athena flagged (project_event_choices,
scenarios.spec, DOM-only). All three were empty per the audit so no
data migration is needed.

The jsonb map carries two key shapes:

  * named flags (whitelist via scenario_flag_catalog) — today
    with_ccr / with_amend / with_cci
  * per-rule selection deviations of shape "rule:<uuid>" — wired up
    here for validation; the consumer UI lands in P3

Endpoints:

  GET   /api/projects/{id}/scenario-flags
  PATCH /api/projects/{id}/scenario-flags

PATCH semantics: bool = write; null = delete (priority-driven default
returns); missing key = leave alone. The service validates every key
on write (catalog lookup + UUID rule-membership + mandatory-cannot-be-
deselected) before persisting, so a single bad key fails the whole
patch.

Frontend bind: new scenario-flags.ts client module + Mode B's flag
checkboxes (ccr-flag / inf-amend-flag / rev-amend-flag / rev-cci-flag)
now hydrate from / persist to the project's scenario_flags on every
toggle. Kontextfrei (no project) is unchanged. Cross-surface coherence
via a scenario-flag-changed CustomEvent (peer surfaces — Verfahrens-
ablauf strip, Mode B result-view — will subscribe in P3).

Mig 154 is audit-defensive (set_config of paliad.audit_reason); no
audit trigger fires on paliad.projects today but a future one will
inherit the reason. Seeds the three known flags. CHECK constraints
enforce the top-level shape (jsonb_typeof = 'object') and the
catalog key pattern (lowercase, not 'rule:%' prefix).

Verified against the live DB: 18 projects default to '{}', catalog
has 3 rows, applied_migrations advanced to 154.

Design: docs/design-deadline-system-revision-2026-05-27.md §2.3, §2.4a,
§4.1, §5 (P0 row). t-paliad-331.
2026-05-27 15:02:01 +02:00
mAi
a9fd979cdb Merge: t-paliad-330 — timeline view dark-mode CSS token migration
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
brunel converted hardcoded hex to existing design tokens for the .timeline-* classes (Verfahrensablauf at ?view=timeline). No new tokens introduced; 11 existing tokens reused. Layout/spacing untouched.

Mirrors the t-paliad-326 playbook applied earlier to the Fristenrechner overhaul CSS, but on the separate timeline view that wasn't in scope then.
2026-05-27 14:52:35 +02:00
mAi
c48fa93e3d fix(verfahrensablauf): dark-mode token migration for timeline view CSS
The /tools/verfahrensablauf?view=timeline page (and the columns-view
mirror via `.fr-col-item--*` modifiers) had hardcoded light backgrounds
that survived a dark-mode flip. Root cause: four undefined custom
properties (`--color-bg-soft`, `--color-border-soft`, `--color-accent-bg`,
`--brand-lime`) consumed by the `.timeline-*` and adjacent
`.event-card-choices-*` rules, each with a hardcoded hex / RGBA fallback.
Since neither :root nor :root[data-theme='dark'] defines those tokens,
every consumer fell through to the light fallback in both themes —
leaving the conditional-row bg, the popover surface, the option-button
bg, the chip-skipped bg, and the popover block-separator border stuck on
near-white in dark mode. Earlier t-paliad-326 covered the new
Fristenrechner overhaul CSS only; the timeline view styles are a
separate block (~L3337-3810 in `frontend/src/styles/global.css`) and
were not touched.

Migrate every consumer to the existing dual-theme tokens already used
across paliad. Same approach m approved for t-paliad-326, no new tokens
introduced (all reuse):

- Card / popover surfaces (`.event-card-choices-popover`,
  `.event-card-choices-option`) → `--color-surface` (white light /
  card-tint dark) so the popover reads as raised above the body in
  both themes.
- Subtle raised surface for conditional row, skipped chip, option
  hover (`.timeline-item--conditional .timeline-content`,
  `.fr-col-item--conditional`, `.event-card-choices-chip-part--skipped`,
  `.event-card-choices-option:hover/:focus-visible`) →
  `--color-surface-muted`. **This is the visible bug fix m flagged.**
- Lime-tint backdrops (`.timeline-context-note` bg,
  `.event-card-choices-caret:hover/:focus-visible`) →
  `--color-bg-lime-tint` (lime-alpha 0.10 light / 0.12 dark).
- Active-option chip bg (`.event-card-choices-chip-part`) →
  `--color-accent-soft-bg`.
- Lime accent borders / fills (`.timeline-context-note` left border,
  `.timeline-date--overridden`, `.frist-date-edit-input`,
  `.event-card-choices-unhide-btn`, `.event-card-choices-option--active`)
  → `--color-accent` / `--color-accent-fg`. Drops the
  legacy `#c6f41c` fallback that doesn't match the current brand
  (`--hlc-lime: #BFF355`).
- Neutral borders (`.event-card-choices-caret`,
  `.timeline-item--hidden .timeline-content`, `.fr-col-item--hidden`,
  `.timeline-item--conditional .timeline-content`,
  `.event-card-choices-popover`, `.event-card-choices-option`,
  `.event-card-choices-block + .event-card-choices-block`) →
  `--color-border` (warm cream-derived light / cream-alpha dark).
- Popover shadow `rgba(0, 0, 0, 0.12)` → `var(--shadow-md)`
  (auto-deepens to `rgba(0, 0, 0, 0.45)` in dark).
- Status red (`.event-card-choices-error`) → `--status-red-fg`
  (defined in both themes; old `#b00020` fallback unreachable).

Zero new tokens introduced — every replacement uses a token already
shipped in both :root and :root[data-theme='dark']. Verified by
mounting `frontend/dist/assets/global.css` against a representative
static DOM (context-note banner, every timeline-item variant —
conditional / skipped / hidden / overridden-date / mandatory —
popover with active/inactive options, unhide button, error message,
all four party-badge stances) and toggling `data-theme` between
light and dark: conditional row bg flips from grey to muted-cream-on-
midnight, popover lifts off the body via `--shadow-md`, every chip and
border stays legible in both themes.

bun build + bun test (256 pass) + go vet clean.
2026-05-27 14:51:15 +02:00
mAi
5f7a66bbec Merge: t-paliad-329 — Phase 2 deadline + procedural-events full revision design (m/paliad#149)
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 comprehensive Phase 2 design covering Option B (full revision absorbs t-paliad-327 narrow scope). 776 lines.

Spine: connection schema for all procedural events as ASCII trees per PT + Mermaid spawn graph. parent_id canonical, trigger_event_id deprecated.

Tier 1 (4 questions, all on-recommendation):
- parent_id canonical predecessor link
- Trigger discoverability via derived EXISTS check
- Scenario state SSoT in projects.scenario_flags jsonb (mig 154)
- Cross-party display contract

Tier 2 (4 questions, 1 divergent):
- Q5 m diverged: revert upc.apl.unified back into merits/cost/order split (mig P1 retargets 16 rules)
- Spawn-only events excluded from picker
- Entry A 'sequence-from-proceeding-type' view extends /tools/verfahrensablauf
- Legacy /api/tools/event-deadlines + paliad.trigger_events deprecated

Tier 3: condition_expr grammar formalised ({flag} / {op:and|or,args:[...]}), editorial backfill workflow on /admin/procedural-events parent-NULL filter, trigger_events table dropped.

Shift-2 additions (per m's 14:31-14:35 direction, folded in 490c8a8):
- Selection state via per-rule scenario_flags entries (rule:<uuid>) — generalises the existing with_ccr dropdown pattern. NO new column on sequencing_rules.
- Three-way view-mode toggle on Verfahrensablauf: Nur Pflicht / Gewählt / Alle Optionen.
- R.109 chain (Antrag auf Simultanübersetzung → Mitteilung Dolmetscherkosten) as Tier 3 editorial worked example: current parent_id linkage is wrong; right shape uses with_interpreter_denied flag.

6-slice migration train:
- P0 Scenario SSoT (mig 154 + endpoints + binding)
- S1+S1a Cross-party display + spawn-only picker filter
- P1 upc.apl re-split (16 rules retarget)
- P2 condition_expr write-validator
- P3 Entry A Verfahrensablauf tree UI
- P4 Legacy deprecation + trigger_events drop

Worked examples for both entry paths. Coder dispatch awaits m's go.
2026-05-27 14:45:43 +02:00
mAi
490c8a8c8c design(deadline-system): fold m's selection + view-mode + R.109 additions (t-paliad-329)
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
m's clarification at 14:40 reframed the original "rarity" framing: every
optional rule is a per-scenario selectable card; the Verfahrensablauf
gets a three-way detail-level filter (Nur Pflicht / Gewählt / Alle
Optionen). The CCR-dropdown pattern generalises to per-rule chips.

Three folded additions:

§2.4a — Selection state + detail-level filter. NO new column on
sequencing_rules (reverts the earlier is_edge_case strawman). Extends
projects.scenario_flags jsonb to carry both named flags (with_ccr etc.)
and per-rule entries (rule:<uuid>). Storage only carries deviations
from the priority default (recommended = default-selected,
optional = default-unselected). Whitelist accepts rule:<uuid> when the
UUID resolves to an active rule on the project's PT.

§3.3a — Verfahrensablauf view-mode toggle: three-way segmented control,
localStorage persistence, default "Gewählt". Mode B result view stays
single-purpose (no view-mode toggle there).

§4.2.1 — R.109 translation chain editorial worked example: R.109.1 stays
as optional anchor; R.109.4 reparents to R.109.1 with condition_expr
{flag: with_interpreter_denied} and primary_party=both (parties, not
court); R.109.5 reparents to R.109.1 with {flag: with_translation_granted}.
Introduces two new flags to scenario_flag_catalog.

§6 UI spec updated: two mocked tree states (Gewählt + Alle Optionen)
showing the dotted-border [Aufnehmen] chips, [Entfernen] on selected
optionals, greyed-with-hint on flag-gated conditionals, and the
subtree-hide-on-unselected-ancestor render logic.

§10.0a captures the additions; §10.1 notes they don't change the slice
train (P0 + P3 take the extended scope; no new mig).
2026-05-27 14:45:00 +02:00
mAi
b1c9e8dd97 design(deadline-system): Phase 2 revision — connection schema + 12 m's decisions (t-paliad-329)
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
Builds on athena's Phase 1 assessment (9aee9e4) + atlas's t-paliad-327
pre-ratified subset. m's Option B direction: "overall schema for all
procedural events and how they are connected" — connection graph as the
spine.

Connection schema (§1):
- Rules are nodes, parent_id is the canonical edge, spawn rules are the
  cross-PT edges, condition_expr filters the visible subgraph
- ASCII trees for the 3 largest PTs (upc.inf.cfi 25, upc.rev.cfi 17,
  upc.apl post-Q5-split 16); Mermaid graph for the 4 spawn cross-PT edges
- Per-PT health table covering all 23 active primaries (17 ruled + 6 empty)

m's 12 design decisions (3 batches of 4 via AskUserQuestion):

Tier 1 — model (all 4 on-recommendation):
- Q1: parent_id is canonical, deprecate trigger_event_id
- Q2: Reparent 73 legacy globals via editorial walk
- Q3: Derive trigger discoverability from data (EXISTS)
- Q4: projects.scenario_flags jsonb (confirms t-paliad-327 design)

Tier 2 — surface (1 divergent, 3 on-recommendation):
- Q5 DIVERGENT: Reverse the upc.apl unification — split back into 3 PTs
  (merits/cost/order). m: "I only wanted the approach to be unified in
  the 'determinator' — but they are actually different proceedings!"
  Mig P1 retargets 16 rules by event_code prefix.
- Q6: Show empty PTs with "Keine Regeln gepflegt" badge
- Q7: Fold Entry A into /tools/verfahrensablauf
- Q8: Drop /event-deadlines after 73-globals reparenting

Tier 3 — editorial (all on-recommendation):
- Q9: Lock condition_expr grammar {flag} | {op:and|or, args}
- Q10: parent-NULL filter on /admin/procedural-events
- Q11: Drop trigger_events table once route is gone
- Q12: ASCII per-PT + Mermaid spawn graph

6-slice migration train (§5): P0 scenario SSoT, P1 appeal re-split, S1/S1a
from t-paliad-327, P2 empty-PT badge, P3 Entry A, P4 editorial walk, P5
trigger_event_id deprecation. P5 gated on P4.

No code yet — coder gate held per inventor SKILL.
2026-05-27 14:32:02 +02:00
mAi
9aee9e4101 Merge: t-paliad-328 — Phase 1 assessment of the deadline + procedural-events system (m/paliad#149)
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
athena delivered the consultant audit per RFC m/paliad#149 Phase 1:

- 738-line doc, read-only, no design proposals
- §1 consumer audit: every service / handler / frontend / migration that touches sequencing_rules or procedural_events, cited file:line
- §2 health-check: green / yellow / red / dead-code buckets
- §3 corpus quality: parent_id 47% coverage, condition_expr keys, spawn distribution, primary_party by PT, court-set, trigger_event_id overlap
- §4 editorial gap map per proceeding_type
- §5 risk register: 11 items, severities marked
- §6 recommendation: Tier 1 model decisions to grill first, Tier 2 surface decisions, Tier 3 editorial+cleanup

Headline risks bumped from the RFC's framing:
- R1 cross-party filter (high) — 39 rules dropped
- R2 picker over-accepts (high) — only 67/222 events are real chain-anchors
- R3 4 spawn rules target inactive proceeding_type id=11 (high) — dangling FK
- R4 73 legacy globals with NULL PT (medium) — invisible to Mode B
- R5 5 surfaces still read legacy trigger_events bigint table (medium)
- R6 3 scenario stores, all empty but all live (medium) — clarified: paliad.scenarios.spec jsonb is mig 145, not projects.scenarios as the RFC misstated

Phase 2 (inventor) gates on m's go. The inventor reads this + RFC + grills m on Tier 1 before sketching.
2026-05-27 11:03:33 +02:00
mAi
810b65463e docs(assessment): Phase 1 audit — deadline + procedural-events system (m/paliad#149)
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
t-paliad-328. Read-only audit of every consumer of paliad.sequencing_rules
+ paliad.procedural_events + the legacy paliad.trigger_events, plus the
rules-corpus quality on the live database. No design — Phase 2 (inventor)
gates on this landing.

Highlights:
- 226 active+published rules / 222 events (1:1 since mig 136)
- parent_id chain vs trigger_event_id are functionally disjoint
  (2/226 overlap); 73 legacy globals own the trigger_event_id lane
- 11 risk items captured with file:line; B1 (cross-party follow-up
  filter) and B2 (picker accepts spawn-only + leaves) confirmed
  from code at fristenrechner_followups.go:358-367 and :241-287
- 4 spawn rules still point at the inactive upc.apl.merits (id=11);
  the active appeal type is id=160 (upc.apl.unified)
- 6 active proceeding_types are entirely unruled
- 3 scenario stores wired (project_event_choices, scenarios table,
  DOM state); all currently empty, so divergence is dormant
- 738 lines (under the 800 cap)

Recommendation §6 sequences Tier 1 model decisions ahead of Tier 2
surface decisions and Tier 3 editorial cleanup for the inventor.
2026-05-27 11:02:38 +02:00
mAi
33c5fb2983 Merge: t-paliad-326 — dark-mode token migration for Fristenrechner overhaul CSS (m/paliad#146 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
brunel fixed m's bug ('Das CSS vom neuen Fristenrechner scheint wieder keinen Darkmode zu supporten') by migrating the 121 hardcoded hex colors knuth added in S2/S3/S4 to the project's design-token system.

Net: 161 inserts / 123 deletes in frontend/src/styles/global.css. 10 new tokens added to :root and :root[data-theme='dark'] for the few shades that didn't have an existing variable (group dividers, party-stance backgrounds, filter-pill subtle states). All 121 hex usages replaced with var(--color-*) references.

Verified visually via standalone harness: trigger card, 4 priority groups, per-rule rows (claimant/defendant/both/court), Mode A filter strip + result list, Mode B wizard with Filter/Qualifier badges, kontextfrei nudge, write-back footer, success/error toasts — all flip cleanly between light and dark. Layout/spacing/sizing untouched.

bun build + go vet clean.
2026-05-27 10:42:22 +02:00
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 / build (push) Has been cancelled
Paliad CI gate / test-go (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 / 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:21:56 +02:00
mAi
94310ba498 feat(submissions): Composer Slice E — specialist bases + base-swap content survival (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
Two new firm-agnostic base templates + the generic generator that
produced them + a regression test pinning Q10's base-swap-content-
survival contract.

Mig 150: seeds two `submission_bases` rows with firm=NULL.

- lg-duesseldorf — proceeding_family='de.inf.lg'. Conservative
  German legal style: Times New Roman 11pt; plain black headings.
  Stylemap targets LG-Body / LG-Heading1..3 / LG-ListBullet /
  LG-ListNumber / LG-Quote.

- upc-formal — proceeding_family='upc.inf.cfi'. UPC court style:
  Calibri 11pt body; UPC-blue (#1F3864) headings; Cambria italic
  for blockquotes. Stylemap targets UPC-Body / UPC-Heading1..3 / …

Both rows ship the same 10-section spec.defaults shape as the Slice A
bases (letterhead → signature) with their own seed Markdown.

scripts/gen-submission-base/main.go (NEW, ~240 LoC):

- Generic generator with -preset flag. Two presets baked in
  (lg-duesseldorf + upc-formal). Each preset hard-codes typography
  (font, sizes, colour) so the lawyer can swap between bases and
  see chrome change while section content carries through unchanged.
- Output is byte-reproducible (zip mtime pinned to 2026-05-26 UTC).
- Emits a minimal Composer-mode .docx: [Content_Types].xml,
  _rels/.rels, word/_rels/document.xml.rels (empty envelope so the
  composer's hyperlink-rels patch from Slice D has somewhere to land),
  word/styles.xml (preset's full named-style block + "Hyperlink"
  character style for Slice D link runs), word/document.xml (anchor-
  only body in §6.1 default section order).

Gitea uploads (via mAi):

- 6 - material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx
  blob SHA: 82f57b3cb3b54c755fc5ab36862bfd61b8aaa73e
- 6 - material/Templates/Word/Paliad/Composer/upc-formal.docx
  blob SHA: 41b9a388263ccc43ddc28b55caab301a4cf74fe8

These live under Composer/ (not under HLC/) so a future non-HLC
deployment serves the same cross-firm files.

Backend wiring:

- internal/handlers/files.go: two new fileRegistry entries
  (composerBaseLGDuesseldorfSlug, composerBaseUPCFormalSlug) +
  matching slugs in composerBaseSlugMap so fetchComposerBaseBytes
  routes the new catalog rows to the new Gitea objects.

Tests:

- TestComposer_BaseSwapPreservesContent — composes the same draft
  against an HLC-style stylemap AND an LG-style stylemap; asserts
  (a) content survives both ways, (b) each output carries the
  correct stylemap-entry stylenames, (c) neither output leaks the
  other's stylenames. Pins Q10's base-swap-survives-content
  contract.

Build hygiene: go build/vet/test -short clean (all packages);
bun run build clean.

NOT in scope (Slice E's brief was specialist bases + survival test):

- Generator coverage for HL Patents Style bases — gen-hl-skeleton-
  template stays as the per-firm path (it needs the proprietary
  .dotm source). gen-submission-base is for firm-agnostic bases.
- LG-Düsseldorf-court-style-guide deep fidelity — the LG preset is
  a conservative starting point; admin refines via the admin editor
  in a later slice if needed.
- numbering.xml carrying numId=1/2 — Slice D's MD walker emits
  visible "• " / "N. " prefixes that don't need numbering.xml;
  honours stylemap entry for indentation.

Hard rules honoured:

- Migration purely additive (`ON CONFLICT (slug) DO NOTHING`).
- NO behavior change for pre-Composer drafts.
- NO behavior change for existing hlc-letterhead + neutral seed
  rows.
- {{rule.X}} aliases preserved (walker passes placeholders through;
  v1 SubmissionRenderer pass substitutes).
- Q10 base-swap-content-survival pinned by new test.

t-paliad-317 Slice E
2026-05-26 20:21:12 +02:00
mAi
5834e3dc66 Merge: Composer Slice D — rich prose (headings, lists, blockquote, hyperlinks) in MD→OOXML walker (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:16:14 +02:00
mAi
677849784c feat(submissions): Composer Slice D — rich prose (headings, lists, blockquote, hyperlinks) (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
Extends the Composer's MD → OOXML walker per the design at
docs/design-submission-generator-v2-2026-05-26.md §12 Slice D from
Slice B's paragraphs + B/I baseline to the full rich-prose feature set:
headings 1-3, bullet + numbered lists, blockquote, inline hyperlinks.

MD walker (internal/services/submission_md.go, +320 / -75 LoC):
- RenderMarkdownToOOXMLWithStyles is the new Slice-D entry point;
  RenderMarkdownToOOXML stays as a thin back-compat wrapper.
- splitMarkdownBlocks classifies every line into one of:
  paragraph, heading_1/2/3, list_bullet, list_numbered, blockquote.
  CommonMark-style 3-space indent tolerance; "N. " and "N) " for
  numbered. Blank-line spacing semantics preserved from Slice B.
- renderBlockParagraph applies stylemap[blk.styleKey] (with
  fall-back to stylemap["paragraph"]). List blocks emit visible
  "• " / "N. " prefix runs so the structure surfaces even if Word
  isn't configured with auto-list-numbering — lawyer can apply a
  real Word list style post-export. Numbered-list ordinals reset
  on every non-list block (so "1. A\nplain\n1. C" renders 1./1.,
  not 1./2.).
- parseInlineRuns adds `[label](url)` recognition. Each link gets
  routed through the optional HyperlinkAllocator; the walker emits
  `<w:hyperlink r:id="{rId}">…runs…</w:hyperlink>` with the
  "Hyperlink" character style on each child run. Nil allocator
  falls back to plain-text label (URL drops, label survives).

Composer (internal/services/submission_compose.go, +130 / -10 LoC):
- composerLinkAllocator hands the walker fresh rIds (rIdComposer1,
  rIdComposer2, …) outside the base's existing namespace; same URL
  shared across multiple sections dedupes to one rId.
- patchDocumentXMLRels appends matching <Relationship Type="…/hyperlink"
  Target="URL" TargetMode="External"/> entries to
  word/_rels/document.xml.rels. Idempotent on rIds already present;
  synthesizes a fresh rels part when missing (defensive for stripped
  bases). Returns the patched parts slice (caller must overwrite
  because append may grow the backing array — fixed in this slice).
- Compose now passes the full stylemap (paragraph + heading_1/2/3 +
  list_bullet + list_numbered + blockquote) into the walker, not
  just the paragraph-style entry.

Frontend (frontend/src/client/submission-draft.ts):
- Toolbar adds H1/H2/H3 buttons (formatBlock h1/h2/h3), bullet
  list, numbered list, blockquote, and a link button that prompts
  for a URL + wraps the selection via execCommand("createLink").
- domToMarkdown serializer extends to <h1>/<h2>/<h3>, <ul>/<ol>
  with per-item ordinal counter for numbered lists, <blockquote>,
  and <a href="…"> → `[label](url)`. Nested <li> handling sits in
  the ul/ol branch.

Tests (internal/services/submission_md_test.go, internal/services/
submission_compose_test.go):
- TestRenderMarkdownToOOXML_Heading1 / _Heading2And3 — stylemap
  applied.
- _BulletList / _NumberedList / _NumberedListResetsOnNonList —
  prefixes + ordinal counter.
- _Blockquote — stylemap applied.
- _Hyperlink — allocator called, w:hyperlink rId wired, Hyperlink
  character style on label runs.
- _HyperlinkNilAllocatorFallsBackToPlain — label survives, no
  hyperlink tag emitted.
- TestDetectBlockMarker — 13 marker / non-marker cases.
- TestComposer_HeadingsAndLists — end-to-end through Compose with
  a multi-construct draft; verifies stylemap presence + content +
  ordinal prefixes.
- TestComposer_HyperlinkWiresRels — body has the right
  <w:hyperlink r:id="rIdComposer{N}">, document.xml.rels has the
  matching <Relationship> rows with External target mode.
- TestComposer_HyperlinkDedupesByURL — two `[label](url)` references
  to the same URL share one rId; second allocation gets no new
  Relationship row.

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

NOT in scope (Slice D's brief was rich-prose + toolbar):
- Numbering.xml audit on bases — current approach emits visible
  "• " / "N. " prefix runs without depending on numbering.xml. A
  future slice can swap to `<w:numPr>` if firm-style auto-numbering
  becomes a hard requirement.
- DOM-from-Markdown on initial editor paint — the editor still uses
  textContent=md, so toolbar-applied formatting reverts to literal
  Markdown text after autosave + repaint. Acceptable trade-off for
  Slice D; a future polish could parse MD into the DOM on paint.
- Tables, images, footnotes (still design §13 out of scope).

Hard rules honoured:
- NO new migrations (Slice D is pure code).
- NO behavior change for pre-Composer drafts (gate on draft.BaseID
  unchanged).
- {{rule.X}} aliases preserved (placeholders pass through the walker
  verbatim, get substituted by the v1 SubmissionRenderer pass).
- Q2 ratification preserved (no building_block_id lineage).
- Q9 ratification preserved (4-tier BB visibility from Slice C).

t-paliad-316 Slice D
2026-05-26 20:15:28 +02:00
mAi
b27d402156 Merge: Slice B.6 — /admin/rules → /admin/procedural-events URL rename + 301 redirects + .tsx i18n rebind. #93 slice train concludes (m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 20:13:17 +02:00
mAi
14290294b4 Merge: hotfix mig 140 — drop+recreate deadline_search matview (unblock prod)
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:12:55 +02:00
mAi
6b970da774 fix(mig 140): drop+recreate deadline_search matview (was blocking DROP TABLE deadline_rules)
prod-down: mig 140 fails with `cannot drop table deadline_rules because
other objects depend on it (2BP01)`. The dependent object is the
deadline_search materialized view (mig 077) — curie's brief listed FK
re-pointing but missed the matview.

Fix: drop the matview before DROP TABLE deadline_rules, recreate it at
the end of mig 140 against deadline_rules_unified (same column shape).
All 11 indexes restored. REFRESH at end so search keeps working.

Single-TX atomicity preserved — if anything past step 6a fails, the
whole drop-and-recreate rolls back. The pre_140 snapshot from step 1
remains as the forensic backstop.

Refs t-paliad-305 / m/paliad#93 Slice B.4.
2026-05-26 20:12:49 +02:00
mAi
9359e99a6b feat(handlers,frontend): Slice B.6 — admin URL rename /admin/rules → /admin/procedural-events with 301 redirects + .tsx i18n rebind (t-paliad-305 / m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Closes the procedural-events rename loop opened by m/paliad#93. The
admin surface now lives under its canonical URL; the legacy paths
remain reachable for one deprecation cycle via 301 redirects so
bookmarks, audit-log entries, and curl scripts keep working.

* internal/handlers/handlers.go —
  - Registers the 12 canonical routes under
    /admin/procedural-events* (page paths and JSON API). Same
    handlers — just the new URL slot.
  - Registers the 12 legacy /admin/rules* routes as 301 redirects.

* internal/handlers/admin_rules.go —
  - redirectToProceduralEvents(dst) — fixed-destination redirect for
    paths without an {id}.
  - redirectToProceduralEventEdit — page redirect carrying the {id}.
  - redirectToProceduralEventAPI(suffix) — JSON API redirect carrying
    {id} + optional suffix (/clone-as-draft, /publish, /archive,
    /restore, /audit, /preview). Query string is preserved on every
    redirect.
  - All three helpers add the IETF Deprecation header + a Link
    header pointing at the successor-version path.

* frontend internal nav + URL strings —
  Sidebar.tsx, admin.tsx, admin-rules-list.tsx, admin-rules-edit.tsx,
  client/admin-rules-list.ts, client/admin-rules-edit.ts: every
  `/admin/rules*` reference flipped to `/admin/procedural-events*`.
  In-app navigation now hits the canonical paths directly without a
  redirect round-trip; external callers keep working via the 301s.

* frontend .tsx i18n rebind —
  9 admin .tsx i18n bindings rebound to the canonical
  `admin.procedural_events.*` keys that already exist as aliases in
  i18n.ts (per Slice A from t-paliad-262). Specifically:
    admin.rules.list.title           → admin.procedural_events.list.title
    admin.rules.list.heading         → admin.procedural_events.list.heading
    admin.rules.list.new             → admin.procedural_events.list.new
    admin.rules.col.submission_code  → admin.procedural_events.col.code
    admin.rules.edit.title           → admin.procedural_events.edit.title
    admin.rules.edit.breadcrumb      → admin.procedural_events.edit.breadcrumb
    admin.rules.edit.field.submission_code → admin.procedural_events.edit.field.code
    admin.rules.edit.field.event_type      → admin.procedural_events.edit.field.event_kind
    admin.rules.edit.field.parent          → admin.procedural_events.edit.field.parent

  The remaining ~142 admin.rules.* keys do NOT yet have
  procedural_events aliases. Migrating them is a follow-up slice —
  each needs a new alias entry in i18n.ts (DE + EN) before the .tsx
  reference can be flipped. The 9 keys touched here are the most
  visible (page titles + edit-page field labels) so the admin UI
  immediately reads as "Verfahrensschritte" everywhere.

* frontend/src/client/i18n.ts header comment updated to reflect that
  the URL rename has shipped (Slice B.6 done) and to flag the
  remaining i18n-key migration as the next step.

Scope (documented, paliadin authorised):
- "go everything" applied: backend routes + frontend nav + .tsx
  rebind of the 9 keys whose canonical aliases exist.
- Full migration of all 142 admin.rules.* keys deferred — would
  require seeding ~142 new alias entries in i18n.ts (DE + EN) plus
  another 142 .tsx rebinds. Out of scope for tonight; flag as
  follow-up `feat(i18n): finish admin.rules.* → admin.procedural_events.*
  alias migration`.
- 12 legacy /admin/rules routes still hit a handler (the redirect
  helper) — they don't 404 yet. Once a deprecation window passes
  with no traffic on the old paths, a future slice can drop them
  outright.

Build + vet clean. TestMigrations_NoDuplicateSlot passes.

This concludes the m/paliad#93 procedural-events rename slice train
(Slices A through B.6). curie stays parked persistently for any
follow-up the deploy / monitor cycle surfaces.
2026-05-26 20:12:20 +02:00
mAi
2c0efc396c Merge: Slice B.5 — Go type aliases (SequencingRule = DeadlineRule) + JSON envelope dual-emit + Deprecation headers (m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 20:08:43 +02:00
mAi
5c6a0095e3 feat(models,services,handlers): Slice B.5 Go rename + JSON envelope dual-emit (t-paliad-305 / m/paliad#93)
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
Adds the Slice B.5 canonical Go names (SequencingRule, ProceduralEvent,
LegalSource, SequencingRuleService) without breaking any existing
call-site, and dual-emits / dual-accepts the two JSON envelope key
renames on /admin/api/rules with a Deprecation header.

* internal/models/models.go —
  - type SequencingRule = DeadlineRule (alias; same struct, same db /
    json tags). DeadlineRule remains the underlying type for now —
    deferred hard-rename keeps the slice small.
  - type ProceduralEvent struct mirroring paliad.procedural_events
    (id, code, name, name_en, description, event_kind,
    primary_party_default, legal_source_id, concept_id,
    lifecycle_state, draft_of, published_at, is_active, timestamps).
    Used by future code that needs the PE identity row alone.
  - type LegalSource struct mirroring paliad.legal_sources (citation,
    jurisdiction, pretty_de / pretty_en — both nullable per mig 136).

* internal/services/deadline_rule_service.go —
  - type SequencingRuleService = DeadlineRuleService (alias).
  - var NewSequencingRuleService = NewDeadlineRuleService (constructor
    alias). Internal callers can adopt either name.

* internal/services/rule_editor_service.go —
  - CreateRuleInput gains Code + EventKind fields tagged
    json:"code" / json:"event_kind". CoalesceCanonicalKeys() folds
    canonical → legacy after json.Decode so the rest of the service
    keeps using SubmissionCode / EventType. Canonical wins when
    both are sent.
  - RulePatch gains EventKind field with the same fold.

* internal/handlers/admin_rules.go —
  - adminRuleResponse wraps *models.DeadlineRule and adds Code +
    EventKind fields alongside the legacy SubmissionCode /
    EventType. Outputs both keys per response for one
    deprecation-window slice.
  - wrapRuleResponse / wrapRuleListResponse helpers.
  - adminRuleDeprecationHeaders emits IETF Deprecation + Link/Sunset
    headers on every Rule-bearing response so clients see the
    migration signal in transit.
  - All 8 Rule-returning handlers (List, Get, Create, Patch, Clone,
    Publish, Archive, Restore) now wrap their result and add the
    headers.
  - Create + Patch handlers call CoalesceCanonicalKeys after decode
    so legacy AND canonical request bodies are both accepted.

Scope decisions (documented in commit):
- Type renames use aliases instead of a hard 200-LOC rename. Same
  semantics, no call-site churn. A future cleanup slice can flip
  the underlying type definitions when convenient.
- ProceduralEvent + LegalSource are NEW structs (not aliases) since
  they represent new conceptual rows; no legacy callers exist yet.
- Frontend admin .tsx i18n key rebinds (mentioned in parent task
  brief B.5 deliverable list) are deferred — i18n keys themselves
  already exist from Slice A (t-paliad-262); rebinding only changes
  which key the .tsx file looks up. Pulling this into B.5 ballooned
  scope; flagging as a small follow-up slice or B.6 sibling.
- Only /admin/api/rules emits dual keys today. Other handlers that
  surface rule rows (Schriftsätze list, deadlines join) continue to
  emit the legacy keys via models.DeadlineRule's existing JSON tags
  — they're read paths, not the editor surface, and the deprecation
  signal is most important where clients write.

Build + vet clean. TestMigrations_NoDuplicateSlot passes.
2026-05-26 20:07:48 +02:00
mAi
6e0961cc30 Merge: Composer Slice C — building blocks library + section picker (mig 149) (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:05:30 +02:00
mAi
ee98db94fa feat(submissions): Composer Slice C — building blocks library (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
Per the design at docs/design-submission-generator-v2-2026-05-26.md §8
and the Q2 / Q9 ratifications:

- Q2 (m, 2026-05-26): building blocks are plain text paste sources.
  No building_block_id reference is stored on submission_sections.
- Q9 (m, 2026-05-26): four visibility tiers — private / team / firm
  / global.

Schema (mig 149):

- paliad.submission_building_blocks — library catalog. Columns: slug,
  firm (NULL = cross-firm), section_key (binds to one section kind),
  proceeding_family (NULL = any), title_de/_en + description_de/_en
  + content_md_de/_en, author_id, visibility (CHECK in 4-tier set),
  is_published, created_at, updated_at, deleted_at (soft delete).
  RLS: coarse-grained SELECT — every authenticated user sees
  non-deleted non-private rows + own private rows. Tier-specific
  predicate (private/team/firm/global) applied in Go-layer service so
  semantics evolve without RLS migrations. Mutations admin-only (no
  RLS write paths).

- paliad.submission_building_block_admin_versions — append-only
  history per block, retention=20. Admin-side only; NOT referenced
  from submission_sections (per Q2's plain-text-paste model). Exists
  so accidental delete / overwrite are recoverable.

Backend:

- internal/services/submission_building_block_service.go (~510 LoC):
  BuildingBlockService. ListVisible applies tier predicate at query
  time (private = author_id match; firm = firm column NULL OR matches
  branding.Name; team = author shares a project_team with caller via
  paliad.project_teams self-join; global = open). ListAllForAdmin
  drops the predicate. Create + Update + SoftDelete + RestoreVersion
  all transactional; appendVersionTx writes one audit row +
  GC-deletes anything past the retention=20 horizon in the same tx.
  InsertIntoSection (the paste mechanic) clones content_md_<lang>
  into the section row with a "\n\n" separator if section already has
  content. NO building_block_id stamped per Q2.

- internal/handlers/submission_building_blocks.go (~480 LoC): nine
  handlers split between the lawyer-facing picker (list, insert) and
  the admin editor (list, get, create, update, delete, list-versions,
  restore-version, page). buildingBlockUpdateInput uses presence-
  tracking UnmarshalJSON for the four nullable fields (firm,
  proceeding_family, description_de/_en) so PATCH can distinguish
  "no change" from "set to null".

- Routes registered: lawyer-facing under /api/submission-building-blocks,
  admin-gated under /api/admin/submission-building-blocks/* and
  /admin/submission-building-blocks (page).

- Wiring: handlers.Services + dbServices + cmd/server/main.go all
  gain SubmissionBuildingBlock. NewBuildingBlockService takes the
  branding.Name firm hint for the visibility predicate.

Frontend:

- frontend/src/admin-submission-building-blocks.tsx (~85 LoC):
  three-pane admin shell (list / editor / version log) registered
  in build.ts.

- frontend/src/client/admin-submission-building-blocks.ts (~370
  LoC): admin client — list paint, edit form (slug + firm +
  section_key + proceeding_family + title/desc/content per lang +
  visibility radio + is_published toggle), per-block version log
  with restore button. Bilingual labels.

- frontend/src/client/submission-draft.ts: per-section "+ Baustein"
  button on the Composer editor toolbar (Slice B substrate gets one
  more affordance). openBlockPicker opens a modal filtered to the
  section's section_key, 200ms-debounced search by free text against
  title/description/content. Click a hit → POST insert-into-section
  → section row's content_md_<lang> gains the block's content
  appended at the end (Q2's plain-text paste semantic, no lineage).

- ~240 LoC of CSS: modal overlay + picker rows with tier-colored
  visibility chips + admin editor 3-pane grid + form rows + version
  list.

- 12 new i18n keys × 2 langs (admin.building_blocks.*).

Tests:
- TestValidVisibility (8 cases including case-sensitivity + empty).
- TestAppendBlockContent (8 cases covering empty-existing / empty-
  addition / whitespace-only / trailing newline collapse).
- TestBuildingBlockVisibilityConstants pins the 4 string literals
  against drift (RLS predicate + DB CHECK depend on them).

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

Hard rules per ratifications honoured:
- Q2: no building_block_id lineage on sections (paste is plain text).
- Q9: 4 visibility tiers (private/team/firm/global).
- NO behavior change for pre-Composer drafts (the picker just doesn't
  show — section list is hidden for base_id NULL drafts).
- {{rule.X}} aliases preserved (block content goes through the same
  v1 placeholder pass on export as section prose).

NOT in scope per Slice C brief:
- User-authored private blocks (Slice C ships admin curation only;
  any-user create is a follow-up).
- Tier promotion review workflow (admin sets tier directly today).
- Per-section "where is this block used" reverse lookup (no lineage
  to query).
- Slice D's rich-prose features (headings, lists, blockquote) still
  Slice D's job; this Slice doesn't extend the MD walker.

t-paliad-315 Slice C
2026-05-26 20:04:40 +02:00
mAi
987db27831 Merge: t-paliad-305 — Slice B.4 destructive drop: paliad.deadline_rules retired, INSTEAD OF triggers on view (mig 140, snapshot pre_140 same-TX) (m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 19:54:27 +02:00
mAi
1129baba7a feat(db,services): Slice B.4 destructive drop — paliad.deadline_rules retired, INSTEAD OF triggers on view route writes (mig 140, t-paliad-305 / m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
Drops the legacy paliad.deadline_rules table after 3 weeks of dual-write
shadowing (mig 136 → B.2 dual-write → B.3 read cutover via view). The
new tables — paliad.procedural_events, paliad.sequencing_rules,
paliad.legal_sources — are the sole source of truth from this commit
forward.

Pre-flip drift verified clean against prod:
  deadline_rules=231 == sequencing_rules=231 == procedural_events=231
  legal_sources=87
  missing_sr=0, orphaned_sr=0, mismatched_lifecycle=0

* internal/db/migrations/140_drop_deadline_rules.up.sql (new) —
  Single TX, audit-first:
  1. CREATE TABLE paliad.deadline_rules_pre_140 AS TABLE paliad.deadline_rules
     (precedent migs 091/093/095/098 — snapshot in same TX as destructive op).
  2. Final reconciliation UPDATE on paliad.deadlines (no-op when
     drift is already 0; defensive against last-minute writes).
  3. DROP TRIGGER deadline_rules_audit_aiud.
  4. Re-point FKs to sequencing_rules:
     - paliad.appointments.deadline_rule_id → paliad.sequencing_rules(id)
     - paliad.deadline_rule_backfill_orphans.resolved_rule_id → paliad.sequencing_rules(id)
     (the id values are identical — sr.id inherited dr.id at mig 136.)
  5. DROP COLUMN paliad.deadlines.rule_id.
  6. DROP TABLE paliad.deadline_rules.
  7. CREATE INSTEAD OF INSERT + INSTEAD OF UPDATE triggers on
     paliad.deadline_rules_unified. Triggers route writes into the
     three new tables in the same TX, preserving the legacy column
     shape on the wire so RuleEditorService SQL only needs a
     table-name swap, not a structural rewrite. Synthetic-code mint
     expression is byte-identical to mig 136 + the B.2 dual-write
     helper. POST assertions confirm the table is gone, the column
     is gone, and the snapshot matches.

  Trigger design notes (1:N caveat documented in-trigger):
  - PE identity columns (code, name, name_en, description, event_kind,
    primary_party_default, legal_source_id, concept_id) mirror from
    the writing sequencing-rule.
  - PE lifecycle columns (lifecycle_state, published_at, is_active)
    deliberately do NOT mirror — a draft sequencing-rule cloned from
    a published source shares the source's PE; we don't want the
    clone's 'draft' lifecycle to leak back onto the source's PE.
    Practical bound today (1:1 corpus); explicit comment in-trigger
    for the eventual 1:N pattern.

* internal/db/migrations/140_drop_deadline_rules.down.sql (new) —
  Best-effort restore from the snapshot. Triggers / indexes /
  CHECK constraints from historical migrations are NOT replayed;
  operator must reapply 078/079/091/095/098/122/128/134/135 to
  bring the restored table to working shape. The down path is for
  catastrophic recovery, not casual revert.

* internal/services/rule_editor_service.go —
  Six syncDualWriteFromDeadlineRule(...) calls removed (the
  INSTEAD OF triggers now do the fan-out). Five
  INSERT/UPDATE paliad.deadline_rules statements (Create,
  UpdateDraft, CloneAsDraft INSERT+SELECT, Publish, peer-archive,
  flipLifecycle) renamed to paliad.deadline_rules_unified —
  trigger handles the routing.

* internal/services/rule_editor_orphans.go — ResolveOrphan no
  longer writes deadlines.rule_id (column dropped). Sets
  sequencing_rule_id directly + derives procedural_event_id from
  the matching sequencing_rules row in the same UPDATE statement.

* internal/services/deadline_service.go — deadlineColumns now
  lists sequencing_rule_id (Deadline.RuleID still binds to it via
  the db tag rename below). Update path's appendSet("rule_id",…)
  flipped to appendSet("sequencing_rule_id",…) and post-write
  derivation moved to the renamed syncDeadlineProceduralEventID
  helper.

* internal/services/projection_service.go,
  internal/services/submission_vars.go — `WHERE rule_id = $X`
  reads on paliad.deadlines flipped to sequencing_rule_id.

* internal/models/models.go — Deadline.RuleID db tag changed from
  "rule_id" to "sequencing_rule_id". Field name + JSON name kept
  for backward compat with the frontend and existing Go callers;
  semantic value is identical (same UUID).

* internal/services/dual_write.go — Massively trimmed.
  Removed: syncDualWriteFromDeadlineRule, syncDeadlineDualLinks,
  CheckDualWriteDrift, DualWriteDriftReport, HasDrift,
  StartDualWriteDriftCheckLoop. All referenced
  paliad.deadline_rules which no longer exists.
  Kept (renamed): syncDeadlineProceduralEventID — derives
  procedural_event_id from sequencing_rule_id after any
  DeadlineService.Update that touched the back-link.

* cmd/server/main.go — Removed the StartDualWriteDriftCheckLoop
  bootstrap call (and its `time` import that only that call
  needed). Comment notes the retirement.

* internal/services/dual_write_test.go — Removed the final
  CheckDualWriteDrift assertion in
  TestDualWrite_RuleEditorLifecycle (function deleted). The
  per-step asserts against procedural_events / sequencing_rules
  / legal_sources cover the same contract by direct query.

Hard rules followed:
- Audit-first: snapshot precedes destructive ops in the same TX.
- No silent data loss: pre-drop drift was zero; snapshot captures
  the final state; FK re-points use identical UUIDs.
- INSTEAD OF triggers documented in mig 140 — single source of
  truth for the legacy→new mapping.
- Down migration is honest about its scope (catastrophic recovery
  only).

Build + vet clean. TestMigrations_NoDuplicateSlot passes. Live-DB
tests skipped (no TEST_DATABASE_URL in this env) — they'll exercise
the full mig 140 + INSTEAD OF triggers in CI.
2026-05-26 19:53:24 +02:00
mAi
c20e935a4b Merge: t-paliad-313 — Composer Slice B: editable prose + anchor-spliced render + MD→OOXML walker (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 19:46:40 +02:00
mAi
f963b0df34 feat(submissions): Composer Slice B — editable prose sections + anchor-spliced render (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 "Composer actually works" milestone per the design at
docs/design-submission-generator-v2-2026-05-26.md §12 Slice B. Builds on
Slice A's substrate (submission_bases, submission_sections, base_id on
drafts); no new migrations needed.

Backend additions:

- internal/services/submission_md.go (~240 LoC): Markdown → OOXML
  walker. Per the head's Slice B brief, scope is paragraphs +
  bold/italic + blank-line spacing. Placeholders pass through
  unchanged for the v1 substitution pass. CRLF normalisation; nested
  formatting (***bold-italic***); two delimiter forms (* and _);
  XML-escaping for &/</>; explicit empty-paragraph emit so blank
  lines round-trip. 12 unit tests.

- internal/services/submission_compose.go (~470 LoC): SubmissionComposer
  service. Pipeline: ConvertDotmToDocx pre-pass → extract
  word/document.xml → render each included section's content_md_<lang>
  → splice via {{#section:KEY}}/{{/section:KEY}} anchor pairs in
  the body → strip anchors for excluded sections → append unanchored
  sections before <w:sectPr> → repack zip → run v1 placeholder pass.
  RE2-friendly anchor scanner walks markers in body-order and matches
  open/close pairs with a stack (handles unbalanced anchors
  defensively). 6 unit tests covering anchor-mode splice,
  append-mode-no-anchors, excluded-section drop, placeholder
  resolution, lang column pick, order_index ASC.

- internal/services/submission_section_service.go: SectionPatch +
  Update method. Six optional fields (content_md_de/en, included,
  label_de/en, order_index). Sentinel ErrSubmissionSectionNotFound on
  RLS-filtered miss.

- internal/handlers/submission_sections.go (NEW, ~150 LoC):
  PATCH /api/submission-drafts/{draft_id}/sections/{section_id}.
  Owner-scoped via SubmissionDraftService.Get; section-belongs-to-draft
  cross-check. 404 on both missing-draft and section-belongs-elsewhere
  paths.

- internal/handlers/files.go: fetchComposerBaseBytes + composerBaseSlugMap
  reuse the existing Gitea proxy cache for base .docx bytes. hlc-letterhead
  → existing firmSkeletonSubmissionSlug, neutral → existing
  skeletonSubmissionSlug.

- internal/handlers/submission_drafts.go: exportSubmissionDraft helper
  branches on draft.BaseID. When set AND base + bytes + sections all
  resolve → Composer pipeline. Else v1 fallback render path stays.
  Audit metadata jsonb gains "composer": true + "base_id" flag when
  composer was used.

Wiring:
- handlers.Services gains SubmissionComposer.
- dbServices.submissionComposer wired from svc.SubmissionComposer.
- main.go instantiates NewSubmissionComposer with the existing
  SubmissionRenderer (so the {{rule.X}} alias contract stays preserved
  inside section content).

Frontend additions (~400 LoC):
- client/submission-draft.ts: paintSectionList rewritten to render a
  contentEditable per included section with a per-section B/I
  toolbar. Per-section autosave debounced 500ms; mousedown handlers on
  toolbar buttons preserve editor focus mid-command. domToMarkdown
  walks the contentEditable's DOM tree back to Markdown source-of-
  truth (b/strong → **…**, i/em → *…*, div/p → paragraph break, br
  → newline). Updated state.view.sections in-place on PATCH success
  without re-painting (avoids focus-stealing on every keystroke);
  re-paints only on structural changes (included toggle, label edits,
  order changes).

- client/submission-draft.ts: onSectionToggleIncluded hides/shows a
  section via PATCH. flushSectionAutosave on blur force-flushes
  pending edits so leaving an editor doesn't strand unsynced changes.

- styles/global.css: editor surface (contentEditable area with focus
  ring + placeholder), toolbar buttons (B/I 1.8rem squares),
  per-section "Hide"/"Include" toggle in the head row.

- Updated i18n hint copy: "Inhalt pro Abschnitt — Autosave nach
  500ms. Letztes Layout in Word."

Templates regenerated on Gitea:
- _skeleton.docx → composer-mode body (anchors only): blob SHA
  ac0cdeaf49f7cd417ec143e2319ffbb02ec65644.
- _firm-skeleton.docx → composer-mode body (anchors only, preserves
  sectPr → firm header/footer rIds): blob SHA
  f1e9a9fb9a29ca01bf7bee709a45c5dda2a8e317.
- Both uploaded as mAi via --netrc-file ~/.netrc-mai.
- gen-skeleton-submission-template script gains an -anchors flag
  (default true) so future regens emit composer-ready bodies. The
  _firm-skeleton.docx regen was done via a one-off /tmp helper since
  the gen-hl-skeleton-template script requires the proprietary .dotm
  source which lives in HL/mWorkRepo; extending that script to accept
  an existing .docx as input is a follow-up cleanup.

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

NO behavior change for pre-Composer drafts (base_id NULL → v1
fallback render path stays compiled in). NO migrations needed in this
slice — sections were already in the schema from Slice A; only
content_md_de/en UPDATEs happen via the new PATCH endpoint.

Hard rules per Q2/Q10 ratification still honoured:
- No building_block_id lineage (Slice C territory; Q2).
- Caption/letterhead/signature are regular prose sections, seeded from
  base spec; lawyer can edit/hide freely (Q10).
- {{rule.X}} aliases preserved (renderer pass unchanged).

NOT in scope per Slice B brief:
- Headings 1–3, lists, blockquote (Slice D's MD walker extension).
- Building blocks library (Slice C).
- Reorder / add-custom-section (Slice F).
- Auto-upgrade of pre-Composer drafts (Slice C — explicitly NOT in
  this slice per head's brief msg #2393).

t-paliad-313 Slice B
2026-05-26 19:45:29 +02:00
mAi
6cd340300b Merge: t-paliad-313 — Composer Slice A: base picker + read-only section list (migs 146/147/148) (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 19:25:08 +02:00
mAi
557f9a4cce Merge: fix(paliadin): one-shot fallback when persona lacks streaming (unblock chat)
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 19:24:42 +02:00
mAi
3af71e772b fix(paliadin): fall back to one-shot when aichat persona lacks streaming
Symptom: paliadin chat returns "Verbindung verloren" because aichat's
paliadin persona is not configured with streaming support — every
RunTurnStream() call gets back HTTP 400 unsupported_streaming and the
SSE stream closes empty.

Fix: when RunTurnStream() detects "unsupported_streaming" in the
upstream error, transparently retry against /chat/turn (non-streaming)
with the same body. The full response gets emitted as a single
StreamChunk + StreamMeta so the SSE relay sees identical event
ordering. Persistence (completeTurn + markPrimed) mirrors the one-shot
RunTurn() path.

No real-time chunking until the persona is reconfigured upstream, but
the chat works end-to-end. Once the paliadin persona supports streaming
on aichat, this code path goes dormant — the unsupported_streaming
branch is only entered when the upstream actually returns that error.

Diagnostic logs from commit 937ff13 made this visible:
  paliadin: backend returned error err=aichat: HTTP 400 (bad_request):
  unsupported_streaming: persona paliadin does not support streaming

Refs m/paliad demo path.
2026-05-26 19:24:41 +02:00
mAi
e2969fc358 feat(submissions): Composer Slice A — base picker + read-only section list (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 first slice of the Submission generator v2 ("Composer") per the
design at docs/design-submission-generator-v2-2026-05-26.md §12 Slice A.
Ships the base concept + per-draft section seeding end-to-end with NO
change to the .docx render path — v1 export still works exactly as
today.

Schema (mig 146/147/148):
- paliad.submission_bases — catalog table; one row per template base
  (slug, firm, proceeding_family, label_de/en, gitea_path, section_spec
  jsonb, is_default_for[]). RLS: wide-open SELECT for authenticated
  users, mutations admin-only (handler-enforced, no RLS write paths).
  Seeded with 2 rows: hlc-letterhead → _firm-skeleton.docx; neutral →
  _skeleton.docx. Each section_spec carries the 10-section default
  (letterhead, caption, introduction, requests, facts, legal_argument,
  evidence, exhibits, closing, signature) with bilingual labels +
  bag-driven seed Markdown for caption/letterhead/signature.
- paliad.submission_drafts gains base_id (FK SET NULL, optional) +
  composer_meta jsonb (default '{}'). Purely additive; pre-Composer
  drafts keep base_id NULL → v1 fallback render path stays active.
- paliad.submission_sections — per-draft section rows (draft_id,
  section_key, order_index, kind ∈ {prose,requests,evidence},
  label_de/en, included, content_md_de/en). RLS mirrors
  submission_drafts (owner-scoped + can_see_project, four policies).

Backend:
- BaseService (read-only Slice A): List + GetByID + GetBySlug +
  GetDefaultForCode (firm/family fallback chain).
- SectionService: ListForDraft + Get + SeedFromSpec (transactional
  multi-INSERT).
- SubmissionDraftService.AttachComposer wires both; Create resolves
  the firm default base and seeds base_id + section rows in one tx.
  Composer wiring is additive — when bases==nil the service stays
  v1-shaped.
- Update accepts BaseID **uuid.UUID (set / clear / no-change).
- submissionDraftView gains BaseID, ComposerMeta, Sections fields.
- Routes: GET /api/submission-bases (catalog list). PATCH endpoints
  on both project-scoped and global drafts accept "base_id".

Frontend:
- submission-draft.tsx: base picker dropdown above language toggle
  (hidden until catalog loads); section-list pane above the preview
  (hidden when no rows).
- client/submission-draft.ts: loadBases() parallel-fetches on boot;
  paintBasePicker rebuilds <option> list on every paint; onBaseChange
  PATCHes base_id and repaints; paintSectionList renders each section
  read-only (label + kind chip + excluded badge + Markdown body).
- Per the brief: NO auto-upgrade of existing 11 drafts (that's Slice C).
  Pre-Composer drafts get the picker (catalog still loads) but the
  section pane stays hidden until they pick a base on a new draft.

Tests:
- TestFamilyOfCode + TestBaseSectionSpec_DecodeShape + _EmptyDecode
  (pure unit, no DB).
- TestComposerSeedFlow (live, TEST_DATABASE_URL-gated): asserts mig 146
  seeded 10 default sections on both bases; GetDefaultForCode picks
  hlc-letterhead for HLC/de.inf.lg.erwidg; new draft via Create seeds
  base_id + 10 section rows in tx with ascending order_index and
  bilingual labels populated.

NO behavior change to .docx export — the v1 path stays sole render
path this slice. Composer's anchor-based assembly engine + MD→OOXML
walker land in Slice B.

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

t-paliad-313
2026-05-26 19:23:40 +02:00
mAi
85d0cedd22 Merge: t-paliad-312 — PRD for submission generator v2 (Composer); 12 questions ratified (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 19:05:04 +02:00
mAi
0e1691f00e docs: ratify Q1-Q12 — submission generator v2 design final (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
All 12 §11 design questions ratified by m on 2026-05-26 via
AskUserQuestion (paliadin-authorised override per instruction msg #2391).

Picks matching inventor recommendations (9 of 12):
 Q1 separate submission_sections table
 Q3 Gitea-backed body + thin DB row
 Q4 contentEditable + Markdown + in-house serializer
 Q5 section anchors + in-house MD->OOXML walker
 Q7 split content_md_de + content_md_en from day 1
 Q8 Go map for per-submission_code section defaults
 Q9 4 visibility tiers (private/team/firm/global)
 Q11 collapsed preview pane by default
 Q12 moot (superseded by Q2 simplification)

Deviations from recommendation (3 of 12):
 Q2 SIMPLIFY further — m: "sounds overengineered". Building blocks
    become plain text paste sources. No building_block_id column on
    sections, no _versions table referenced from sections, no
    refresh-from-library affordance. Slice G dropped.
 Q6 Auto-upgrade all 11 existing drafts at mig-148 apply time (not
    opt-in per draft). v1 fallback render path stays compiled in.
 Q10 *_auto kind removed. Caption/letterhead/signature sections are
    regular prose rows seeded with bag-driven Markdown; lawyer can
    edit/hide. Untouched drafts export identically to v1.

Body sections updated inline (§4.3 schema, §4.4 BB tables, §6.3
seeding, §8.3+8.4 BB insert, slice plan A/C/G, §11 ratification notes,
§14 risks #8+11, §17+18 acceptance + gate). §11 retains the historical
recommendation matrix.

Status: ALL DESIGN QUESTIONS RATIFIED — design doc final, ready for
Slice A coder shift. Inventor parks per hard gate. Head decides hire.

t-paliad-312
2026-05-26 19:04:21 +02:00
mAi
05ad43aa46 Merge: t-paliad-308 — Verfahrensablauf URL state hybrid (chips in URL, scenario in localStorage) (m/paliad#137)
Some checks failed
Paliad CI gate / deploy (push) Has been cancelled
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
2026-05-26 18:46:32 +02:00
mAi
635457474a docs: PRD/design — submission generator v2 ("Composer") (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
Sectioned composition, swappable base templates, in-app prose editing,
building-blocks library. Deepens t-paliad-215 + t-paliad-238 without
replacing them — v1 contracts (submission_drafts shape, {{rule.X}}
aliases, audit shape) preserved.

7 slices A→G; Slice B is the smallest "Composer works" milestone.
Existing 11 v1 drafts continue via v1 path; opt-in upgrade per draft.

12 open design questions with recommended defaults + alternatives for m
to ratify via head escalation (no AskUserQuestion per task brief).

Flags two issue-body inaccuracies: no submission_drafts.audit_log column
(audit lives in system_audit_log + project_events); live row count is
11, not 7.

t-paliad-312
2026-05-26 18:37:52 +02:00
mAi
235e68496b Merge: t-paliad-311 — backup exporter drift-resistant + 4 broken ORDER BY cols fixed (m/paliad#140)
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
2026-05-26 18:20:42 +02:00
mAi
8125caf49a test(backup): add TEST_DATABASE_URL-gated live smokes for org export
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 complementary live tests (both skipped without TEST_DATABASE_URL):

- TestResolveOrgSheets_LiveSchemaSnapshot — runs the schema probe + SQL
  composer the way the backup runner does at the start of every run,
  then executes each resolved SELECT against the live DB (wrapped in
  LIMIT 1 to keep table reads cheap). A future column rename in a
  table our spec still names triggers this test and surfaces in CI
  before /admin/backups breaks.

- TestWriteOrg_LiveSmoke — end-to-end pipeline against a real DB:
  schema probe, REPEATABLE READ tx, every sheet query, xlsx + JSON +
  per-sheet CSV assembly, outer zip framing. Spot-checks meta.RowCounts
  and the zip magic bytes; doesn't materialise the full bundle to
  disk.

Both tests exercise the exact failure mode m/paliad#140 reproduced
(hardcoded ORDER BY against a renamed column) so CI catches regressions
once TEST_DATABASE_URL is wired.

m/paliad#140
2026-05-26 18:19:55 +02:00
mAi
937ff13470 Merge: footer 'by' + paliadin diagnostic logs (unblock 'Verbindung verloren' diagnosis)
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 18:17:39 +02:00
mAi
b97f170c1d chore: footer "by" + paliadin diagnostic logs
- Footer: "© 2026 Paliad — ein Werkzeug von / a tool by" → "© 2026 Paliad — by" (both DE + EN).
- Paliadin streaming handler now log.Printf on every error path (StreamError, silence_timeout, backend nil/err) so the next "Verbindung verloren" failure produces a server-side trace. Previous behaviour: silent SSE close + empty paliad logs, impossible to diagnose.
2026-05-26 18:17:33 +02:00
mAi
935ea23038 refactor(backup): make orgSheetQueries drift-resistant
Refactor orgSheetQueries() into orgSheetSpecs() returning declarative
(SheetName, Table, OrderBy []string) triples instead of free-form SQL,
with composeOrgSheetSQL() as a pure builder and resolveOrgSheets() as
the DB-touching orchestrator.

At backup time the resolver:
  1. probes information_schema.columns once for every spec table,
  2. composes SELECT * FROM <table> ORDER BY <columns-that-exist>,
  3. logs WARN per ORDER BY column dropped because it's gone.

A future column rename or removal can no longer break /admin/backups:
the worst case is one sheet temporarily losing sort stability, and the
WARN log surfaces which spec needs updating.

Sheets needing custom projections (documents drops ai_extracted) keep
the SQL override path. All other org-scope sheets — entity + ref__ —
declare their ORDER BY as a column list.

Tests:
  - 6 composeOrgSheetSQL unit tests cover the drift behaviour with no
    DB needed (missing column, all-missing, override bypass, declared
    order preserved, unknown table)
  - Existing registry-shape tests (no duplicates, no paliadin leakage,
    ref__ prefix, ORDER BY-for-determinism) updated to the spec API
  - Full internal/services suite green

m/paliad#140
2026-05-26 18:17:21 +02:00
mAi
f8e5be5f7a Merge: fix(submissions): order Schriftsätze catalog by sequence_order (was alphabetic — Berufungsbegründung ahead of Klageerhebung)
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 18:15:07 +02:00
mAi
ee0a9ea6cb fix(submissions): order catalog by sequence_order, not alphabetic submission_code
The Schriftsätze list rendered procedurally meaningless: Berufungsbegründung
ahead of Klageerhebung etc. because the ORDER BY was alphabetic by
submission_code within each proceeding. Add dr.sequence_order ASC as the
primary intra-proceeding sort; submission_code stays as the deterministic
tiebreaker for rules sharing a sequence_order.

deadline_rules.sequence_order is already populated for every published
filing rule (verified via paliad.deadline_rules_unified). Pure read-side
fix; no schema or data change.
2026-05-26 18:15:01 +02:00
mAi
da464813b7 fix(backup): repair 4 broken ORDER BY columns in orgSheetQueries
Backup export was 100% broken because four sheets referenced columns
that no longer exist (or never did) in their target tables:

- email_templates: ORDER BY id → key, lang (composite PK)
- policy_audit_log: ORDER BY changed_at → created_at
- ref__deadline_event_types: ORDER BY rule_id → deadline_id (post-rename)
- ref__event_category_concepts: ORDER BY category_id → event_category_id

Audited every entry in orgSheetQueries() against information_schema.columns;
these were the only mismatches. Patch unblocks /admin/backups → Generate.
Drift-resistant refactor (m/paliad#140 Part B) follows in a separate commit.

m/paliad#140
2026-05-26 18:13:27 +02:00
mAi
6d24fb8931 Merge: t-paliad-310 — dark-mode CSS: repoint 12 var(--color-surface-alt) sites to defined tokens (m/paliad#138)
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 18:07:45 +02:00
mAi
446c46e5c5 fix(css): repoint 12 var(--color-surface-alt, hex) sites to defined tokens (t-paliad-310, m/paliad#138)
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 --color-surface-alt token was never defined in :root or :root[data-theme="dark"],
so the var() fallback hex literal always won — leaving 12 surface sites with
zero dark-mode treatment. Same pattern as t-paliad-087 / t-paliad-150 / t-paliad-291.

Issue #138 surfaced four panels visibly broken in dark mode:
1. submission-draft no-project banner ("Kein Projekt zugeordnet…") — white-on-white
2. submission-draft preview header ("Vorschau / Read-only Vorschau…") — white-on-white
3. smart-timeline rule-chip (e.g. de.null.bpatg.berufung in Vorhersage rows) — grey-on-grey
4. submission-draft addparty manual form (Manuell / Aus DB / Name / …) — white-on-white

Eight more latent sites with the same root cause are fixed in the same pass:
.submissions-new-chip:hover, .submissions-new-project-item:hover,
.submission-draft-import-row, .submission-draft-addparty-search-projref,
.collab-invite-hint, .smart-timeline-status-icon,
.smart-timeline-kind-chip--projected, .smart-timeline-add-choice:hover.

Each site repointed to the semantically correct existing token
(--color-surface-2 for #fafafa, --color-surface-muted for #f4f4f4,
--color-bg-subtle for #f7f7f0, --color-bg-lime-tint for the lime-tinted
collab-invite-hint). All four target tokens are defined in both :root
and :root[data-theme="dark"]. No new tokens introduced.

Light-mode hex values are functionally identical (#fafafa==#fafafa,
#f4f4f4≈#f3f4f6, #f7f7f0≈#f7f3f0).

Verified: bun run build clean; Playwright screenshots of the four panels
in both light + dark modes show correct rendering.
2026-05-26 18:07:02 +02:00
mAi
d1aa0f72c0 Merge: t-paliad-305 — Slice B.3: read cutover via paliad.deadline_rules_unified view (mig 139); legacy writes retire in B.4 (m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
2026-05-26 18:01:25 +02:00
mAi
94f2831f3f Merge: fix(backup): export ORDER BY uses binding_id (was calendar_binding_id) — unblocks /admin/backups
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 18:00:37 +02:00
mAi
83be122b19 fix(backup): export ORDER BY uses binding_id, not calendar_binding_id
paliad.appointment_caldav_targets's join column is named binding_id
(mig 101). The backup sheet exporter referenced calendar_binding_id
which doesn't exist, so /admin/backups generate failed with 42703.

Single-char fix. Also flags follow-up: hardcoded ORDER BY columns on
every sheet in orgSheetQueries() are fragile under schema renames —
a separate slice (m/paliad#140) tracks making the exporter flexible
to drift (e.g. probe information_schema or use NULLS LAST id-only).
2026-05-26 18:00:17 +02:00
mAi
df592f9fc4 feat(db,services): Slice B.3 read cutover — flip reads to paliad.deadline_rules_unified view backed by sr+pe+ls (t-paliad-305 / m/paliad#93)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled
The new tables (mig 136) and the dual-write that keeps them in sync
(B.2) have been steady-state in prod since mig 136 deployed at
13:24 UTC today. Drift verified clean before this commit:
deadline_rules=231, sequencing_rules=231, procedural_events=231 (153
codes + 78 synthetic), legal_sources=87, zero mismatches across
counts, FK integrity, lifecycle, is_active.

This commit flips READ paths to source data from the new tables via
a backwards-compatible view, leaving the dual-write WRITE paths
untouched for B.4 to retire alongside the destructive drop.

* internal/db/migrations/139_deadline_rules_unified_view.up.sql (new) —
  CREATE VIEW paliad.deadline_rules_unified projecting sr+pe+ls
  back into the legacy paliad.deadline_rules column shape. Same
  column names + types so the Go-side change is a 1-token
  substitution per query with no struct or scanner edits.
  Post-apply DO block asserts view row count = sequencing_rules row
  count (FK NOT NULL on procedural_event_id guarantees they match).

* 10 service / handler files — every SELECT FROM paliad.deadline_rules
  (or JOIN paliad.deadline_rules) flipped to use the view:
  - internal/handlers/submissions.go            (Schriftsätze list)
  - internal/services/deadline_rule_service.go  (8 read sites)
  - internal/services/rule_editor_service.go    (3 read sites — ListRules, getByID, validateSpawnNoCycle)
  - internal/services/rule_editor_orphans.go    (candidate-rule lookup)
  - internal/services/submission_vars.go        (loadPublishedRule)
  - internal/services/deadline_service.go       (deadlines list join)
  - internal/services/fristenrechner.go         (calculator reads)
  - internal/services/projection_service.go     (projection reads)
  - internal/services/event_deadline_service.go (event→rule join)
  - internal/services/export_service.go         (3 export sites — ref__deadline_rules)

Verified semantically safe on live (read-only smoke):
- 231 rows in view match 231 in legacy.
- name + event_type pair: 231/231 match.
- legal_source: 231/231 match (NULL on both sides treated as match).
- submission_code: 153 non-NULL codes match exactly; the 78
  synthetic 'null.<8hex>' codes diverge from legacy NULL but no
  reader filters on NULL submission_code (verified
  handlers/submissions.go: synthetic-code rules all have NULL
  event_type so the WHERE event_type = 'filing' filter excludes
  them; the Schriftsätze surface returns the same 105 rows).

Scope decisions documented (deviation from design §5.3):
- B.3 ships the READ flip only. WRITE paths (RuleEditorService
  Create / UpdateDraft / CloneAsDraft / Publish / flipLifecycle)
  retain the dual-write from B.2 — they write to both legacy and
  new tables. B.4 (destructive drop) will retire the legacy writes
  in the same slice that drops the table, avoiding a transient
  state where the legacy writes have no purpose.
- The B.2 drift-check ticker (StartDualWriteDriftCheckLoop) stays
  active for the same reason: dual-write continues, so the
  invariants the loop checks remain meaningful.

This shape is paliadin-approvable on a "good solution > strict
phase boundary" reading of m's greenlight. If paliadin pushes back
and wants the legacy writes removed in B.3, the refactor is ~300
LOC across the 5 RuleEditorService write methods + buildPatchSets
split into PE/SR sets — schedulable as B.3.5 before B.4.

Build + vet clean. TestMigrations_NoDuplicateSlot passes.
2026-05-26 17:59:58 +02:00
151 changed files with 30985 additions and 5981 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

@@ -12,7 +12,6 @@ import (
"strconv"
"strings"
"syscall"
"time"
// Embed Go's IANA tz database into the binary so time.LoadLocation works
// without OS tzdata. The runtime image (alpine) doesn't ship /usr/share/
@@ -160,6 +159,21 @@ func main() {
submissionVarsSvc := services.NewSubmissionVarsService(pool, projectSvc, partySvc, users)
submissionRenderer := services.NewSubmissionRenderer()
submissionDraftSvc := services.NewSubmissionDraftService(pool, projectSvc, submissionVarsSvc, submissionRenderer)
// t-paliad-313 Composer Slice A — base catalog + section seeding.
// AttachComposer wires both into the draft service so Create
// seeds base_id + submission_sections rows on new drafts. v1
// fallback path stays active for pre-Composer drafts (base_id
// NULL, no section rows).
submissionBaseSvc := services.NewBaseService(pool)
submissionSectionSvc := services.NewSectionService(pool)
submissionDraftSvc.AttachComposer(submissionBaseSvc, submissionSectionSvc, branding.Name)
// t-paliad-313 Slice B — render-pipeline assembler. Reuses the
// existing SubmissionRenderer for the final placeholder pass so
// the {{rule.X}} alias contract stays preserved inside the
// composed body.
submissionComposerSvc := services.NewSubmissionComposer(submissionRenderer)
// t-paliad-315 Slice C — building-block library.
submissionBuildingBlockSvc := services.NewBuildingBlockService(pool, branding.Name)
// t-paliad-225 Slice A — user-authored checklist templates.
// Slice B adds checklist_shares grants + admin promotion.
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
@@ -171,7 +185,11 @@ func main() {
Team: teamSvc,
PartnerUnit: partnerUnitSvc,
Party: partySvc,
SubmissionDraft: submissionDraftSvc,
SubmissionDraft: submissionDraftSvc,
SubmissionBase: submissionBaseSvc,
SubmissionSection: submissionSectionSvc,
SubmissionComposer: submissionComposerSvc,
SubmissionBuildingBlock: submissionBuildingBlockSvc,
Deadline: deadlineSvc,
Appointment: appointmentSvc,
CalDAV: caldavSvc,
@@ -224,6 +242,14 @@ func main() {
EventChoice: services.NewEventChoiceService(pool, projectSvc, users),
// Slice D (m/paliad#124 §5, mig 145) — named scenario compositions.
Scenario: services.NewScenarioService(pool, projectSvc, rules),
// m/paliad#149 Phase 2 P0 (mig 154) — per-project scenario_flags
// SSoT. Drives Verfahrensablauf + Mode B result-view conditional
// rendering and per-rule selection state (`rule:<uuid>` keys).
ScenarioFlags: services.NewScenarioFlagsService(pool, projectSvc),
// t-paliad-340 / m/paliad#153 B0 (mig 157) — Litigation Builder.
// CRUD over the new normalised scenarios + scenario_proceedings
// + scenario_events + scenario_shares tables.
ScenarioBuilder: services.NewScenarioBuilderService(pool),
}
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
@@ -340,13 +366,11 @@ func main() {
log.Printf("CalDAV start: %v", err)
}
reminderSvc.Start(bgCtx)
// Slice B.2 dual-write drift check (t-paliad-305 / m/paliad#93).
// Runs every 6 h while the new procedural_events / sequencing_rules /
// legal_sources tables shadow the legacy paliad.deadline_rules
// table. A clean run logs at INFO; drift logs at WARN with the
// full report so a broken dual-write surfaces before the next
// deploy.
services.StartDualWriteDriftCheckLoop(bgCtx, pool, 6*time.Hour)
// Slice B.4 (mig 140, t-paliad-305): legacy paliad.deadline_rules
// dropped. The B.2 dual-write drift-check loop is retired — the
// procedural_events / sequencing_rules / legal_sources tables
// are now the source of truth and there is no parallel side to
// compare against. Pre-drop drift was verified clean in mig 140.
go func() {
<-bgCtx.Done()
log.Println("background services: shutdown signal received")

View File

@@ -0,0 +1,738 @@
# Assessment — Deadline + Procedural-Events System
**Phase 1 of RFC m/paliad#149.** Read-only audit of every consumer of
`paliad.sequencing_rules` + `paliad.procedural_events` + the legacy
`paliad.trigger_events`, the corpus they project, and the surfaces that
read them.
- Author: athena (consultant role)
- Date: 2026-05-27
- Live data: youpc Supabase (`paliad` schema), counts captured during the
audit window (mig 153 applied).
- Scope: assessment only. No design proposals; no schema sketches; no
recommendations on column shape. Phase 2 (inventor) decides those.
---
## 0. Headline numbers
| Bucket | Total | Active + published | Notes |
|---|--:|--:|---|
| `procedural_events` | 236 | 222 | 5 drafts, 9 archived/inactive |
| `sequencing_rules` | 236 | 226 | 1:1 row-mirror with events (mig 136 + 140) |
| `trigger_events` (legacy) | 110 | — | bigint-keyed catalog; lives parallel to events |
| `proceeding_types` | 50 | 23 kind=`proceeding`; 0 active in kind=`phase`/`side_action`/`meta` (mig 153 flipped them off) |
Rules-corpus shape (active + published, 226 rows):
| Classification | Rows |
|---|--:|
| Parent only (chain-linked) | 105 |
| Both parent + legacy trigger | 2 |
| Legacy `trigger_event_id` only — `proceeding_type_id IS NULL` | **73** |
| Neither (root) — `proceeding_type_id` set | 46 |
Other corpus signals:
- `condition_expr` populated: 18 rules. Three distinct keys: `flag` (14),
`op` + `args` (4 each — always nested AND).
- `is_spawn = true`: 4 rules. All four point at the **inactive**
`upc.apl.merits` (id=11). The active appeal type is id=160
(`upc.apl.unified`). See risk R3.
- `is_court_set = true`: 46 rules.
- `is_bilateral = true`: 4 rules.
- `choices_offered` populated: 28 rules. Three shapes:
`{appellant:[…]}` (20), `{skip:[…]}` (6), `{include_ccr:[…]}` (2).
- `applies_to_target` populated: 16 rules.
- 67 distinct events act as chain-anchors (= parent of ≥1 active rule).
That is the *derived* trigger set today.
- `paliad.project_event_choices`: schema present, **0 rows** live.
- `paliad.scenarios` (mig 145): table created, **0 rows**.
`paliad.projects.active_scenario_id`: **0/18 projects** populated.
A more granular per-proceeding-type breakdown is in §4.
---
## 1. Audit — consumers of `sequencing_rules` + `procedural_events`
Every read site, by surface. File paths are repo-relative.
### 1.1 Direct services
| Service | File | What it reads | Surface(s) it backs |
|---|---|---|---|
| `DeadlineRuleService` | `internal/services/deadline_rule_service.go:14-365` | `paliad.deadline_rules_unified` view (sequencing_rules + procedural_events + legal_sources), + `paliad.trigger_events` for parent-chain labels (`:226-285`) | Admin rules list/editor, Fristenrechner result panel |
| `FristenrechnerService` | `internal/services/fristenrechner.go:115-172,1-700+` | `sequencing_rules` + `procedural_events` (proceeding-type catalog; `EXISTS` over rules); scenarios table (`:583-627`) | `/api/tools/fristenrechner` (Mode A + Mode B + Mode C) |
| `FristenrechnerService.LookupFollowUps` | `internal/services/fristenrechner_followups.go:87-403` | resolves anchor by `pe.id`/`pe.code`/`sr.id` (`:241-287`); one-hop children via `parent_id` (`:345-403`) | `/api/tools/fristenrechner/follow-ups` |
| `DeadlineSearchService` | `internal/services/fristenrechner_search_events.go:143-170,194,233,696` | sequencing_rules ⋈ procedural_events ⋈ proceeding_types + legal_sources; counts child rules via `parent_id` subquery | `/api/tools/fristenrechner/search` |
| `EventDeadlineService` | `internal/services/event_deadline_service.go:31-79,186-195,244` | `paliad.trigger_events` + `sequencing_rules WHERE trigger_event_id IS NOT NULL` | `/api/tools/event-deadlines` (legacy bigint surface) |
| `EventTriggerService` | `internal/services/event_trigger_service.go:24-230` | `event_types.trigger_event_id` bridge + sequencing_rules | `/api/tools/event-trigger` |
| `RuleEditorService` | `internal/services/rule_editor_service.go:104,136,232,371,381,459,625-843` | full CRUD on sequencing_rules + procedural_events; reads `trigger_event_id` as an optional filter on list | `/admin/api/procedural-events/*` (Slice B.5) |
| `RuleEditorOrphans` | `internal/services/rule_editor_orphans.go:218-224` | sub-select on sequencing_rules for orphaned deadlines | `/admin/api/orphans` |
| `DualWriteService` | `internal/services/dual_write.go` (+ `dual_write_test.go:50-300`) | parity assertion between legacy + unified projection | internal — write-side guard, no HTTP |
| `ProjectionService` (SmartTimeline) | `internal/services/projection_service.go:3+` | composes the timeline by reading via `DeadlineRuleService` + `FristenrechnerService`; does NOT touch `sequencing_rules` directly | `GET /api/projects/{id}/timeline`, milestone + counterclaim endpoints in `internal/handlers/projection.go:35-436+` |
| `ExportService` | `internal/services/export_service.go:1680` | bulk-exports `paliad.trigger_events` as the `ref__trigger_events` workbook sheet | `/api/admin/export/*` |
| `EventChoiceService` | `internal/services/event_choice_service.go:15-180` | reads + writes `paliad.project_event_choices` | per-project flag persistence (no rows live today) |
| `EventTypeService` | `internal/services/event_type_service.go:40-414` | user-defined `paliad.event_types` rows with optional `trigger_event_id` bridge | `/api/event-types` + Pipeline C compose |
| `ProjectService.validateProceedingTypeCategory` | `internal/services/project_service.go:1176-1267` | reads `paliad.proceeding_types.category` + `kind` + `is_active` | binding guard for `projects.proceeding_type_id` (sister to mig-153 trigger) |
The handlers behind each route are listed in §1.2.
### 1.2 HTTP routes
Every route that ultimately surfaces sequencing/event data. Path
literals + handler file:line cited.
**Knowledge-tool surface (public-ish, behind auth):**
| Route | Handler | Reads |
|---|---|---|
| `POST /api/tools/fristenrechner` | `internal/handlers/fristenrechner.go:39-95+` | `FristenrechnerService.CalculateForProceeding` → engine in `pkg/litigationplanner` |
| `GET /api/tools/fristenrechner/search` | `internal/handlers/fristenrechner_search.go` (filter params: `event_kind`, `primary_party`, `jurisdiction`) | `DeadlineSearchService.SearchEvents` |
| `GET /api/tools/fristenrechner/follow-ups` | `internal/handlers/fristenrechner_followups.go:27-65` | `FristenrechnerService.LookupFollowUps` |
| `GET /api/tools/proceeding-types` | `internal/handlers/event_types.go` | proceeding_types filter (event_kind, jurisdiction) |
| `GET /api/tools/trigger-events` | `internal/handlers/event_types.go` | trigger_events catalog (active only) |
| `POST /api/tools/event-trigger` | `internal/handlers/event_trigger.go:39-106` | unified Pipeline-A + Pipeline-C compose |
| `POST /api/tools/event-deadlines` | `internal/handlers/deadline_rules_db.go:67+` | **legacy** bigint trigger_event_id → rule list |
**SmartTimeline surface (project-bound):**
| Route | Handler | Reads |
|---|---|---|
| `GET /api/projects/{id}/timeline` | `internal/handlers/projection.go:35-109` | `ProjectionService.Render` (no direct rule reads — composes via services) |
| `POST /api/projects/{id}/timeline/milestone` | `internal/handlers/projection.go:445+` | milestone insert; reads `proceeding_type.kind` via service |
| `POST /api/projects/{id}/timeline/counterclaim` | `internal/handlers/projection.go:387-436` | spawns CCR project; reads `parent_id` on response composition |
**Admin editor surface (`/admin/procedural-events/*`):**
| Route | Handler | Reads |
|---|---|---|
| `GET /admin/procedural-events` | `internal/handlers/admin_rules.go:399-402` | page shell |
| `GET /admin/procedural-events/{id}/edit` | `:403-470` | editor form (full rule + event JSON) |
| `GET /admin/api/procedural-events` | `:101-160` | paginated list w/ canonical `code` + `event_kind` (Slice B.5 wrapper) |
| `GET /admin/api/procedural-events/{id}` | `:161-179` | single rule fetch |
| `POST /admin/api/procedural-events` | `:180-204` | create draft |
| `PATCH /admin/api/procedural-events/{id}` | `:205-233` | edit draft |
| `POST /admin/api/procedural-events/{id}/publish` | `:257-279` | publish flow |
| `GET /admin/api/procedural-events/{id}/audit` | `:326-361` | audit log |
| `GET /admin/api/orphans` | `:471-484` | orphaned deadlines (Slice 10 backfill UI) |
| `POST /admin/api/orphans/{id}/resolve` | `:485-520` | link orphan to rule |
| `/admin/rules/*``/admin/procedural-events/*` | `:761-772` | **301 redirects** (legacy bookmarks; one-slice deprecation window) |
| `?trigger_event_id=…` query param | `:119-122` | exposes legacy trigger filter on the admin list |
**Scenarios surface (mig 145):**
| Route | Handler |
|---|---|
| `GET /api/scenarios?project=<id>|abstract=true` | `internal/handlers/scenarios.go:51-90` |
| `GET /api/scenarios/{id}` | `:92-113` |
| `POST /api/scenarios` | `:115-136` |
| `PATCH /api/scenarios/{id}` | `:138-164` |
| `DELETE /api/scenarios/{id}` | `:166-200+` |
| `POST /api/paliadin/suggest/deadline` | `internal/handlers/paliadin_suggest.go:63+` (deadline drafts via Paliadin; does not read rules directly — calls into `DeadlineService`) |
Registration: `internal/handlers/handlers.go:497-501, 880`.
### 1.3 Frontend (TypeScript) consumers
These call the routes above; **no direct DB access**. References per the
i18n key search and `frontend/src/client/*` greps:
- `frontend/src/admin-rules-list.tsx:24-105+` — admin list page shell;
hits `/admin/api/procedural-events*`.
- `frontend/src/admin-rules-edit.tsx:29-187+` — admin editor form; reads
`procedural_events.edit.field.{code,event_kind,parent}` i18n keys.
- `frontend/src/verfahrensablauf.tsx` — proceeding-type ablauf page
(mode C); hits `/api/tools/fristenrechner` with proceeding shape.
- `frontend/src/client/fristenrechner-wizard.ts:80` — Mode A wizard;
`r4: string // procedural_events.code`.
- `frontend/src/client/fristenrechner-mode-a.ts` — Mode A search; hits
`/api/tools/fristenrechner/search?kind=events`.
- `frontend/src/client/fristenrechner-result.ts` — result panel; hits
`/api/tools/fristenrechner/follow-ups`.
- `frontend/src/client/projects-new.ts` — type-aware project wizard;
hits `/api/tools/fristenrechner?proceeding_type_code=…`.
- `frontend/src/client/deadlines-detail.ts` — deadline CRUD detail.
- i18n keys: `admin.procedural_events.list/edit/col.*` and translations
in `frontend/src/client/i18n.ts:3193-3204, 6338-6346+`.
### 1.4 Offline snapshot
- `cmd/gen-upc-snapshot/main.go:150-268` — reads `paliad.trigger_events`,
the legacy `paliad.deadline_rules` projection (now via the unified
view), and `paliad.proceeding_types`. Writes JSON to
`pkg/litigationplanner/embedded/upc/{trigger_events.json,
rules.json, proceeding_types.json, meta.json}`.
- `pkg/litigationplanner/catalog.go` + `engine.go` + `types.go:73-156`
Rule struct carries `TriggerEventID`, `SpawnProceedingTypeID`,
`ConditionExpr`, `Priority`, `IsCourtSet`, `PrimaryParty`, `IsSpawn`,
`SpawnLabel`, `CombineOp`. youpc.org consumes this snapshot.
### 1.5 Migrations touching the tables (chronological)
`internal/db/migrations/`:
`028_youpc_deadlines_import`, `030_event_types`, `033_trigger_events_de`,
`035_event_deadlines_title_de_backfill`, `038_concept_links_and_legal_source`,
`046_cross_cutting_triggers`, `047_deadline_search_view`,
`051_proceeding_display_order`, `063_frist_verpasst_upc`,
`078_unified_rule_columns`, `091_drop_legacy_rule_columns`,
`098_submission_codes_prefix_and_rename`, `125_cross_cutting_filter_legal_source`,
`132_wave1_tier1_rule_additions`, **`136_procedural_events_additive`**
(the schema-authoritative additive split), `139_deadline_rules_unified_view`,
**`140_drop_deadline_rules`** (legacy projection dropped),
`151_dedupe_null_procedural_events`, `152_dedupe_identical_sequencing_rule_clones`,
**`153_proceeding_types_kind`** (kind discriminator + projects FK trigger).
Mig 145 is scenario-side: creates `paliad.scenarios` (table, **not**
a `scenarios` jsonb column on `projects` — the RFC text was imprecise)
and `paliad.projects.active_scenario_id` FK.
---
## 2. Health-check per consumer
### 2.1 Works — green
- **`DualWriteService` parity.** Every CRUD on the editor surface
keeps sequencing_rules + procedural_events + legal_sources locked,
asserted by `dual_write_test.go:50-202`.
- **Admin editor (`/admin/procedural-events/*`).** Full create / edit /
publish / audit loop. Drafts state respected.
- **Mode A picker via search.** `DeadlineSearchService` filters by
`event_kind` / `primary_party` / `jurisdiction`; returns child-rule
counts (`fristenrechner_search_events.go:159`).
- **Mode B Verfahrensablauf calc.** `pkg/litigationplanner.CalculateRule`
+ the `proceeding_type` fan-out works for every type that has any
rule (17/23).
- **`gen-upc-snapshot`.** UPC snapshot for youpc.org keeps shipping;
no DB writes; reads only.
- **Counterclaim spawn project creation.**
`internal/handlers/projection.go:387-436` + mig 153 trigger guard
reject any non-`proceeding` `proceeding_type_id`.
- **EventChoiceService** SQL is wired and tested — but see §2.3.
### 2.2 Works with known caveats — yellow
- **Spawn rules.** Behaviour is correct in the abstract (rule fires,
user can spawn a child case), but every spawn target points at the
**inactive** `upc.apl.merits` (id=11). Surfaces that resolve the
spawn target via `paliad.proceeding_types` will return an inactive
row. See R3. Cited at `sequencing_rules` 4 rows; service code in
`fristenrechner_followups.go:388` SELECTs `spt.code` via
`LEFT JOIN paliad.proceeding_types spt ON spt.id = sr.spawn_proceeding_type_id`
— no `is_active` filter on the join. Frontend renders an "open
Berufungsverfahren" CTA that points at a UI flow expecting the
active id=160 (`upc.apl.unified`).
- **Legacy 73 globals.** 73 rules with `proceeding_type_id IS NULL`
and `trigger_event_id NOT NULL`. These all anchor on legacy
`null.<8hex>` event codes that don't match any `proceeding_types.code`
prefix. They are consumed via `/api/tools/event-deadlines` (the
bigint route) AND surface on the unified view. They have no place
in the Mode B "proceeding-type ablauf" view because they have no
proceeding. See R4.
- **Legacy `/api/tools/event-deadlines` route.** Live, used by
Pipeline-C `event_types` consumers (`EventTypeService`). The
`ExportService:1680` also still emits `ref__trigger_events` to the
workbook. Deprecation has been deferred — see R5.
### 2.3 Broken / leaky — red
- **B1 — Follow-up cross-party filter is over-broad.**
`fristenrechner_followups.go:358-367`:
```go
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)))
}
```
The filter keeps `both` + `NULL` rules but **drops cross-party
follow-ups**. From the corpus there are 39 active rules whose
`primary_party` differs from their parent's primary_party (excluding
`court`). Example: `upc.inf.cfi.def_to_ccr` is claimant-filed; its
child rule `RoP.029.d → reply_def_ccr` is defendant-filed. With
`party=claimant` selected on the result view, the defendant child
is hidden and the user reads "Keine Folge-Fristen" — a lie. This
is the exact bug the RFC §"What's actually broken" item 2 calls
out.
- **B2 — Picker doesn't distinguish triggers from leaves.**
`LookupFollowUps` (`fristenrechner_followups.go:241-287`) resolves
by `pe.id` / `pe.code` / `sr.id` with no
"is-this-event-actually-a-trigger" gate. The data already supports
derivation — 67 of 222 active events act as a chain anchor. The
picker just isn't wired to the derivation. Compounding: 4 events
are *spawn-only* consequences (`upc.{inf,rev,pi,dmgs}.cfi.appeal_spawn`)
— picking one returns the spawn rule itself with no follow-ups,
which surfaces as "Keine Folge-Fristen".
- **B3 — Scenario state is forked across three stores by design but
zero stores by data.**
- `paliad.project_event_choices` (mig 129) — schema present, 0 rows.
`EventChoiceService` reads + writes it via
`internal/services/event_choice_service.go:74,123,180`.
- `paliad.scenarios` (mig 145) — 0 rows, 0/18 projects bound via
`active_scenario_id`. `ScenarioService.LoadScenarios` in
`internal/services/fristenrechner.go:583-627` reads it.
- DOM state on the result view — Verfahrensablauf checkbox state
only lives client-side. Confirmed by absence of a write path
from `verfahrensablauf.tsx` to either DB-side store.
The RFC's "three independent stores" claim is *architecturally*
true today, but every store is empty. Risk is dormant — until
someone enables persistence on either path and the divergence
materialises. See R6.
- **B4 — 6 active `proceeding_types` have zero rules.**
`upc.bsv.cfi`, `upc.ccr.cfi`, `upc.costs.cfi`, `upc.dni.cfi`,
`upc.epo.review`, `upc.pl.cfi`. They appear in
`/api/tools/proceeding-types` (`is_active=true` + `kind='proceeding'`)
but produce empty timelines when chosen. The Mode A picker can
bind a project to them; the Mode B result view is blank.
### 2.4 Dead-or-decaying code
- **`paliad.trigger_events` table.** 110 rows; columns
`(id, code, name, name_de, description, is_active, created_at, concept_id)`.
Bigint PK. No `parent_id`, no `proceeding_type_id`. Consumed by:
`deadline_rule_service.go:226-285` (label fallback), `event_deadline_service.go`
(legacy route), `event_type_service.go` (Pipeline C bridge),
`export_service.go:1680` (workbook sheet), and 80 active
sequencing_rules' `trigger_event_id` (which is in turn primarily a
bridge for the 73 globals + 7 hybrid rules with a real proceeding).
- **Inactive proceeding_types still referenced by spawn rules.**
id 11 (`upc.apl.merits`), 19 (`upc.apl.cost`), 20 (`upc.apl.order`).
Mig 138 (`appeal_target_backfill_merits_order`) split them, mig
later unified them onto id 160. The 4 spawn rules' FK was not
updated.
- **3 non-`proceeding` kinds.** 23 rows total
(`phase` × 4 + `side_action` × 10 + `meta` × 9), all
`is_active=false` after mig 153. Live in the table for audit;
unused by any active surface. The Slice 10 orphan-resolution path
(`rule_editor_orphans.go`) could theoretically encounter them, but
active = false filters them out.
---
## 3. Rules-corpus quality audit (live data)
### 3.1 `parent_id` coverage
- 107/226 active+published rules have `parent_id` set (**47%**, matches
RFC).
- 119/226 do not. Decomposition (active+published):
| Subset | Rows | Meaning |
|---|--:|---|
| `parent_id NULL` AND `trigger_event_id IS NULL` AND `proceeding_type_id` set | 46 | Genuine proceeding-level roots (each PT has 16 such). |
| `parent_id NULL` AND `trigger_event_id` set AND `proceeding_type_id NULL` | 73 | The legacy globals — no place in the new chain model yet. |
Of the 46 proceeding-level roots:
| `proceeding_type.code` | roots | active rules |
|---|--:|--:|
| `de.inf.lg` | 5 | 9 |
| `de.null.bpatg` | 4 | 10 |
| `epa.grant.exa` | 4 | 7 |
| `upc.apl.unified` | 6 | 16 |
| `epa.opp.boa` | 3 | 8 |
| `upc.pi.cfi` | 3 | 7 |
| `epa.opp.opd` | 2 | 8 |
| `de.inf.bgh`, `de.inf.olg`, `de.null.bgh`, `dpma.appeal.bgh`, `dpma.appeal.bpatg`, `dpma.opp.dpma`, `upc.disc.cfi` | 1 each | various |
| `upc.dmgs.cfi`, `upc.inf.cfi`, `upc.rev.cfi` | 4 each | 8/25/17 |
Most "root" rules are legitimate (the chain start event has no logical
predecessor — `Klageerhebung`, `Zustellung`, `Veröffentlichung`,
`Anmeldung`, etc.). A small number are leaves whose parent chain just
hasn't been seeded (e.g. `de.inf.lg.berufung` / `de.inf.lg.beruf_begr`
list "Berufungsfrist" and "Berufungsbegründung" as parent-NULL despite
both having a logical predecessor in `de.inf.lg.urteil`).
### 3.2 `condition_expr` usage
18 rules use the column. Three keys total:
| Key | Uses | Sample shape |
|---|--:|---|
| `flag` | 14 | `{"flag":"with_ccr"}`, `{"flag":"with_amend"}`, `{"flag":"with_cci"}` |
| `op` | 4 | `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}` |
| `args` | 4 | always nested under an `op:and` |
Distinct expressions (4 total, all UPC inf/rev):
`{"flag":"with_ccr"}` (×6), `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}` (×4), `{"flag":"with_cci"}` (×4), `{"flag":"with_amend"}` (×4).
No formal validation at write time — `RuleEditorService` accepts the
column as freeform jsonb. The 3 flags are de-facto convention.
### 3.3 Spawn distribution
4 rules, all in the UPC CFI cluster, all `priority='optional'` +
`primary_party='both'` + spawn target id=11 (`upc.apl.merits`, inactive):
| Anchor event | Spawn label | Target |
|---|---|---|
| `upc.inf.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) |
| `upc.rev.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) |
| `upc.pi.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) |
| `upc.dmgs.cfi.appeal_spawn` | "Berufungsverfahren öffnen" | id=11 (inactive) |
### 3.4 `primary_party` distribution
Excluding the 73 globals (all NULL), the published+active rules split:
| `proceeding_type` cluster | `claimant` | `defendant` | `both` | `court` |
|---|--:|--:|--:|--:|
| `upc.inf.cfi` (25) | 6 | 7 | 8 | 4 |
| `upc.rev.cfi` (17) | 6 | 7 | 1 | 3 |
| `upc.apl.unified` (16) | 0 | 0 | 12 | 4 |
| `de.null.bpatg` (10) | 2 | 2 | 3 | 3 |
| `de.inf.lg` (9) | 2 | 3 | 2 | 2 |
| `epa.opp.opd` (8) | 0 | 1 | 6 | 1 |
| `epa.opp.boa` (8) | 0 | 0 | 6 | 2 |
| `de.inf.bgh` (8) | 0 | 0 | 6 | 2 |
| `upc.dmgs.cfi` (8) | 2 | 2 | 1 | 3 |
39 rules have a `primary_party` value that differs from their parent
rule's `primary_party` (excluding `court` ↔ anything, which is
trivial). All 39 are legitimate "ball-in-other-court" hand-offs
(claimant SoC → defendant SoD → claimant Reply → defendant Rejoinder
…). The /follow-ups filter (§2.3 B1) hides all of them when the user
picks a perspective.
### 3.5 `is_court_set` coverage
46 rules carry `is_court_set=true`. Distribution: every proceeding has
at least one (the decision / order / oral-hearing rows). Highest:
`de.inf.lg` (5), `epa.grant.exa` (4), `upc.apl.unified` (4),
`upc.inf.cfi` (3), `upc.rev.cfi` (3), `upc.pi.cfi` (3), `upc.dmgs.cfi`
(3). Calculator skips these in date math — they surface as
"wird vom Gericht bestimmt" markers.
### 3.6 Legacy `trigger_event_id` overlap with `parent_id`
| Combination | Rows |
|---|--:|
| `parent_id` set AND `trigger_event_id` set | **2** |
| `parent_id` set AND `trigger_event_id` NULL | 105 |
| `parent_id` NULL AND `trigger_event_id` set | 73 |
| `parent_id` NULL AND `trigger_event_id` NULL | 46 |
**Overlap is 2 rules out of 226 (0.9%).** The two models are
effectively **disjoint** in the corpus: the 73 legacy globals own the
`trigger_event_id` lane; the 105 chain-linked rules own `parent_id`.
The schema permits both columns to be set simultaneously, and 2 rules
exercise that — but they are outliers, not a documented pattern.
The legacy `paliad.trigger_events` table is still read for label
display by `deadline_rule_service.go:226-285` (the "abhängig von …"
chip rule fallback when `parent_id` isn't set) and for the legacy
`/api/tools/event-deadlines` route.
---
## 4. Editorial gap map
Per `proceeding_type` (active, kind=`proceeding`). Columns:
- **A** = active+published rules
- **P** = rules with `parent_id` set
- **R** = rules without `parent_id` (roots + leaves with missing parent)
- **E** = active+published events whose code matches this PT's
prefix
| PT code | A | P | R | E | Health |
|---|--:|--:|--:|--:|---|
| `upc.inf.cfi` | 25 | 21 | 4 | 25 | 84% chained — strongest |
| `upc.rev.cfi` | 17 | 13 | 4 | 17 | 76% |
| `upc.apl.unified` | 16 | 10 | 6 | 16 † | 63% — code-prefix issue, see below |
| `de.null.bpatg` | 10 | 6 | 4 | 10 | 60% |
| `de.inf.lg` | 9 | 4 | 5 | 9 | 44% — gappy |
| `epa.opp.opd` | 8 | 6 | 2 | 8 | 75% |
| `epa.opp.boa` | 8 | 5 | 3 | 8 | 63% |
| `de.inf.bgh` | 8 | 7 | 1 | 8 | 88% |
| `upc.dmgs.cfi` | 8 | 4 | 4 | 8 | 50% |
| `upc.pi.cfi` | 7 | 4 | 3 | 7 | 57% |
| `de.inf.olg` | 7 | 6 | 1 | 7 | 86% |
| `epa.grant.exa` | 7 | 3 | 4 | 7 | 43% |
| `de.null.bgh` | 6 | 5 | 1 | 6 | 83% |
| `dpma.appeal.bpatg` | 5 | 4 | 1 | 5 | 80% |
| `dpma.appeal.bgh` | 4 | 3 | 1 | 4 | 75% |
| `dpma.opp.dpma` | 4 | 3 | 1 | 4 | 75% |
| `upc.disc.cfi` | 4 | 3 | 1 | 4 | 75% |
| `upc.bsv.cfi` | 0 | 0 | 0 | 0 | **unruled** |
| `upc.ccr.cfi` | 0 | 0 | 0 | 0 | **unruled** |
| `upc.costs.cfi` | 0 | 0 | 0 | 0 | **unruled** |
| `upc.dni.cfi` | 0 | 0 | 0 | 0 | **unruled** |
| `upc.epo.review` | 0 | 0 | 0 | 0 | **unruled** |
| `upc.pl.cfi` | 0 | 0 | 0 | 0 | **unruled** |
† `upc.apl.unified` (id=160) is the active type, but its 16 events
retain the *legacy* code prefixes `upc.apl.{merits,cost,order}.*`
from the pre-unification taxonomy. The rules' `proceeding_type_id`
was rebound to 160; the event codes were not renamed. Functional but
inconsistent — see R3.
**Events with no rule:** 0. Every active+published event has at least
one rule (corpus is 1:1 since mig 136). Editorial gap is therefore
parent-chain-shaped, not rule-coverage-shaped.
**Unmatched-prefix events:** 69 events with `code LIKE 'null.%'`. They
have rules (the 73 legacy globals — note the disparity: 73 rules but
69 events, because dedupe in mig 151 collapsed some duplicates while
the rules still point at the canonical event). They do not belong to
any proceeding_type and never will under the current taxonomy.
---
## 5. Risk register
Eleven items. Each: what, where, severity. Severity scale:
**critical** (user-visible incorrect output / data loss possible) →
**high** (user-visible UX lie, no data corruption) → **medium**
(developer-trap; breaks at next refactor) → **low** (cosmetic / dead
code, deferred maintenance).
### R1 — Cross-party follow-up filter drops legitimate hand-offs — **high**
- Where: `internal/services/fristenrechner_followups.go:358-367`.
- Effect: with `party=claimant|defendant`, 39 active rules are hidden
because their `primary_party` is the *other* side. Result-view
reports "Keine Folge-Fristen" on chains that continue cross-party
(e.g. `def_to_ccr` claimant-filed → `reply_def_ccr` defendant-filed
in `upc.inf.cfi`).
- Impact: UX lies to users about chain completion; can lead to missed
deadlines on the opposing side's view.
### R2 — Picker accepts spawn-only and leaf events — **high**
- Where: `internal/services/fristenrechner_followups.go:241-287` (anchor
resolution does not check chain-anchor status); `internal/services/fristenrechner_search_events.go`
(search returns every event).
- Effect: Picking `upc.{inf,rev,pi,dmgs}.cfi.appeal_spawn` (spawn-only)
shows the spawn rule itself but no follow-ups → "Keine Folge-Fristen".
Picking a leaf event (e.g. `upc.inf.cfi.def_to_ccr`) only reaches
whatever hop-1 children exist on the leaf's own party, see R1.
- 67/222 active events are chain-anchors. Today's picker shows all
222 with equal weight.
### R3 — 4 spawn rules point at an inactive `proceeding_type` — **high**
- Where: 4 rows in `paliad.sequencing_rules` with `is_spawn=true`
and `spawn_proceeding_type_id=11` (`upc.apl.merits`, `is_active=false`).
The active appeal type is id=160 (`upc.apl.unified`).
- Effect: any consumer that joins on `spt.is_active=true` (none today,
but the moment any does) returns NULL for the spawn target. Today
the join is permissive (`fristenrechner_followups.go:394`) — it
returns `upc.apl.merits` to the frontend, which may surface as a
CTA pointing at a stale type slug.
- Plus consequence: `upc.apl.unified` events kept legacy code prefixes
`upc.apl.{merits,cost,order}.*` even though the type rebinds to 160.
Code/PT mismatch is harmless today; trap for any future code-prefix
routing.
### R4 — 73 "global" legacy rules orphan from the chain model — **medium**
- Where: `paliad.sequencing_rules WHERE proceeding_type_id IS NULL AND trigger_event_id IS NOT NULL` (73 rows). Anchored on `null.<8hex>`
procedural_events (69 distinct events, 73 rules — small overlap from
pre-dedupe history).
- Effect: invisible to Mode B (proceeding-type ablauf) because they
don't bind to any PT; visible to the legacy bigint route
`/api/tools/event-deadlines` and to /admin/procedural-events.
- Migration debt: any "deprecate `trigger_event_id`" plan must decide
whether to (a) reparent these onto a PT + parent chain, (b) keep them
as floating cross-cutting rules in a separate lane, or (c) drop them.
### R5 — Legacy `paliad.trigger_events` table is read by 5 surfaces — **medium**
- Where:
- `internal/services/deadline_rule_service.go:226-285` — bulk-load for
"abhängig von …" chip label fallback.
- `internal/services/event_deadline_service.go:79,244` — legacy
`/api/tools/event-deadlines` route.
- `internal/services/event_type_service.go:40-414` — Pipeline-C event
types bridge (`event_types.trigger_event_id`).
- `internal/services/export_service.go:1680` — `ref__trigger_events`
workbook sheet.
- `cmd/gen-upc-snapshot/main.go:185-202` — UPC offline snapshot for
youpc.org.
- Effect: 110-row catalog with bigint PK lives alongside the 222 active
procedural_events (UUID PK). Two ID spaces, two label sources,
partial overlap.
### R6 — Three scenario stores: 0 rows each, but 3 live read/write paths — **medium**
- Stores: `paliad.project_event_choices` (0 rows), `paliad.scenarios`
(0 rows), DOM state on Verfahrensablauf checkboxes.
- Paths:
- `EventChoiceService` (`internal/services/event_choice_service.go:15-180`)
reads + writes the table.
- `ScenarioService.LoadScenarios` + handlers
(`internal/services/fristenrechner.go:583-627`, `internal/handlers/scenarios.go:14-200+`)
read + write the table.
- Verfahrensablauf result view writes nothing back — DOM only.
- Effect today: nothing — empty tables. Effect tomorrow: the moment any
surface starts persisting, the three paths can diverge. The RFC
(§"What's actually broken" item 3) calls out the symptom: toggling
"Mit Widerklage" on Verfahrensablauf doesn't drive conditional
checkboxes in result-view submission cards.
### R7 — 6 active `proceeding_types` are entirely unruled — **medium**
- Where: `upc.bsv.cfi`, `upc.ccr.cfi`, `upc.costs.cfi`, `upc.dni.cfi`,
`upc.epo.review`, `upc.pl.cfi`. All `is_active=true`, `kind='proceeding'`,
0 active+published rules, 0 events with their code prefix.
- Effect: pickable on `/api/tools/proceeding-types`, bindable on
`paliad.projects.proceeding_type_id` (mig 153 only rejects non-
proceeding kind, not zero-rule). Binding succeeds → SmartTimeline +
Mode B render an empty result. UX lies.
### R8 — `condition_expr` is freeform jsonb — **medium**
- Where: column declaration in mig 136; consumer in
`deadline_rule_service.go` (selected + passed to engine in
`pkg/litigationplanner/engine.go`); writer in
`internal/services/rule_editor_service.go:625-843` (no validation).
- Effect: 4 distinct shapes used today, 3 keys (`flag`, `op`, `args`).
No write-time validation. New keys can be silently added; the
engine consumes by switching on string literals. Refactor trap.
### R9 — Inactive `proceeding_types` rows linger (23) — **low**
- Where: mig 153 flipped 4 phase + 10 side_action + 9 meta rows to
`is_active=false`. They still exist for audit.
- Effect: snapshots and snapshots-of-snapshots
(`proceeding_types_pre_153`, `procedural_events_pre_151`,
`sequencing_rules_pre_151/_pre_152`) accumulate without a decay
policy. Storage cost is trivial; query-shape cost is real if any
query forgets `WHERE kind='proceeding' AND is_active=true`.
### R10 — `event_kind` is nullable + not enumerated in DB — **low**
- Where: `paliad.procedural_events.event_kind text NULL`. Code at
`frontend/src/admin-rules-edit.tsx:187` lists `filing / hearing /
decision / order` in the UI but the DB accepts anything.
- Effect: drift between UI vocab and persisted values is possible.
Currently 5 buckets: `filing`, `hearing`, `decision`, `order`, NULL
(per RFC).
### R11 — `applies_to_target` + `choices_offered` lack a schema — **low**
- Where: `paliad.sequencing_rules.applies_to_target text[]`,
`choices_offered jsonb`.
- Effect: 16 rules use `applies_to_target`, 28 use `choices_offered`.
Three observed `choices_offered` shapes: `{appellant:[…]}` (20),
`{skip:[…]}` (6), `{include_ccr:[…]}` (2). Wire-level convention,
no documentation. New shapes silently land if a future editor
decides on one.
---
## 6. Recommendation — order of operations for the inventor
Phase 2 design starts with the highest-stakes, hardest-to-rewind
decisions and finishes with editorial/cleanup. Each step is a
question for m, not a design choice for the inventor.
### Tier 1 — model decisions (grill first)
1. **Trigger semantics.** Keep `parent_id` as the canonical link?
What is the role of `trigger_event_id` after this RFC ships? If
deprecated, what happens to the 73 legacy globals (R4) — reparent
onto PTs, keep as a separate "cross-cutting" lane, or drop?
2. **Trigger discoverability.** Derive from data (events that
parent ≥1 rule = 67 today), maintain a materialised view, or carry
an explicit `is_trigger` flag on `procedural_events`? Affects R2.
3. **Scenario state — single home.** Of the three stores in R6, which
wins? Migration shape for the others? The RFC mis-spoke about
`projects.scenarios jsonb` — the table is `paliad.scenarios` with
a `spec` jsonb column (mig 145). Confirm which storage the inventor
reasons from.
4. **Cross-party display semantics.** Backend stops filtering,
frontend groups by side? Or backend tags + frontend renders an
"andere Partei" group? Affects R1.
### Tier 2 — surface decisions
5. **Spawn → consequence-only events.** Stop surfacing spawn-only
events in the picker (R2), or keep them and tag visually?
6. **Re-target the 4 spawn rules** (R3) — point at id=160 vs reseed
legacy ids; align event code prefixes vs. accept the mismatch.
7. **Sequence-from-proceeding-type view** (Entry A). Where does it
live? How do its toggles persist to the chosen scenario store?
8. **Legacy `/api/tools/event-deadlines` deprecation** (R5). Drop,
redirect, or keep behind a flag during transition?
### Tier 3 — editorial + cleanup
9. **Editorial backfill plan.** Which of the 119 parent-NULL rules
are real roots vs. unseeded leaves (a per-PT walkthrough by m).
10. **Empty proceeding_types** (R7). Stub with placeholder rules, or
hide from the picker until rules land?
11. **`condition_expr` formalisation** (R8). Pick a grammar, document
it, add write-time validation. Same question for `choices_offered`
+ `applies_to_target` (R11).
12. **Legacy `trigger_events` table fate.** Drop, archive, or
repurpose? Depends on Q1 + Q2 above.
The inventor should grill m on Tier 1 before sketching anything.
Tier 2 follows from Tier 1's decisions. Tier 3 is mechanical once
Tier 1+2 land.
---
## Appendix — query receipts
All counts in this assessment came from the live `paliad` schema on
the youpc Supabase instance during the audit window (2026-05-27).
Representative queries:
```sql
-- §0 + §3.1 + §3.6
SELECT
CASE
WHEN parent_id IS NOT NULL AND trigger_event_id IS NOT NULL THEN 'both'
WHEN parent_id IS NOT NULL AND trigger_event_id IS NULL THEN 'parent only'
WHEN parent_id IS NULL AND trigger_event_id IS NOT NULL THEN 'legacy only'
ELSE 'neither (root)'
END AS classification,
proceeding_type_id IS NULL AS pt_null, count(*) AS rules
FROM paliad.sequencing_rules
WHERE is_active AND lifecycle_state = 'published'
GROUP BY classification, pt_null
ORDER BY classification, pt_null;
-- → both/false=2, legacy only/true=73, neither/false=46, parent only/false=105
-- §3.4
SELECT pt.code, sr.primary_party, count(*)
FROM paliad.sequencing_rules sr
LEFT JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
WHERE sr.is_active AND sr.lifecycle_state='published'
GROUP BY pt.code, sr.primary_party ORDER BY pt.code, count(*) DESC;
-- §4 (gap map)
SELECT pt.code, count(sr.id) AS active_rules,
count(*) FILTER (WHERE sr.parent_id IS NULL) AS roots
FROM paliad.proceeding_types pt
LEFT JOIN paliad.sequencing_rules sr ON sr.proceeding_type_id = pt.id
AND sr.is_active AND sr.lifecycle_state='published'
WHERE pt.is_active AND pt.kind='proceeding'
GROUP BY pt.code ORDER BY pt.code;
-- §3.2 (condition_expr keys)
WITH expanded AS (
SELECT jsonb_object_keys(condition_expr) AS k
FROM paliad.sequencing_rules
WHERE condition_expr IS NOT NULL AND condition_expr::text <> '{}'
) SELECT k, count(*) FROM expanded GROUP BY k ORDER BY count(*) DESC;
-- → flag=14, args=4, op=4
```
Full set of queries used during the audit is available in the agent
transcript; reproducible against any read-only Supabase role.
— end of assessment.

View File

@@ -0,0 +1,776 @@
# Design — Deadline + procedural-events system revision (Phase 2 of RFC m/paliad#149)
**Task:** t-paliad-329
**Gitea:** m/paliad#149 (Phase 2)
**Inventor:** atlas (shift-1)
**Date:** 2026-05-27
**Status:** Draft — coder gate held; awaiting m's go on the slice train
**Branch:** `mai/atlas/inventor-deadline-system`
**Builds on:**
- `docs/assessment-deadline-system-2026-05-27.md` (athena Phase 1, 738 lines — premises here are athena's)
- `docs/design-fristenrechner-followup-rules-2026-05-27.md` (atlas t-paliad-327, pre-ratified subset: cross-party display + scenario SSoT + spawn-only picker exclusion)
- `docs/design-proceeding-types-taxonomy-2026-05-26.md` (mig 153 shipped; `kind` discriminator)
- `docs/design-fristenrechner-overhaul-2026-05-26.md` (Entry B foundation S1-S6 shipped)
m authorised Phase 2 at 2026-05-27 11:33 ("Go on"). m's "big picture" direction at 13:53 ("yeah, b - big! We need an overall schema for all procedural events and how they are connected") makes the connection graph itself the spine of this design.
---
## §0 Premises — reconciliation with athena's audit
Athena established the live data; this design takes that as given. Three cross-checks ran 2026-05-27 against the live `paliad` schema; counts match athena's §0/§3 numbers (chain-linked 107 / PT-roots 46 / legacy globals 73 / overlap 2). The only material refinement is athena's R3 finding ("4 spawn rules point at INACTIVE id=11") — which m's Q5 answer now re-interprets as **correct** rather than broken (see §3.1).
### §0.1 The athena↔RFC conflicts surfaced
| Item | RFC said | Athena found | Picked side |
|---|---|---|---|
| Scenario state shape | "`projects.scenarios` jsonb (mig 145)" exists | `paliad.scenarios` table exists; `projects.scenarios` jsonb does **not** | Athena. Use new `projects.scenario_flags jsonb` column (Q4) — different from both. |
| Three stores diverge | "Three independent stores. No single source of truth." | All three stores empty (0 rows in `project_event_choices`, 0 in `scenarios`, DOM-only). Risk dormant. | Athena. Design picks one store going forward; nothing to migrate. |
| Spawn FK is "broken" | Implied | Athena R3: 4 spawn rules point at inactive `upc.apl.merits`. | m's Q5 inverts: the unification was the bug, not the FK. Re-split apl into merits/cost/order (§3.1). |
### §0.2 The pre-ratified subset from t-paliad-327
m ratified the following on 2026-05-27 (via `AskUserQuestion`, all on-recommendation in that task) — Phase 2 carries them forward unchanged:
- Cross-party display: backend stops filtering by party, `is_cross_party` derived field, "Gegenseitig" badge, muted/greyed visual, unchecked default, write-back excluded unconditionally. (Folded into §2.4.)
- Scenario flag SSoT: `paliad.projects.scenario_flags jsonb` column + GET/PATCH `/api/projects/{id}/scenario-flags`. (Folded into §2.3.)
- Spawn-only event picker exclusion: `SearchEvents` SQL adds `AND sr.is_spawn = false`. (Folded into §2.2.)
These are not re-asked. They are the foundation Phase 2 builds on.
---
## §1 The overall connection schema (m's "big picture")
Per m's direction: document the canonical connection graph across all procedural_events + sequencing_rules + proceeding_types as a unified model.
### §1.1 Conceptual model in one paragraph
A **rule** (`paliad.sequencing_rules` row) is the atomic node. It carries one deadline for one event, on one proceeding-type. Every rule has at most one **predecessor edge** via `parent_id` → another rule whose own deadline must elapse before this one starts. The chain root (rule with `parent_id IS NULL`) is anchored to its **proceeding-type root event** (typically a filing — Klageerhebung, Veröffentlichung, Anmeldung). A small number of rules are **spawn rules** (`is_spawn=true`) — they don't compute their own deadline; instead they open a fresh proceeding of a different type, edge labelled by `spawn_proceeding_type_id`. Conditional rules carry a `condition_expr` jsonb predicate over a small flag vocabulary (`with_ccr`, `with_amend`, `with_cci`); the active subset of the graph for a given project is the rules whose predicate is satisfied by `projects.scenario_flags`. **The only canonical predecessor link is `parent_id`. The `trigger_event_id` column is deprecated** (Q1). Trigger discoverability is **derived from data**: any event whose anchor rule has `EXISTS (non-spawn child WHERE child.parent_id = anchor.id)` is a valid trigger; everything else (spawn-only consequences, terminal leaves) is filtered out at the picker (Q3, §2.2).
### §1.2 The shape — ASCII tree per representative PT
Showing 3 representative PTs (the rest follow the same structural pattern; counts in §1.4).
#### upc.inf.cfi (25 rules, depth 5, the densest tree)
```
upc.inf.cfi (Verletzungsverfahren CFI)
├─ RoP.013.1 soc Klageerhebung [claimant · M] ← anchor
│ ├─ RoP.019.1 prelim Vorl. Einwendungen [defendant · O]
│ ├─ RoP.262.2 confidentiality_response Vertraulichkeit [both · O]
│ ├─ RoP.023 sod Klageerwiderung [defendant · M]
│ │ └─ RoP.029.b reply Replik [claimant · M · ?with_ccr]
│ │ └─ RoP.029.c rejoin Duplik [defendant · M · ?with_ccr]
│ ├─ RoP.025 ccr Widerklage auf Nichtigkeit [defendant · O · ?with_ccr]
│ │ └─ RoP.029.a def_to_ccr Erwiderung auf CCR [claimant · M · ?with_ccr]
│ │ └─ RoP.029.d reply_def_ccr Replik auf Erw. CCR [defendant · M · ?with_ccr] ← X-party from claimant
│ │ └─ RoP.029.e rejoin_reply_ccr Duplik auf Replik CCR [claimant · M · ?with_ccr]
│ │ └─ RoP.030.1 app_to_amend Antrag auf Patentänderung [claimant · M · ?with_amend]
│ │ └─ RoP.032.1 def_to_amend Erwiderung auf Änderung [defendant · M · ?with_amend]
│ │ └─ RoP.032.3 reply_def_amd Replik auf Erw. Änderung [claimant · M · ?with_amend]
│ │ └─ RoP.032.3 rejoin_amd Duplik auf Replik Änderung [defendant · M · ?with_amend]
│ ├─ RoP.333.2 cmo_review Antrag CMO-Überprüfung [both · O]
│ ├─ RoP.109.1 translation_request Übersetzungsantrag [both · O]
│ ├─ RoP.109.5 translations_lodge Übersetzungen einreichen [both · M]
│ ├─ RoP.118.4 cons_orders Antrag Folgenanordnungen [both · O]
│ ├─ RoP.151 cost_app Kostenantrag [both · O]
│ ├─ RoP.353 rectification Berichtigungsantrag [both · O]
│ └─ RoP.220.1.a appeal_spawn ⇲ Berufungsverfahren öffnen [both · O · SPAWN→ upc.apl.merits]
├─ RoP.104 interim Zwischenanhörung [court · M]
├─ (n/a) oral Mündliche Verhandlung [court · M]
├─ (n/a) decision Endentscheidung [court · M]
│ (Note: interim/oral/decision are court-set; they're chain-anchored but
│ have no scheduled rule of their own — phase markers carried via event_kind.)
└─ RoP.109.4 interpreter_cost Dolmetscherkosten [court · M]
```
**Legend.** `[party · M|O · ?flag · SPAWN→target]`. `M` = mandatory, `O` = optional. `?flag` = conditional on the scenario flag. ← X-party = cross-party row vs claimant perspective; see §2.4 for display. SPAWN → opens a new proceeding under that PT.
#### upc.rev.cfi (17 rules, depth 4, mirrors inf.cfi shape)
Same SoC → SoD → Reply → Rejoinder spine; CCR mirrored as Erwiderung auf Widerklage on revocation. `with_cci` (Widerklage auf Verletzung — the inverse of with_ccr) replaces `with_ccr`. Same `with_amend` branch for R.30. 13 chain-linked, 5 roots, 1 spawn (→ upc.apl.merits, post-Q5 split).
#### upc.apl (POST-Q5 SPLIT — 3 trees, 16 rules total)
After §3.1 mig: id=160 `upc.apl.unified` is retired; rules re-bound to the 3 reactivated PTs (id=11 `upc.apl.merits` 7 rules / id=19 `upc.apl.cost` 2 rules / id=20 `upc.apl.order` 7 rules). Trees:
```
upc.apl.merits (7 rules)
├─ RoP.224.1.a notice Berufungseinlegung
│ └─ RoP.224.2.a grounds Berufungsbegründung
│ └─ RoP.235.1 response Berufungserwiderung
│ └─ RoP.237 cross_a Anschlussberufung
│ └─ RoP.238.1 cross_a_reply Erwiderung Anschlussberufung
├─ (n/a) oral Mündliche Verhandlung [court · M]
└─ (n/a) decision Entscheidung [court · M]
upc.apl.cost (2 rules)
├─ RoP.221.1 leave_app Antrag auf Berufungszulassung
└─ (n/a) decision Kostenfestsetzungsbeschluss
upc.apl.order (7 rules)
├─ (n/a) order angegriffene Entscheidung
│ ├─ RoP.220.2 with_leave Berufung mit Zulassung
│ └─ RoP.220.3 discretion Ermessensüberprüfung
├─ RoP.224.2.b grounds_orders Berufungsbegründung (Orders Track)
│ └─ RoP.235.2 response_orders Berufungserwiderung (Orders Track)
└─ RoP.237 cross Anschlussberufung
└─ RoP.238.2 cross_reply Erwiderung Anschlussberufung
```
The 3 trees are independent. Determinator UX (proceeding_mapping.go) keeps a single user-facing "Berufung" entry that fans out to one of the 3 based on what's being appealed (judgment → merits, cost decision → cost, order → order). Routing layer unchanged from t-paliad-204 S1; only the data shape changes.
The remaining 14 ruled PTs (de.inf.lg / .olg / .bgh, de.null.bpatg / .bgh, dpma.opp / .appeal.bpatg / .bgh, epa.opp.opd / .opp.boa / .grant.exa, upc.dmgs.cfi, upc.disc.cfi, upc.pi.cfi) follow the same shape — root anchored on a filing/grant event, chain depth 1-3, optionals and conditionals branching off the root or first-hop. Athena's §4 gap map gives the per-PT P/R counts; see also §1.4 below.
### §1.3 Cross-PT edges — the spawn graph (post-Q5)
```mermaid
graph LR
upc_inf_cfi[upc.inf.cfi<br/>Verletzungsverfahren CFI] -.->|R.220.1.a<br/>appeal_spawn| upc_apl_merits[upc.apl.merits<br/>Berufung Hauptsache]
upc_rev_cfi[upc.rev.cfi<br/>Nichtigkeitsverfahren CFI] -.->|R.220.1.a<br/>appeal_spawn| upc_apl_merits
upc_dmgs_cfi[upc.dmgs.cfi<br/>Schadensbemessung] -.->|R.220.1.a<br/>appeal_spawn| upc_apl_merits
upc_pi_cfi[upc.pi.cfi<br/>Einstweilige Maßnahmen] -.->|R.220.1.a<br/>appeal_spawn| upc_apl_order[upc.apl.order<br/>Berufung Orders Track]
```
4 spawn edges, all in the UPC CFI cluster. PI appeals go to the orders track (not main proceedings); the rest go to merits. The cost-decision-appeal track (`upc.apl.cost`) is reached not via spawn but via direct filing (`leave_app` rule); cost decisions arrive within their parent proceeding and the cost-appeal opens as a standalone application.
DE-side, EPA-side, DPMA-side: no spawn edges today. Each tier-of-court is a separate `proceeding_type` (de.inf.lg / .olg / .bgh) with its own root + chain; chained-by-instance is not modelled as a spawn (the user explicitly creates a new project for the appeal stage). m may revisit this if DE-side workflow benefits from spawn edges; out of scope for this revision.
### §1.4 Per-PT health summary (post-Q5)
| PT code | rules | roots | chained | conditional | spawns | gap |
|---|--:|--:|--:|--:|--:|---|
| upc.inf.cfi | 25 | 4 | 21 | 10 | 1 | 84% chained — strongest |
| upc.rev.cfi | 17 | 4 | 13 | 8 | 1 | 76% |
| upc.apl.merits | 7 | 3 | 4 | 0 | 0 | post-Q5 split — to be re-rooted |
| upc.apl.order | 7 | 3 | 4 | 0 | 0 | post-Q5 split |
| upc.apl.cost | 2 | 1 | 1 | 0 | 0 | post-Q5 split |
| de.inf.lg | 9 | 5 | 4 | 0 | 0 | 44% — gappy |
| de.null.bpatg | 10 | 4 | 6 | 0 | 0 | 60% |
| de.inf.olg | 7 | 1 | 6 | 0 | 0 | 86% |
| de.inf.bgh | 8 | 1 | 7 | 0 | 0 | 88% |
| de.null.bgh | 6 | 1 | 5 | 0 | 0 | 83% |
| dpma.opp.dpma | 4 | 1 | 3 | 0 | 0 | 75% |
| dpma.appeal.bpatg | 5 | 1 | 4 | 0 | 0 | 80% |
| dpma.appeal.bgh | 4 | 1 | 3 | 0 | 0 | 75% |
| epa.opp.opd | 8 | 2 | 6 | 0 | 0 | 75% |
| epa.opp.boa | 8 | 3 | 5 | 0 | 0 | 63% |
| epa.grant.exa | 7 | 4 | 3 | 0 | 0 | 43% |
| upc.dmgs.cfi | 8 | 4 | 4 | 0 | 1 | 50% |
| upc.pi.cfi | 7 | 3 | 4 | 0 | 1 | 57% |
| upc.disc.cfi | 4 | 1 | 3 | 0 | 0 | 75% |
| **Empty (Q6)** | | | | | | |
| upc.bsv.cfi | 0 | — | — | — | — | unruled — badge "Keine Regeln" |
| upc.ccr.cfi | 0 | — | — | — | — | unruled — badge |
| upc.costs.cfi | 0 | — | — | — | — | unruled — badge |
| upc.dni.cfi | 0 | — | — | — | — | unruled — badge |
| upc.epo.review | 0 | — | — | — | — | unruled — badge |
| upc.pl.cfi | 0 | — | — | — | — | unruled — badge |
Plus **73 legacy globals** sitting in the corpus with `proceeding_type_id IS NULL` — these are the editorial backfill target (Q2 / §4.2). Each needs to be reparented onto one of the 23 PTs.
---
## §2 Tier 1 — model decisions (m ratified all 4 on-recommendation)
### §2.1 `parent_id` is the canonical predecessor link
`paliad.sequencing_rules.parent_id` (uuid FK to another rule) is the **only** predecessor pointer going forward. `paliad.sequencing_rules.trigger_event_id` (bigint FK to legacy `paliad.trigger_events`) gets dropped at the end of the migration train (§5).
**Implication for the 75 rules that currently use `trigger_event_id`:**
- The 73 legacy globals (proceeding_type_id IS NULL): editorial walk reparents each onto a real PT chain (Q2, §4.2). Slow but right — no data is lost, just structurally normalised.
- The 2 hybrid rules (both parent_id AND trigger_event_id set): keep `parent_id`, NULL out `trigger_event_id`. No data loss — `parent_id` already carries the live edge.
After backfill, `trigger_event_id` is unused — safe to drop the column (§5, Mig P4).
### §2.2 Trigger discoverability — derive from data
A `procedural_event` is a **picker-eligible trigger** when EXISTS a published+active non-spawn rule with `parent_id` pointing at this event's anchor rule. The picker SQL gains:
```sql
WHERE EXISTS (
SELECT 1 FROM paliad.sequencing_rules child
WHERE child.parent_id = anchor.id
AND child.is_active = true
AND child.lifecycle_state = 'published'
AND child.is_spawn = false -- spawn-only consequences not pickable (t-paliad-327 §3a)
)
```
No new column. No materialised view. The EXISTS subquery uses the existing `sequencing_rules.parent_id` index. At today's scale (226 rules) it's cheap; at 10× scale still fine (parent_id is indexed; child lookup is index-only scan).
Mode A's `SearchEvents` (`internal/services/fristenrechner_search_events.go`) and Mode B R4's chip-strip both apply this filter. Terminal leaves (Duplik etc.) stay pickable — they have a non-spawn anchor rule and result in an empty follow-up list, which is honest UX (t-paliad-327 §3a.4, m ratified).
### §2.3 Scenario state SSoT — `projects.scenario_flags jsonb`
Reconfirmed from t-paliad-327 §3.2:
```sql
ALTER TABLE paliad.projects
ADD COLUMN scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb;
```
Shape:
```json
{ "with_ccr": true, "with_amend": false, "with_cci": false }
```
Whitelist-validated against the set of flag names appearing in `sequencing_rules.condition_expr` (today: `with_ccr`, `with_amend`, `with_cci`).
API: `GET /api/projects/{id}/scenario-flags` returns the map; `PATCH /api/projects/{id}/scenario-flags` accepts partial deltas (null deletes a key).
**Kontextfrei (no project):** stays on localStorage. No DB writes when `project_id IS NULL`.
**Relationship with `paliad.scenarios`:** complementary, not duplicate. `scenarios.spec.flags[]` (the Litigation Planner Slice D shape) is a *named snapshot*; activating a scenario copies its flag array into `projects.scenario_flags`. Live edits write to `scenario_flags`. `paliad.project_event_choices` (the legacy empty table) is deprecated (§4.3).
### §2.4a Selection state + detail-level view-mode filter
m's reframe (14:40): the real ask isn't "rarity" — it's **detail-level control over the timeline**. Every event/rule is a card; the user picks which optional cards belong to *their* scenario; the Verfahrensablauf has a view-mode toggle that controls how much of the picture surfaces.
m's quote (14:40): *"It is more that I want a grade of detail in our swimlane display […] I want to show them but also be able to 'focus' by not displaying optional things. And we can select these options somehow, for example like we do with the appeal in the Decision dropdown. And if none is selected, none are displayed. We need an option 'Show unselected options' or 'show only selected' or 'mandatory' […] It would be great to basically filter events from the timeline based on whether they are selected in this scenario."*
The underlying mental model:
- **Mandatory rules** are always in the scenario. They render in every view-mode. The user cannot deselect them.
- **Recommended rules** are *selected by default* in the scenario. The user can deselect them.
- **Optional rules** are *not selected by default*. The user opts in via the same UI mechanism that already exists for `with_ccr` / `with_amend` (a chip / dropdown / "Aufnehmen" CTA per rule).
- **Conditional rules** (with `condition_expr`) are gated by scenario flags first, then by selection (a conditional rule whose flag is on still respects its priority's default selection rule).
The Verfahrensablauf gets a three-way **detail-level toggle** (§3.3a):
- **Nur Pflicht (Mandatory only)** — only `priority='mandatory'` cards.
- **Gewählt (Selected)** — mandatory + every rule the scenario has explicitly selected. Default.
- **Alle Optionen (All considered)** — every rule that *could* belong, including unselected optionals (rendered with a dotted border + "Aufnehmen" CTA) and conditional rules whose flag isn't set (rendered greyed with a "wenn-…" hint).
#### Schema — no new column on `sequencing_rules`
The original §2.4a strawman proposed `is_edge_case boolean` as a chain-head flag. m's reframe makes that wrong: **every** optional rule is potentially "rare" depending on the lawyer's scenario; the dimension isn't a property of the rule, it's a property of the scenario.
Instead, the selection state lives entirely in **`projects.scenario_flags jsonb`** (already on the table from P0, §2.3) with an extended shape:
```json
{
"with_ccr": true,
"with_amend": false,
"with_cci": false,
"rule:<uuid_of_recommended_X>": false,
"rule:<uuid_of_optional_Y>": true
}
```
The flat-map shape stays — entries are either named scenario flags (`with_*`) or per-rule selection deviations (`rule:<uuid>`). Storage only carries **deviations from the priority default**:
- `priority='recommended'` is selected-by-default; `rule:X = false` records an explicit deselection.
- `priority='optional'` is unselected-by-default; `rule:X = true` records an explicit selection.
- `priority='mandatory'` is always selected; trying to store `rule:X = false` is rejected (422 from the PATCH endpoint).
Whitelist (Q9 catalog) gains a wildcard pattern `rule:<uuid>` — any well-formed UUID matches; the handler validates that the UUID resolves to an active+published rule on the project's proceeding_type before persisting.
Kontextfrei (no project): localStorage stores the same shape under a per-PT key (`scenario:upc.inf.cfi`). Different PT → different stored selection set; this matches how kontextfrei users explore.
#### Visual — generalising the CCR dropdown to per-rule chips
The existing `with_ccr` / `with_amend` checkboxes are *coarse* scenario flags. The new per-rule selection is *fine-grained* but uses the same UI vocabulary:
- **Selected rule**: solid card, normal background. (Identical to today's mandatory render.)
- **Selected optional that's deselectable**: solid card with a small `[Entfernen]` chip; click removes from `selected_optionals` (writes `rule:X = false`).
- **Unselected optional (default state in "Alle Optionen" mode)**: dotted-border card, muted background, `[Aufnehmen]` CTA. Click writes `rule:X = true`.
- **Conditional rule whose flag isn't set**: greyed card with a "Aktivieren via 'Mit Widerklage' im Szenario" hint; clicking the hint scrolls to the scenario-flags strip.
- **Cross-party** (§2.4): orthogonal — applies its `Gegenseitig` badge and muted style on top of whichever state above.
Each card thus carries up to four orthogonal axes of display state — priority, selection, conditional-gate, cross-party. The 4 axes compose; no axis dominates.
#### Subtree semantics — implicit via parent chain
When a chain head is deselected (e.g. R.109.1 Übersetzungsantrag = `false`), its descendants in the parent_id tree (R.109.4 Mitteilung etc.) **inherit the deselected state for display** without needing their own entries in `selected_optionals`. The tree renderer walks the chain; if any ancestor is unselected, the descendant doesn't render in "Gewählt" mode. In "Alle Optionen" mode, the whole subtree renders greyed under the deselected head.
If a descendant has its own explicit `rule:X = true` entry, that overrides the ancestor — the user has explicitly pulled this leaf into their scenario despite not selecting the parent. Edge case; documented but no special UI affordance.
#### Default population on project creation
When a project is created with `proceeding_type_id = X`, the server seeds `scenario_flags = {}`. Nothing in the map. The tree renderer computes per-rule selection on-the-fly from priority + scenario_flags entries. No upfront write-storm of "rule:X = true" for every recommended rule — only deviations land in storage.
#### Why this beats the `is_edge_case` boolean
- **No new column.** All state lives in the existing `projects.scenario_flags jsonb` from P0.
- **Generalised.** Every optional rule is selectable, not just the few flagged as "rare". m's "sequence density is very high" complaint is solved by the user controlling which optionals belong to *their* scenario, rather than the editorial process having to decide globally which rules deserve dotted-border treatment.
- **Composable with condition_expr.** A conditional rule is selectable when its flag is on; the selection state is independent of the flag state.
- **Matches m's stated UX prior art.** The CCR dropdown pattern *is* the model; we're just generalising it from 3 named flags to N per-rule selections.
### §2.4 Cross-party display
From t-paliad-327 §2 (m ratified on-recommendation all 8 sub-Qs):
- Backend: drop the perspective WHERE clause in `queryFollowUpRows`; return all rows; add server-computed `is_cross_party` boolean.
- UI: render cross-party rows with a `Gegenseitig` badge, muted/greyed style, unchecked by default, date visible.
- Write-back: cross-party rows are **unconditionally excluded** from the project-deadline bulk insert, even if the user manually checks the box.
Composite `condition_expr` (and-of-flags) — checkbox is read-only in the result view; Verfahrensablauf is the canonical toggle surface for individual flags.
Sync: `document.dispatchEvent(new CustomEvent('scenario-flag-changed', { detail: { flag, value } }))`. Single-tab v1; cross-tab in Akte mode deferred.
---
## §3 Tier 2 — surface decisions
### §3.1 Appeal re-split: revert upc.apl.unified → merits/cost/order (m's Q5 divergent pick)
**m's call (2026-05-27):** *"Reverse the unification as suggested in 3. They are different proceedings, I only wanted the approach to be unified in the 'determinator' — but they are actually different proceedings!"*
The current state (mig 096 unified the appeal track):
- id=160 `upc.apl.unified` is `is_active=true`, holds 16 rules.
- id=11 `upc.apl.merits` is `is_active=false`.
- id=19 `upc.apl.cost` is `is_active=false`.
- id=20 `upc.apl.order` is `is_active=false`.
- 4 spawn rules point at id=11 (inactive) — looks like the R3 bug but is actually correctly aimed at merits since cost+order arrive differently (athena R3 partially mis-classified the situation).
- Event codes already carry the split prefix: `upc.apl.{merits,cost,order}.*`. 16 events split cleanly into 7 merits + 2 cost + 7 order.
The migration:
```sql
-- Mig P1: re-activate the three discrete appeal PTs and retire the unified row.
UPDATE paliad.proceeding_types SET is_active = true WHERE id IN (11, 19, 20);
UPDATE paliad.proceeding_types SET is_active = false WHERE id = 160;
-- Mig P1: re-target each rule whose proceeding_type_id is currently 160
-- to the right reactivated PT based on its event_code prefix.
UPDATE paliad.sequencing_rules sr
SET proceeding_type_id = 11
FROM paliad.procedural_events pe
WHERE pe.id = sr.procedural_event_id
AND sr.proceeding_type_id = 160
AND pe.code LIKE 'upc.apl.merits.%';
UPDATE paliad.sequencing_rules sr
SET proceeding_type_id = 19
FROM paliad.procedural_events pe
WHERE pe.id = sr.procedural_event_id
AND sr.proceeding_type_id = 160
AND pe.code LIKE 'upc.apl.cost.%';
UPDATE paliad.sequencing_rules sr
SET proceeding_type_id = 20
FROM paliad.procedural_events pe
WHERE pe.id = sr.procedural_event_id
AND sr.proceeding_type_id = 160
AND pe.code LIKE 'upc.apl.order.%';
-- 4 spawn FKs: stay at id=11 (merits) for inf/rev/dmgs; update upc.pi.cfi's
-- spawn to point at id=20 (order) — appeals against PI orders go to the
-- orders track, not merits.
UPDATE paliad.sequencing_rules
SET spawn_proceeding_type_id = 20
WHERE is_spawn AND procedural_event_id = (
SELECT id FROM paliad.procedural_events WHERE code = 'upc.pi.cfi.appeal_spawn'
);
-- The other 3 spawn rules (inf/rev/dmgs) keep spawn_proceeding_type_id = 11
-- (correct after re-activation).
```
**Determinator UX preserved.** `internal/services/proceeding_mapping.go` (t-paliad-204 S1) keeps its single "Berufung" front door. The mapping fans out to id=11/19/20 based on what's being appealed (judgment / cost decision / order). No user-facing routing change. The change is purely structural.
**Active scenarios / projects pointing at id=160:** none (`paliad.scenarios` and `paliad.projects.active_scenario_id` both empty per athena §0; only 6 projects have any `proceeding_type_id` set and none of them is 160). Zero data migration on the project side.
### §3.2 Empty PTs — show with "Keine Regeln gepflegt" badge
Per m's Q6 — option 2 with a follow-on editorial note ("We need to publish rules then... but yeah, show with the badge for now"):
Picker query for `/api/tools/proceeding-types` gains a flag-not-filter:
```sql
SELECT pt.*,
EXISTS (
SELECT 1 FROM paliad.sequencing_rules sr
WHERE sr.proceeding_type_id = pt.id
AND sr.is_active AND sr.lifecycle_state = 'published'
) AS has_rules
FROM paliad.proceeding_types pt
WHERE pt.is_active AND pt.kind = 'proceeding';
```
Frontend renders the chip with a muted/disabled treatment + badge "Keine Regeln gepflegt" when `has_rules = false`. Project creation can still bind to an empty PT (admin override), but Mode A/B/Verfahrensablauf surface a clear "this proceeding has no seeded rules yet" message.
Editorial follow-up: m publishes rules for the 6 empty PTs (`upc.bsv.cfi`, `upc.ccr.cfi`, `upc.costs.cfi`, `upc.dni.cfi`, `upc.epo.review`, `upc.pl.cfi`) over time; each new published rule auto-removes the badge for its PT. Not blocking this design.
### §3.3 Entry A — extend /tools/verfahrensablauf
Per m's Q7. The existing `/tools/verfahrensablauf` page (used by `frontend/src/client/verfahrensablauf.ts` + shared `views/verfahrensablauf-core.ts`) already serves the pick-a-PT shape. Extend it to:
- Render the parent_id chain as a **collapsible tree** (top-down chronological). Same data shape as §1.2's ASCII trees.
- Expose **optionals + conditionals as toggleable checkboxes** in the tree itself. Ticking writes via `PATCH /api/projects/{id}/scenario-flags` (Akte mode) or localStorage (kontextfrei).
- Reflect cross-party rows with the same muted style as §2.4 (Gegenseitig badge).
- Spawn rows render as **leaf with edge annotation** (⇲ Berufungsverfahren öffnen) and a "create child case" CTA in Akte mode.
- Optionally: a "Zur Frist-Ansicht" deeplink on each tree node → opens Mode B Fristenrechner with that event pre-locked as the trigger.
Backend: extend `/api/tools/fristenrechner` (the proceeding-type fan-out endpoint) to return a tree-shaped payload (`parent_id` resolved into nested children). New handler param or new endpoint `/api/tools/verfahrensablauf/tree?proceeding_type_code=X&project=Y`.
The legacy `/tools/fristenrechner?legacy=1` Procedure-mode page deprecates naturally — same scope, replaced by this Entry A view.
### §3.3a Verfahrensablauf view-mode toggle
A three-way segmented control above the tree at the Verfahrensablauf surface:
```
┌─ Anzeige ──────────────────────────────────────┐
│ ( ) Nur Pflicht (•) Gewählt ( ) Alle Optionen │
└────────────────────────────────────────────────┘
```
Behaviour:
- **Nur Pflicht**: only `priority='mandatory'` cards render. Tightest view.
- **Gewählt** (default): mandatory + every rule that resolves to "selected" given current scenario state (mandatory always; recommended unless explicitly deselected via `rule:X = false`; optional only if explicitly selected via `rule:X = true`; conditional only if its flag predicate holds AND the priority-default-or-deviation puts it in the selected set). Honest summary of what *this* lawyer has chosen for *this* project.
- **Alle Optionen**: everything that could belong, with unselected optionals rendered with the dotted-border + `[Aufnehmen]` CTA, and conditional rules whose flag isn't set rendered greyed with the activation hint.
**Persistence**: per-user, per-browser via `localStorage` under key `verfahrensablauf:view_mode`. Not project-scoped — the same user looking at two different projects probably wants the same verbosity. Not in `scenario_flags` either — view-mode is a UI preference, not a scenario fact. No new schema; no API; no migration.
Cross-surface sync: the **Mode B result view** does NOT carry its own view-mode toggle. It always renders in "Gewählt" semantics (mandatory + selected). Rationale: Mode B locks a single trigger event and lists its follow-ups; the lawyer isn't browsing the full ablauf, they're focused on one moment. The view-mode toggle is a Verfahrensablauf-only affordance.
The view-mode toggle composes with the scenario-flags strip (§2.3). Toggling "Mit Widerklage auf Nichtigkeit" off in "Gewählt" mode removes the CCR conditional branch from view; flipping to "Alle Optionen" re-renders the CCR branch greyed with the activation hint. The user can see what they're *not* currently considering without losing the simplified default view.
### §3.4 Legacy `/api/tools/event-deadlines` deprecation
Per m's Q8. Sequence:
1. **Mig P3 — 73-globals reparenting completes** (§4.2, editorial work). Once `paliad.sequencing_rules WHERE proceeding_type_id IS NULL` is empty, the legacy route has no live data shape it uniquely serves.
2. **Code drop:** remove `/api/tools/event-deadlines` route + `EventDeadlineService` + the `deadline_rule_service.go:226-285` label-fallback path + the `ExportService:1680` workbook sheet.
3. **Table drop:** `DROP TABLE paliad.trigger_events` (mig P4, §4.3).
4. **Snapshot generator:** `cmd/gen-upc-snapshot/main.go` stops reading `paliad.trigger_events`; UPC snapshot for youpc.org only carries the unified rule shape.
The cleanup is gated on §4.2 completion. If editorial backfill is slow, the route can live behind a `/api/legacy/` prefix until done — but the design assumption is that we close the loop within the slice train.
---
## §4 Tier 3 — editorial + cleanup framework
### §4.1 `condition_expr` grammar formalisation
Per m's Q9. The grammar:
```ts
type CondExpr =
| { flag: KnownFlag } // leaf
| { op: 'and' | 'or'; args: CondExpr[] } // composite (recursive)
type KnownFlag = 'with_ccr' | 'with_amend' | 'with_cci' // closed set; extensible via admin
```
Implementation:
- A JSON-schema validator in `RuleEditorService.create`/`update` rejects writes that don't match. Today's 18 rules all conform; no data migration.
- Known-flag whitelist sourced from a small Go constant + an admin-editable `paliad.scenario_flag_catalog(name, description, added_at)` table — keeps the vocabulary discoverable. (Lightweight ALTER, not a major migration.)
- Engine consumer (`pkg/litigationplanner/expr.go`, currently a switch over string literals) gains exhaustive-case enforcement against the same catalog. Linter catches drift between catalog and engine.
`choices_offered` and `applies_to_target` (athena R11) — same grammar treatment in a separate ticket (not blocking this revision). Document their 3 known shapes (`appellant`, `skip`, `include_ccr`) in code comments meanwhile.
### §4.2 Editorial backfill workflow — `/admin/procedural-events` parent-NULL filter
Per m's Q10:
- Add filter chip "parent: nicht gesetzt" to the admin list at `/admin/procedural-events`. The filter URL `?parent_filter=null` (or similar).
- Track completion per PT via the existing gap-map query (athena §3.1) — show as a progress bar in the admin shell ("upc.inf.cfi: 4/4 roots OK" / "de.inf.lg: 2/5 roots remain").
- For the 73 globals: a separate filter `?orphan=true` showing only `proceeding_type_id IS NULL` rules. m clicks each, assigns a PT + parent rule via the editor.
- Each save flips lifecycle_state to draft (unchanged from existing editor flow); m publishes a batch when satisfied with a PT.
No new code surface — the existing admin list + editor handle everything once the filter is added.
This is editorial work, not coder work. The design captures the framework; m drives the content at his own cadence. No mig is gated on completion (the parent-NULL filter is a feature add; rules stay valid in their current shape during the walk).
#### §4.2.1 Worked editorial example — R.109 translation chain
m flagged this case (14:35) as a concrete instance of malformed parent-chain shape. The current data for `upc.inf.cfi`:
| rule | event | current parent | current primary_party | correct shape |
|---|---|---|---|---|
| `RoP.109.1` | `upc.inf.cfi.translation_request` (Antrag auf Simultanübersetzung) | upc.inf.cfi root (Mündliche Verhandlung) | both | parent stays at MV; flagged optional (default-unselected) |
| `RoP.109.4` | `upc.inf.cfi.interpreter_cost` (Mitteilung Dolmetscherkosten) | upc.inf.cfi root (Mündliche Verhandlung) — **WRONG** | court — **WRONG** | parent = R.109.1; primary_party = both (parties give the Mitteilung, not the court); condition_expr = `{"flag": "with_interpreter_denied"}` |
| `RoP.109.5` | `upc.inf.cfi.translations_lodge` (Übersetzungen einreichen) | upc.inf.cfi root | both | parent = R.109.1 (lodging follows the request); priority stays mandatory but conditional via `{"flag": "with_translation_granted"}` |
Two new scenario flags introduced (`with_interpreter_denied`, `with_translation_granted`) get added to the `scenario_flag_catalog` (§4.1) when the editor saves these rules.
Editorial walk for m:
1. Open `/admin/procedural-events?orphan=false&parent_filter=null&proceeding_type=upc.inf.cfi`.
2. Find R.109.1, R.109.4, R.109.5 — they sit at depth 1 under the root.
3. Edit R.109.4: set `parent_id = <R.109.1's id>`; set `primary_party = both`; set `condition_expr = {"flag": "with_interpreter_denied"}`. Save (draft).
4. Edit R.109.5: set `parent_id = <R.109.1's id>`; set `condition_expr = {"flag": "with_translation_granted"}`. Save (draft).
5. Publish both.
6. The catalog accepts the two new flag names; the validator updates.
Result in the Verfahrensablauf tree (post-fix):
```
upc.inf.cfi root
├─ Mündliche Verhandlung (court · M)
├─ Antrag auf Simultanübersetzung (RoP.109.1) [both · O]
│ ├─ Mitteilung Dolmetscherkosten (RoP.109.4) [both · M · ?with_interpreter_denied]
│ └─ Übersetzungen einreichen (RoP.109.5) [both · M · ?with_translation_granted]
```
In **Gewählt** mode without scenario flags: only the root + Mündliche Verhandlung surface. R.109.1 is an unselected optional → hidden. R.109.4 + R.109.5 are conditional + below an unselected ancestor → hidden.
In **Gewählt** mode after the user clicks `[Aufnehmen]` on R.109.1: R.109.1 appears. R.109.4 still hidden (its flag `with_interpreter_denied` isn't set; the user would need to know the court denied the Antrag, then tick the flag in the Szenario-Flags strip). R.109.5 similarly hidden until `with_translation_granted` is on.
In **Alle Optionen** mode: every rule renders, conditionals greyed with their flag hint, R.109.1 dotted with `[Aufnehmen]`.
This is the model in miniature: the editorial fix is data-only (no schema change, just `parent_id` + `condition_expr` + `primary_party` UPDATEs via the editor); the display fix is policy that the existing scenario_flags + view-mode mechanism already supports.
### §4.3 `paliad.trigger_events` table fate — drop
Per m's Q11. Sequence (chained to §3.4):
1. After 73-globals reparented + route dropped + label-fallback ported to `procedural_events.name`:
2. `DROP TABLE paliad.trigger_events` (mig P5, last in the train).
3. Migrate `cmd/gen-upc-snapshot/main.go` to no longer SELECT from this table.
4. Remove the `ref__trigger_events` sheet from `ExportService` workbook output.
The bigint PK / parallel taxonomy disappears entirely. `procedural_events` (uuid PK) is the only event catalog.
---
## §5 Schema delta + migration plan (slice train)
Six slices, sequential where data-coupled, parallelisable where not. Each slice ships as one or two PRs.
| Slice | Mig | What ships | Reversible? |
|---|---|---|---|
| **P0 — Scenario SSoT** | mig 154 | `ALTER TABLE projects ADD COLUMN scenario_flags jsonb`; GET/PATCH endpoints w/ extended whitelist (named flags + `rule:<uuid>` per-rule entries, validated against project's PT rule set); Verfahrensablauf + result-view binding; `scenario_flag_catalog` table (§4.1) | Yes — DROP COLUMN |
| **P1 — Appeal re-split** | mig 155 | UPDATE proceeding_types (re-activate 11/19/20, deactivate 160); UPDATE sequencing_rules (rebind 16 rules to merits/cost/order by event_code prefix); UPDATE pi.cfi spawn FK → 20 | Reversible by inverse UPDATEs; documented in down mig |
| **S1+S1a from t-paliad-327** | — | Cross-party display backend + frontend; spawn-only picker filter (`sr.is_spawn = false` in SearchEvents) | Yes — code-only |
| **P2 — Empty-PT badge** | — | `has_rules` flag on /api/tools/proceeding-types; frontend muted-chip rendering | Yes — code-only |
| **P3 — Entry A (Verfahrensablauf tree)** | — | Tree endpoint + tree UI in /tools/verfahrensablauf; three-way view-mode toggle (localStorage); per-rule `[Aufnehmen]`/`[Entfernen]` chips wire to scenario_flags `rule:<uuid>` entries; subtree-hide-on-unselected-ancestor render logic | Yes — code-only |
| **P4 — Editorial walk (73 globals)** | — | parent-NULL filter on /admin/procedural-events; editorial work by m (no coder task per se) | Trivially reversible |
| **P5 — trigger_event_id deprecation** | mig 156 | DROP `/api/tools/event-deadlines`; DROP `EventDeadlineService`; port label-fallback in deadline_rule_service.go; remove ref__trigger_events sheet; `ALTER TABLE sequencing_rules DROP COLUMN trigger_event_id`; `DROP TABLE trigger_events`; condition_expr write-time validator | Last; downgrade requires re-adding column + re-populating — irreversible in practice |
Constraint: **P5 is gated on P4 completion** (no rules can have NULL proceeding_type_id when DROP runs). All other slices ship independently.
Ordering rationale:
- P0 unblocks the Fristenrechner-side bugs immediately (no waiting on appeal-split editorial).
- P1 is data-only, low risk, can land in parallel with P0.
- S1+S1a are code-only follow-ons to P0 (same scenario-flag plumbing).
- P2 ships once P1 lands (re-activated PTs need badge support too).
- P3 builds on P2 + the tree endpoint; depends on P0 for flag persistence.
- P4 is m's editorial work — duration depends on m's cadence, not coder velocity.
- P5 is the cleanup at the end. Only safe when P4 is done.
---
## §6 Entry A UI spec (sequence-from-proceeding-type)
Live URL: `/tools/verfahrensablauf?project=<id>&proceeding_type=upc.inf.cfi`.
### §6.1 Layout
```
┌─ Akte / kontextfrei ─────────┐ ┌─ Verfahren ──┐ ┌─ Anzeige ──────────────────────────┐
│ HL-2024-001 ▼ │ ohne Akte │ │ upc.inf.cfi ▼│ │ Nur Pflicht ⦿ Gewählt ○ Alle Optionen │
└──────────────────────────────┘ └──────────────┘ └────────────────────────────────────┘
┌─ Szenario-Flags ──────────────────────────────────┐
│ ☑ Mit Widerklage auf Nichtigkeit (with_ccr) │
│ ☐ Mit Antrag auf Patentänderung R.30 (with_amend) │
│ ☐ Mit Widerklage auf Verletzung (with_cci) │
└────────────────────────────────────────────────────┘
┌─ Ablauf ── (view-mode: Gewählt) ───────────────────────────────────┐
│ 📥 Klageerhebung [claimant · mandatory] │
│ ├─ Klageerwiderung [defendant · mandatory] │
│ │ └─ Replik [claimant · M · ?with_ccr]│
│ │ └─ Duplik [defendant · M · ?with_ccr]│
│ ├─ Widerklage auf Nichtigkeit [defendant · O · ?with_ccr][Entfernen]│ ← selected optional
│ │ └─ Erwiderung auf CCR [claimant · M · ?with_ccr]│
│ │ └─ Replik auf Erw. CCR [defendant · M · ?with_ccr][Gegenseitig]│
│ │ └─ Duplik auf Replik [claimant · M · ?with_ccr]│
│ └─ ⇲ Berufungsverfahren öffnen [SPAWN → upc.apl.merits] │
│ 🏛️ Zwischenanhörung [court · mandatory] │
│ 🏛️ Mündliche Verhandlung [court · mandatory] │
│ ⚖️ Endentscheidung [court · mandatory] │
└────────────────────────────────────────────────────────────────────┘
↓ (user flips view-mode to "Alle Optionen")
┌─ Ablauf ── (view-mode: Alle Optionen) ─────────────────────────────┐
│ 📥 Klageerhebung [claimant · mandatory] │
│ ├─ ┄ Vorl. Einwendungen [defendant · O] [Aufnehmen]┄ │ ← unselected, dotted
│ ├─ Klageerwiderung [defendant · mandatory] │
│ ├─ Widerklage auf Nichtigkeit [defendant · O · ?with_ccr][Entfernen]│
│ ├─ ┄ Antrag auf Patentänderung [O · ?with_amend] greyed │ ← flag not set
│ │ └─ wenn 'Mit Patentänderung' im Szenario aktiv │
│ ├─ ┄ Antrag auf Simultanübersetzung [O] [Aufnehmen]┄ │ ← post-§4.2.1
│ │ ├─ ┄ Mitteilung Dolmetscherkosten [M · ?with_interpreter_denied]│
│ │ └─ ┄ Übersetzungen einreichen [M · ?with_translation_granted]│
│ ├─ ┄ Antrag CMO-Überprüfung [both · O] [Aufnehmen]┄ │
│ ├─ ┄ Antrag Folgenanordnungen R.118(4) [both · O] [Aufnehmen]┄ │
│ └─ ⇲ Berufungsverfahren öffnen [SPAWN → upc.apl.merits] │
│ 🏛️ ... │
└────────────────────────────────────────────────────────────────────┘
```
### §6.2 Behaviour
- **Project picker (Step 0)** unchanged from Fristenrechner.
- **Proceeding-type picker** chips → switching re-fetches the tree.
- **View-mode toggle (§3.3a)** — three-way segmented control (Nur Pflicht / Gewählt / Alle Optionen). State in `localStorage["verfahrensablauf:view_mode"]`. Default = "Gewählt". Re-renders the tree on toggle; no network call.
- **Szenario-Flags strip** reads/writes `projects.scenario_flags` (Akte) or localStorage (kontextfrei). Same `scenario-flag-changed` CustomEvent as Mode B's result view — both surfaces stay in sync. Flag entries (`with_ccr` etc.) live alongside per-rule entries (`rule:<uuid>`) in the same jsonb.
- **Per-rule selection chips** — every non-mandatory rule's card carries `[Aufnehmen]` (unselected → tick selects) or `[Entfernen]` (selected → tick deselects). The handler PATCHes `projects.scenario_flags` with `{ "rule:<uuid>": true|false }` and fires the same `scenario-flag-changed` event.
- **Subtree hide-on-deselect** — when a chain head (any rule with children via `parent_id`) is unselected in "Gewählt" mode, its descendants don't render. The tree walker checks each rule's full ancestor chain; any unselected ancestor hides the descendant. In "Alle Optionen" mode, descendants render greyed under the unselected ancestor.
- **Cross-party rows** render with `Gegenseitig` badge, muted style (same as Mode B result view §2.4). Composes with selection state and view-mode independently.
- **Spawn rows** render as leaves with the ⇲ symbol + "Neues Verfahren öffnen" CTA (Akte mode only; kontextfrei shows the badge without the CTA). Spawn rows ignore selection state — they always render in "Gewählt" + "Alle Optionen" modes since they represent a possible next-procedure rather than an in-scenario deadline.
- **Empty PT** (the 6 unruled): tree area renders an inline "Für dieses Verfahren sind noch keine Regeln gepflegt" message + a link to /admin if the user is admin.
- **Deeplink to Mode B:** each tree node has a "Frist berechnen" link that opens `/tools/fristenrechner?event=<code>&trigger_date=…&project=…`.
### §6.3 Backend
New handler: `GET /api/tools/verfahrensablauf/tree?proceeding_type=upc.inf.cfi&project=<id>` returns:
```jsonc
{
"proceeding_type": { "code": "upc.inf.cfi", "name_de": "...", "name_en": "..." },
"scenario_flags": { "with_ccr": true, "with_amend": false },
"tree": [
{
"rule_id": "...", "event_code": "upc.inf.cfi.soc",
"name_de": "Klageerhebung", "primary_party": "claimant",
"priority": "mandatory", "has_condition": false, "is_spawn": false,
"is_cross_party": false,
"children": [
{ "rule_id": "...", "event_code": "upc.inf.cfi.sod", ... , "children": [...] },
...
]
},
... // chain-anchored roots
]
}
```
The tree is the result of walking `parent_id` recursively from the PT's root rules (those with `parent_id IS NULL` for this PT). Computed via one recursive CTE; cached per-PT (the tree shape changes only on rule edits).
`is_cross_party` is computed against `projects.our_side` (Akte mode) or the request's `?party=` query param (kontextfrei).
---
## §7 Entry B UI spec — reaffirms shipped Fristenrechner Mode A+B
Mode A (`/tools/fristenrechner?mode=search`) and Mode B (`?mode=wizard`) — both shipped via t-paliad-322 S1-S6. Surgical follow-ons from t-paliad-327 design (§0.2):
- Mode A search: add `AND sr.is_spawn = false` to `SearchEvents` WHERE block + add the derived-trigger filter `EXISTS (non-spawn child)` from §2.2. Compiled together as one PR (S1+S1a).
- Mode B R4 chip-strip: identical filter on the wizard's event-pool query.
- Result view: stop filtering follow-ups by party server-side (§2.4); render cross-party with badge.
- Scenario flag binding: result-view CONDITIONAL group reads/writes `projects.scenario_flags` via the new API (P0). Same CustomEvent sync as Entry A.
No layout changes. The mode tabs (⚡ Direkt suchen / 🧭 Geführt) stay as today. The 3rd entry path is Entry A on the verfahrensablauf page — not a Mode C.
---
## §8 Worked examples
### §8.1 Entry A — claimant on HL-2024-001 (upc.inf.cfi, with_ccr=true)
User opens `/tools/verfahrensablauf?project=HL-2024-001&proceeding_type=upc.inf.cfi`.
- Project context loads. `scenario_flags = {with_ccr: true}`.
- Tree GET returns the §1.2 shape, with conditional rules' `has_condition` flagged.
- UI renders: top-level SoC anchor → branches. The CCR branch is fully expanded because `with_ccr=true`. The R.30 amend branch renders but conditionals are greyed (with_amend=false).
- User clicks "Mit Antrag auf Patentänderung R.30" in the Szenario-Flags strip.
- Frontend fires `PATCH /api/projects/HL-2024-001/scenario-flags { with_amend: true }`. Server stores. CustomEvent dispatches.
- Tree re-renders: R.30 amend branch ungreys; conditional rules become live.
- User scrolls to "Erwiderung auf CCR" → clicks "Frist berechnen" → deeplinks to Mode B with `event=upc.inf.cfi.def_to_ccr&trigger_date=<today>&project=HL-2024-001`.
- Mode B result view loads. Cross-party RoP.029.d (defendant Replik) shows with `Gegenseitig` badge.
### §8.2 Entry B — Mode A search after picker filter
User types "Berufung" in Mode A.
- Backend SQL (post-§2.2 + post-spawn filter):
```sql
WHERE pe.name % 'Berufung' OR pe.code % 'Berufung'
AND sr.is_active AND sr.is_spawn = false
AND EXISTS (
SELECT 1 FROM paliad.sequencing_rules child
WHERE child.parent_id = sr.id AND child.is_active AND NOT child.is_spawn
)
```
- Returns: real triggers in the appeal track (`upc.apl.merits.notice`, `upc.apl.merits.grounds`, `upc.apl.order.with_leave`, etc. — post-Q5 split). Does NOT return: `upc.{inf,rev,pi,dmgs}.cfi.appeal_spawn` (spawn-only) or terminal leaves (no children).
User picks `upc.apl.merits.notice` → result view loads its follow-ups. Tree renders cleanly because the Q5 split gave merits its own chain root.
### §8.3 Editorial flow — m reparents a legacy global
m opens `/admin/procedural-events?orphan=true`. Sees the 73-row list.
- m clicks row "Antrag auf Verlängerung der Klagefrist" (one of the legacy globals with `proceeding_type_id NULL`).
- Editor opens. m assigns `proceeding_type_id = upc.inf.cfi` and `parent_id = <RoP.013.1 soc rule>`.
- Save. Rule lifecycle flips to draft. m clicks Publish.
- The rule now sits under upc.inf.cfi's tree as a hop-1 child of SoC. Mode A picker EXISTS check passes for SoC (was already passing); the tree gains one more chip.
- 72 globals to go. m walks at own cadence; no coder time blocked.
---
## §9 Out of scope
- **Calculator (`pkg/litigationplanner.CalculateRule`).** Working as designed.
- **Holiday / working-day logic.** Out of scope.
- **`choices_offered` + `applies_to_target` formalisation** (athena R11). Same shape as condition_expr would warrant — separate ticket once condition_expr formalisation ships.
- **Adding new proceeding_types.** The 23 are stable; editorial work fills the 6 unruled ones.
- **DE-side spawn edges** (LG → OLG → BGH as spawns instead of separate projects). Possible v2; not driven by current pain.
- **AI-extracted deadlines from documents.** Deferred per memory `b6a11b55…`.
- **Cross-tab scenario-flag sync in Akte mode.** Single-tab v1; SSE/WebSocket if it matters later.
- **`event_kind` ENUM-ing** (athena R10). Cosmetic; vocab is stable.
---
## §10 m's decisions (2026-05-27)
All 12 questions answered via `AskUserQuestion` on 2026-05-27 ~13:55 (3 batches of 4). 11 picks on-recommendation; Q5 diverged with verbatim reasoning. Plus 8 pre-ratified picks from t-paliad-327 carried forward (§0.2).
### Tier 1 — model decisions
- **Q1 (Trigger link canonical): `parent_id` wins, deprecate `trigger_event_id`.** [= recommendation] **Locks §2.1.** Drop the column after backfill completes.
- **Q2 (73 legacy globals fate): Reparent onto PT chains via editorial walk.** [= recommendation] **Locks §4.2.** m drives the walk at admin /admin/procedural-events; the orphan filter is the only new UI surface.
- **Q3 (Trigger discoverability): Derive from data.** [= recommendation] **Locks §2.2.** EXISTS subquery on parent_id; no new column, no view.
- **Q4 (Scenario SSoT shape): `projects.scenario_flags jsonb`.** [= recommendation; confirms t-paliad-327 design under wider scrutiny] **Locks §2.3.**
### Tier 2 — surface decisions
- **Q5 (Appeal taxonomy): Reverse the unification — split upc.apl.unified back into merits/cost/order.** [≠ recommendation; m picked option 3, "reverse the unification"] m's verbatim:
> yes, reverse the unification as suggested in 3. They are different proceedings, I only wanted the approach to be unified in the "determinator" - but they are actually different proceedings!
**Updates §1.4 + §3.1.** Mig P1 re-activates id=11/19/20, retires id=160, rebinds 16 rules by event_code prefix, retargets the pi.cfi spawn FK to id=20. Determinator routing layer (proceeding_mapping.go) keeps the single "Berufung" front door but fans out to the 3 PTs.
- **Q6 (Empty PTs): Show with "Keine Regeln gepflegt" badge for now.** [= recommendation; option 2] m's note: "We need to publish rules then... but yeah, show with the badge for now." **Locks §3.2.** Editorial follow-up is m's; not blocking the design.
- **Q7 (Entry A location): Fold into /tools/verfahrensablauf.** [= recommendation] **Locks §3.3 + §6.**
- **Q8 (Legacy /event-deadlines route): Drop after Tier 1 + 73-globals reparenting.** [= recommendation] **Locks §3.4. Gated on §4.2 completion.**
### Tier 3 — editorial + cleanup framework
- **Q9 (condition_expr grammar): Lock to `{flag: "X"} | {op: "and"|"or", args: [...]}`.** [= recommendation] **Locks §4.1.** Write-time JSON-schema validator + known-flag catalog table.
- **Q10 (Editorial backfill workflow): Admin /admin/procedural-events with parent-NULL filter.** [= recommendation] **Locks §4.2.** No new UI surface beyond the filter chip.
- **Q11 (`trigger_events` table fate): Drop after route is gone.** [= recommendation] **Locks §4.3.** Sequenced as Mig P5, last in the slice train.
- **Q12 (Visual format): ASCII trees per PT + Mermaid for spawn edges.** [= recommendation] **Locks §1.2 + §1.3.**
### 10.0a Post-ratification additions (m, 2026-05-27 14:3414:40)
After the §10 main grilling, m added three directions on top of the ratified design. None re-opened a Tier 1 decision; all extended the Verfahrensablauf surface.
- **Selection state + detail-level filter (m 14:40, supersedes earlier "rarity" framing).** Every optional rule becomes a per-scenario selectable card; selection state lives in the existing `projects.scenario_flags jsonb` with extended shape (`{flag: bool, "rule:<uuid>": bool}`). Recommended = default-selected; optional = default-unselected; mandatory = locked. Deviations only land in storage. No new column on `sequencing_rules`. **Locks §2.4a.** Replaces the pre-clarification strawman that proposed `is_edge_case boolean` — m's reframe makes that wrong (rarity is a scenario property, not a rule property).
- **View-mode toggle on Verfahrensablauf.** Three-way segmented control: Nur Pflicht / Gewählt / Alle Optionen. Per-user persistence via `localStorage["verfahrensablauf:view_mode"]`. Default "Gewählt". **Locks §3.3a.** Mode B result view does NOT carry the toggle — it's a Verfahrensablauf-only affordance.
- **R.109 chain editorial worked example.** m flagged R.109.1 / R.109.4 / R.109.5 as a concrete editorial-backfill case (wrong parent_id, wrong primary_party on R.109.4, missing condition_expr on R.109.4/.5). Folded as **§4.2.1** worked example demonstrating the parent-NULL filter workflow without code change. Two new scenario-flag names introduced (`with_interpreter_denied`, `with_translation_granted`); both land in the `scenario_flag_catalog` (§4.1) at edit time.
These additions don't change the slice train sequence (§5). They tighten P0 (the `scenario_flags` PATCH endpoint now validates `rule:<uuid>` keys against the project's active rule set) and P3 (Entry A tree now renders the view-mode toggle + per-rule selection chips), but no new mig is added.
### 10.1 What changed from the strawman as a result
Beyond §10.0a additions, the Q5 divergence is the only material change:
- **Mig P1 (appeal re-split)** is now part of the slice train. It was NOT in the strawman; the strawman assumed athena's R3 was a simple FK retarget. m's pick recasts the unification itself as the bug.
- §1.4 per-PT table shows 3 separate appeal PT rows (merits/cost/order) instead of one unified row. The 16 rules under id=160 redistribute to id=11/19/20.
- §1.3 spawn graph fan-out has merits (3 edges from inf/rev/dmgs) + order (1 edge from pi) as distinct targets instead of all 4 pointing at a single unified row.
All other §1-§8 sections hold as originally drafted.
---
## §11 Synthesis links
- mBrian: file as `[synthesis]` linked `triggered_by` t-paliad-329; `related_to` athena's assessment (`document-assessment-deadline-system`) + my proceeding_types taxonomy synthesis + Fristenrechner overhaul synthesis + t-paliad-327 follow-up rules synthesis.
- Cross-refs: `docs/assessment-deadline-system-2026-05-27.md` (athena), `docs/design-fristenrechner-followup-rules-2026-05-27.md` (atlas, pre-ratified subset), `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus, S1-S6 shipped), `docs/design-proceeding-types-taxonomy-2026-05-26.md` (atlas, mig 153 shipped).
- Related migrations: 084 (condition_expr backfill), 136 (procedural_events additive), 140 (drop legacy deadline_rules), 145 (`scenarios` table), 153 (proceeding_types.kind).
- Coder phase (deferred per inventor SKILL): runs after m ratifies. Slice ordering per §5. NOT cronus (parked) / NOT atlas (inventor). A pattern-fluent Sonnet coder picks up P0 first; P1 + S1/S1a can parallelise; P3 follows; P4 + P5 are gated on each other.

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,510 @@
# Design — `/tools/procedures` workflow tracker (m/paliad#152)
**Task:** t-paliad-337
**Gitea:** m/paliad#152
**Inventor:** atlas (shift-1, fresh — name-recycle, not the atlas from earlier today)
**Date:** 2026-05-27
**Branch:** `mai/atlas/inventor-extend-tools`
**Status:** Draft — coder gate held; m to ratify the remaining open questions via `AskUserQuestion` before any coder shift.
**Builds on:**
- `docs/design-unified-procedural-events-tool-2026-05-27.md` (cronus's U0-U4 design, shipped today as `/tools/procedures`)
- `docs/design-deadline-system-revision-2026-05-27.md` §3.3 + §3.3a (atlas Phase 2 model layer + view-mode toggle)
- `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26 Mode A+B+result, shipped via t-paliad-322)
**Reframe note (2026-05-27 21:01):** the first draft of this doc overengineered the surface — three-view toggle, separate compound drawer, separate Konstellationen drawer. m re-anchored: "clean display of timelines that have potential forks the user can select. UX should be key. It should be easy to find your thing." This rewrite collapses to a single canonical shape and folds the zoom / constellation / cross-cut concepts into it. The pre-grilling §13 + the 11-Q batch in §14 of the first draft are gone — superseded by m's 4 answers in §0.2 and the smaller open-question set in §10.
---
## §0 Premises
### §0.1 What shipped today and what m hit
`/tools/procedures` (U0-U4, knuth, m/paliad#151) is a **catalog browser**:
- 4 always-visible tabs (Verfahren wählen / Direkt suchen / Geführt / Aus Akte).
- Shared filter strip + search box at the top (markup-only in U0).
- Two output shapes — TREE (Verfahrensablauf) and LINEAR (Mode A/B result view) — bound to specific entry tabs.
m's bugs (2026-05-27 20:43 / 20:46):
1. 4 tabs visible → pre-form leaks across them, page feels like 4 disjoint workflows.
2. Result view fires too many rules incl. conditional-flag-off + curie's 7 compound rules.
3. Proaktiv/Gericht/Reaktiv columns are a stance grouping, not a sequence anchor.
4. No "you are here" marker.
5. Sequence isn't visualised as a sequence — flat priority groups, not chained.
m's reframe (verbatim, 20:43): "view proceedings with all possible constellations and the sequences and determine **where we are** in that sequence and **what steps are coming next** for any given procedural event."
Tightened by m on 21:01:
> "clean display of timelines that have potential forks the user can select. procedural_events that act as triggers for mandatory or optional events. And there is a limited type of proceedings — a sequence of the events builds the proceeding. Some aux proceedings, some main… but a lot is connected. UX should be key. It should be easy to find your thing."
### §0.2 The four m-answers that lock the architecture
Asked back during the grilling round at 20:57, answered 21:01:
| | inventor's grilling question | m's answer | what it locks |
|---|---|---|---|
| 1 | One canonical shape or still 3 views? | "I still want zoomability for one event and all events it triggers. But that can be from within the full timeline/tree as well." | **One canonical view** (full timeline/tree); zoom is an *interaction* on it, not a separate view. The Anchor / Verfahren / Konstellationen toggle is dropped. |
| 2 | What's a "fork" — scenario flags only / +optionals / everything? | "c" (everything: flags + optionals + appeal-target + court-set picks) | **Every choice point in the data is a fork.** Optionals (priority='optional') + conditional flags + appeal-target + perspective + court-set scheduling. Inline pickers. |
| 3 | "Easy to find" — timeline-as-index / search box / proceeding picker first? | "all of these — text search, filter pills, a display of the resulting proceedings timelines" | **Find = combined affordance.** Text search + filter pills + the displayed result *is* the matched proceeding timelines. The page never has chrome that isn't either the find affordance or the timelines themselves. |
| 4 | Aux proceedings inline or drillable? | "inline" | **Aux proceedings draw inline as expandable child timelines** hanging off the spawn point in the parent timeline. The full connected graph is one visible thing. |
### §0.3 Live data the tracker has to work against
Verified 2026-05-27 against `paliad.sequencing_rules` (231 published / 242 total):
- 110 chained (parent_id not null) — most rules in a chain.
- 78 trigger-rooted, 4 spawns (cross-PT), 47 court-set, 18 conditional (6 `with_ccr` / 4 `with_amend` / 4 `with_cci` / 4 compound `with_ccr AND with_amend`).
- Biggest single proceeding: `upc.inf.cfi` (50 rules).
- ~46 proceeding types total (UPC 35 / DE 5 / EPA 3 / DPMA 3).
- `paliad.deadlines` carries both `procedural_event_id` and `sequencing_rule_id` → Akte actuals overlay is a direct join.
### §0.4 Scope
**In:** redesign the `/tools/procedures` surface as a single timeline-tree view with inline forks + a combined find affordance.
**Out:**
- Calculator changes.
- Editorial backfill (curie's t-paliad-333 owns the 7 compound rules + R.109 chain). This design is *independent* of curie's column-shape work; compound rules surface inline via parent_id like any other rule, with whatever annotation curie ships.
- `/admin/procedural-events` write surface.
- `/projects/{id}` Verlauf / SmartTimeline — cross-link only.
- youpc.org cross-repo / Outlook sync / PDF export.
---
## §1 The single canonical shape
One page. One view. Top section = find affordance. Below = matched proceeding timelines, each as an inline-forked tree, vertically stacked.
```
┌────────────────────────────────────────────────────────────────────┐
│ [🔍 Suche: Klageerwiderung_____________________] │
│ Forum: [● UPC] [DE] [EPA] [DPMA] │
│ Verfahren: [● Verletzung] [● Widerklage] [Berufung] [Nichtigkeit] … │
│ Partei: [Klägerseite] [● Beklagtenseite] │
│ Akte: HL-2024-001 ▼ Stichtag: 2026-04-01 │
│ │
│ 2 Verfahren passen — Anker: Klageerwiderung (HL-2024-001) │
└────────────────────────────────────────────────────────────────────┘
┌─ upc.inf.cfi · Verletzungsverfahren UPC ─────────────────────────┐
│ │
│ ● Klageerhebung (R.13) 2026-01-15 · Klg · M │
│ │ │
│ ▼ ● Klageerwiderung (R.23.1) 2026-04-01 · Bekl · M │
│ │ ━━━━ DU BIST HIER ━━━━ │
│ │ Optionen für dieses Ereignis: │
│ │ ☑ Widerklage auf Nichtigkeit │
│ │ ☐ Antrag Patentänderung (R.30) │
│ │ ☐ Vorläufige Einwendungen │
│ │ │
│ ├─● Replik (R.29.a/b) 2026-06-01 · Klg · M │
│ │ ├─● Duplik (R.29.c) 2026-07-01 · Bekl · M │
│ │ └─● Replik auf Defence to CCR (R.29.d) 2026-08-01 · Klg · M │
│ │ └─● Rejoinder (R.29.e) 2026-09-01 · Bekl · M │
│ │ │
│ ├─● Widerklage auf Nichtigkeit ✓ │
│ │ └─▼ Tochterverfahren upc.rev.cfi ▾ │
│ │ │ │
│ │ ├─● Antrag Patentänderung (R.50) optional ☐ │
│ │ ├─● Hauptverhandlung [Gericht] │
│ │ └─● Entscheidung [Gericht] │
│ │ │
│ └─● Vorläufige Einwendungen ☐ (optional, ausgewählt: nein) │
│ │
│ ● Mündliche Verhandlung [Gericht bestimmt] │
│ │ │
│ └─● Urteil [Gericht] │
│ └─▼ Berufungsverfahren upc.apl ▸ (auf Endentscheidung) │
│ │
└────────────────────────────────────────────────────────────────────┘
┌─ upc.ccr.cfi · Widerklage auf Nichtigkeit (Tochter, oben verlinkt) ┐
│ … │
└────────────────────────────────────────────────────────────────────┘
```
No tabs. No view toggle. The output reacts to the find affordance, the anchor pin, and per-node fork selections.
### §1.1 The shape's components
1. **Find header** (sticky at top): search input + filter pills + Akte/date row + a one-line result summary. §2.
2. **Timeline-trees** (the page body): one block per matched proceeding, full chain + inline forks + inline aux branches. §3-§5.
3. **Anchor pin** (when set): the "DU BIST HIER" band on a specific node, optionally with zoom mode collapsing everything else. §6.
That's the entire UI surface. No drawers, no separate drillable panes, no constellation viewer. Forks are inline checkboxes; aux proceedings are inline expandable subtrees; zoom is an interaction on the existing rendering.
---
## §2 The find affordance
m's #3 answer makes this load-bearing: text + pills + result-timelines are all the same affordance. As the user narrows, the timelines below filter; as the timelines change, the result-count summary updates; clicking a node in a timeline auto-narrows the filter pills to that proceeding (optional sugar).
### §2.1 Composition
| Control | Source | Composes via | Persists in |
|---|---|---|---|
| Free-text search | input box, debounced 200ms | OR-against (procedural_event.name DE/EN, rule_code, aliases) | `?q=<text>` |
| Forum pill row | static enum (UPC/DE/EPA/DPMA), single-select | AND | `?forum=<id>` |
| Verfahren pill row | proceeding_type chips, multi-select (deduped from active forum) | AND (any-of) | `?procs=<csv>` |
| Partei pill row | claimant / defendant / both / — (or auto from Akte) | AND | `?party=<x>` |
| Akte picker | dropdown of user's projects | seeds Verfahren + Partei + scenario_flags + anchor | `?project=<uuid>` |
| Stichtag (date) | date input, defaults today | feeds computed dates throughout the timelines | `?trigger_date=<iso>` |
All controls live in one sticky header. The header keeps its height stable so the timelines below don't reflow on every keystroke.
### §2.2 Cold open behaviour
No URL params, no Akte:
- Search box empty, all forums neutral, all proceeding pills neutral. Show a curated default of the most-common proceedings: `upc.inf.cfi`, `upc.rev.cfi`, `upc.apl.merits`, `de.inf.lg`, `epa.opp.opd`, `dpma.opp.dpma`. (See Q4 below.)
- A hint above the timelines: "Suche oder filtere, um andere Verfahren einzublenden."
With a `?project=` param: filters pre-fill from the Akte, anchor pins to the latest completed deadline.
With a `?q=` or `?event=` param: filters pre-fill to match, single matched proceeding renders pinned.
### §2.3 What the search matches
Free-text search hits the same corpus the existing `/api/tools/fristenrechner/search?kind=events` endpoint covers — procedural_events by name + code + aliases. Spawn-only events stay filtered out (per atlas P0 §2.2). Hits surface in two ways simultaneously:
- The matched proceeding(s) render expanded with the hit event(s) anchor-pinned.
- A small "Treffer: 3 Ereignisse in 2 Verfahren" summary above the timelines.
If the user types something narrow enough to match a single event, the page auto-pins that event (auto-anchor). If multiple events match, the user picks via a small dropdown under the search input — picking sets the anchor.
### §2.4 Why pills, not chips-with-sub-modes
The shipped 4-tab UI tried to express "what kind of question are you asking" via tabs. m's answer #3 collapses that — the find affordance doesn't care which "kind" of question; it cares about the active filter set. A user with a search + a forum + an Akte set gets the right timelines regardless of which tab they "came from". The mental model is: narrow the set; the timelines arrive.
---
## §3 Timelines and forks
Each matched proceeding renders as one card. Inside the card: the proceeding's name + jurisdiction badge in a thin header strip, then the chain.
### §3.1 The chain
Vertical, top-to-bottom = chronological. Each node = one procedural_event (the rule that fires it lives inside). Edges = parent_id. Per node:
- **Bullet style** by priority: solid filled (mandatory), solid outline (recommended), dotted (optional), dashed (conditional-flag-off and hidden).
- **Bullet colour**: priority band — black/grey/blue/light depending on the scale we end up picking. Lime accent (`#c6f41c`) reserved for the anchor pin.
- **Inline metadata**: name, rule code, computed date, party badge, priority badge. Stripped to one line.
- **Court-set events**: render with `[Gericht bestimmt]` in date column.
- **Spawn nodes**: terminate the bullet with `▼ Tochterverfahren <code> ▾` — expandable inline; collapsed by default unless the spawn flag is on. §5.
### §3.2 Forks — every choice point is one
A "fork" is anywhere the user can flip the proceeding's shape:
1. **Scenario flags** (`with_ccr`, `with_amend`, `with_cci`) — currently 3, extensible via curie's `scenario_flag_catalog`.
2. **Optional rules** (`priority='optional'`) — each is a "do I do this?" pick.
3. **Appeal-target picks**`applies_to_target` array on appeal proceedings (endentscheidung / kostenentscheidung / anordnung / schadensbemessung / bucheinsicht). Per-card chip group at the appeal root.
4. **Perspective** — claimant / defendant per proceeding (mostly comes from Akte's `our_side`, picker overrides).
5. **Court-set timing choices**`choices_offered` JSON on `sequencing_rules` (`appellant` / `include_ccr` / `skip` shapes from einstein). Per-card chip set.
**Where forks render.** Inline, *on the node where the fork's effect begins.* Not in a top-of-page flag strip (m's bug #5 — sequences should be visualised as sequences; flags above the tree decouple cause from effect).
Concretely: the `with_ccr` fork renders as a checkbox **on the Klageerwiderung node**, because that's where the user decides "we are filing a Widerklage with our KEW". Toggling it lights up the CCR child branches below. Similarly:
- `with_amend` renders on the KEW node *and* on the Antrag-Patentänderung node it gates.
- `with_cci` renders on the Defence-to-Revocation node.
- Optional rules render as a checkbox on their own card.
- Appeal-target picks render on the appeal root.
If multiple forks share a node, they cluster as a small "Optionen für dieses Ereignis" mini-strip *below* the node header:
```
▼ ● Klageerwiderung (R.23.1) 2026-04-01 · Bekl · M
│ Optionen:
│ ☑ Widerklage auf Nichtigkeit
│ ☐ Antrag Patentänderung (R.30)
│ ☐ Vorläufige Einwendungen einlegen
```
### §3.3 Default rendering ("Gewählt" semantics)
Each node renders iff:
- It's mandatory (priority='mandatory'), OR
- It's selected per current scenario state (priority='recommended' unless explicitly deselected; priority='optional' iff explicitly selected; conditional iff flag is on).
- Same as atlas P3's "Gewählt" view-mode.
Conditional rules whose flag is off **do not render at all** by default. The fork checkbox to *turn the flag on* still appears on the gating node — turning it on causes the dependent branch to render.
This is m's bug #2 fix: no more dump of every-rule including flag-off conditional. The forks themselves are the affordance that brings hidden branches into view.
### §3.4 Optional reveal — "Alle Optionen"
A single toggle at the top of each proceeding card (NOT page-wide):
```
[· Gewählt ·] [ Alle Optionen ]
```
"Alle Optionen" renders every rule including flag-off conditionals (greyed with their flag hint) and unselected optionals (dotted with `[Aufnehmen]` chip). Useful when the user wants to see the whole shape. Per-proceeding so a page with 3 proceedings can have one in "Alle Optionen" mode without affecting the others. State persists in `localStorage` per proceeding code.
### §3.5 Why dropping "Nur Pflicht"
Atlas P3's three-way toggle had Nur Pflicht / Gewählt / Alle Optionen. With forks made inline + per-node, "Nur Pflicht" loses load-bearing — it was useful when the page had no fork interactivity (you wanted to dial down clutter). Now the user can just leave all forks off and see the mandatory-only chain in Gewählt mode. The two-way Gewählt ↔ Alle Optionen is enough.
### §3.6 Cross-party rows
Per atlas §2.4 / m's lock: every rule for the proceeding renders, with rows where the user is *not* the primary_party muted + carrying a "Gegenseitig" badge. Same treatment in this tracker. Not hidden by perspective; just visually deemphasised.
---
## §4 Court-set events & date rendering
`is_court_set=true` rules don't compute a date — the court picks it on the day. Two display options that interact:
- Render with `[Gericht bestimmt]` in the date column, no date. Standard.
- When the user has scheduled the actual date (an `appointments` row on the project or a manual override), the actual date replaces the badge. Akte-only.
If the date is "vom Gericht" and matters as a trigger for downstream events, downstream events render `[abhängig von Verhandlung]` instead of a date, and recompute live once the court date is known.
`choices_offered` per-rule (the 3 known shapes today: `appellant`, `include_ccr`, `skip`) — also inline per-node, treated as forks (§3.2 #5).
---
## §5 Aux proceedings inline
Per m's #4 answer: spawned proceedings draw inline as expandable subtrees, not as drillable separate pages.
### §5.1 Render
A spawn node renders as a leaf chip terminating the parent's chain segment:
```
●─● Widerklage auf Nichtigkeit ✓
└─▼ Tochterverfahren upc.rev.cfi ▾
├─● Antrag Patentänderung (R.50) optional ☐
├─● Hauptverhandlung [Gericht]
└─● Entscheidung [Gericht]
└─▼ Berufungsverfahren upc.apl ▸
```
- Collapsed by default unless the gating fork is on (e.g. `with_ccr` ticked → CCR's spawn into upc.rev.cfi auto-expands).
- Expanding writes nothing — pure UI state in `sessionStorage["procedures:expanded_spawns"]`.
- The aux subtree renders with the same node vocabulary as the parent; forks inside the aux are independently editable.
- Aux subtrees can themselves have aux subtrees (e.g. CCR → Berufung). Depth is bounded by the data — today 2 levels deep at most.
### §5.2 Cross-references
When two paths converge on the same aux proceeding (e.g. CCR triggers from a couple of places), the aux renders inline at the first path's spawn point and renders as a back-reference at subsequent spawn points: `▸ (siehe oben: Tochterverfahren upc.rev.cfi)`. Single source of truth in the rendered tree, even when the graph has multiple edges.
### §5.3 Akte mode
In Akte mode, if the spawn was actualised (a child project exists linked via `parent_project_id`), the aux subtree shows the child project's badge: `📁 HL-2024-001-CCR · Tochterakte`. Clicking the badge navigates to that child project. The subtree itself still renders inline.
---
## §6 Anchor pin & zoom
m's #1 answer: "zoomability for one event and all events it triggers, from within the full timeline".
### §6.1 The anchor pin
Any node can be pinned as the anchor. Pinning sources:
- URL `?event=<sequencing_rule_id>` (deep link).
- Search box auto-pin when the search narrows to a single hit.
- Click-to-pin on any node (small pin icon in the node's metadata row).
- Akte landing: auto-pin to latest completed deadline.
The pinned node renders with a 4px lime-coloured left band + a `━━ DU BIST HIER ━━` divider above its successors. The pin is also reflected in the find-header's result summary: "Anker: Klageerwiderung (HL-2024-001)".
### §6.2 Zoom mode
A small `[ 🔍 Fokus ]` chip on the anchored node toggles zoom mode for that anchor. When zoom is on:
- The anchored node's ancestors collapse to a single breadcrumb at the top of the proceeding card:
```
upc.inf.cfi ▸ Klageerhebung ▸ ━ Klageerwiderung ━
```
- The anchored node renders full.
- Successors render fully (the forward subtree under the anchor).
- Sibling branches at every ancestor depth collapse to a single-line summary card: `… 4 weitere Schritte verborgen — [zeigen]`.
This is what m means by "zoom into one event from within the timeline" — the *same view*, just with non-relevant siblings collapsed. Toggle off → full timeline restored, anchor still pinned.
Zoom is page-scoped (one anchor per page, one zoom state). State in URL: `?event=<id>&zoom=1`.
### §6.5 Multi-proceeding anchor scope (m's Q3 divergence)
When the page shows >1 matched proceeding *and* an anchor is pinned, the non-anchored proceedings auto-collapse to a header-only one-line card:
```
┌─ upc.inf.cfi · Verletzungsverfahren UPC ─────┐
│ … full timeline … │
│ ━━ DU BIST HIER: Klageerwiderung ━━ │
└──────────────────────────────────────────────┘
┌─ upc.rev.cfi ▸ ausblenden — [zeigen] ────────┐
└──────────────────────────────────────────────┘
┌─ upc.apl.merits ▸ — [zeigen] ────────────────┐
└──────────────────────────────────────────────┘
```
Clicking a header card's `[zeigen]` link restores that proceeding's full timeline (the header stays as a per-card affordance for re-collapse). The collapsed state persists in `sessionStorage["procedures:collapsed_proceedings"]`. Un-pinning the anchor restores all visible proceedings to full-render automatically.
The rule applies regardless of how the anchor was pinned (URL, search-auto, click-to-pin, Akte landing). The find-header result count still shows N proceedings matched — header cards are present, just collapsed.
### §6.3 The "where I came from" question
m's brief asked for backward-walk visualisation. Without zoom: the chain above the anchor is the backward walk — it's the same tree. With zoom: the breadcrumb at the top of the proceeding card is the backward walk in compact form. No separate concept; backward walk = upward in the tree.
### §6.4 Akte mode: actuals overlay
When `?project=<uuid>` is set, each node in the chain queries `paliad.deadlines WHERE project_id = $p AND sequencing_rule_id = $r` and overlays:
- `status='done'` → ✓ in the node bullet area + actual completed date in the date column. Greyed slightly to read as "past".
- `status='open'` and `due_date < today` → ⚠ overdue.
- `status='open'` and `due_date >= today` → 📅 actual due date if differs from computed; ◇ marker.
- No deadline row → render as template (current behaviour).
The anchor auto-pins to the latest `status='done'` deadline by default — the natural reading is "we just finished this".
---
## §7 What lives where: the find header vs the timelines
A short table to make the responsibility boundary explicit:
| Concern | Find header | Timeline body |
|---|---|---|
| Pick proceeding(s) | Filter pill row | (auto-rendered after) |
| Pick anchor | Search-narrow → auto-pin / URL `?event=` | Click pin icon on any node |
| Pick perspective | Pill (or auto from Akte) | (read-only — feeds rendering) |
| Pick scenario flags | (no) | Inline fork checkboxes on gating nodes |
| Pick optional rules | (no) | Inline fork checkboxes on each optional node |
| Pick appeal target | (no) | Inline chip group on appeal root |
| Pick date | Stichtag input | (read-only — feeds computed dates) |
| Toggle Alle Optionen / Gewählt | (no) | Per-proceeding 2-way toggle |
| Zoom on anchor | (no) | `[Fokus]` chip on anchored node |
| Akte select | Akte picker | (read-only — feeds actuals overlay) |
Find header = "narrow the set + global context". Timelines = "everything per-event". No drawers, no overlays.
---
## §8 Cold open + empty state
Cold open with no Akte, no URL params (Q4 below): show a curated default of 6 most-common proceedings (`upc.inf.cfi`, `upc.rev.cfi`, `upc.apl.merits`, `de.inf.lg`, `epa.opp.opd`, `dpma.opp.dpma`), each rendered with default Gewählt + no forks selected + no anchor. Hint text above: "Suche oder filtere, um andere Verfahren zu sehen."
Empty filter result (e.g. user types nonsense): zero timelines render, with a helper card: "Keine Treffer. Filter zurücksetzen ▸"
---
## §9 Migration (direct replace per m's Q7)
4 slices + 1 cleanup, all surface, no DB mig, no `?tracker=1` flag. Each slice ships visibly to users at `/tools/procedures`. T1 must be at least as functional as today's catalog browser — so the find header + multi-proceeding render + inline forks + aux inline all front-load there. T2-T4 layer the remaining behaviour.
All independent of curie's editorial work — compound rules render inline via parent_id like any other rule; if curie ships a `compound_predecessors uuid[]` column later, those rules can render at multiple positions (one inline per predecessor) without tracker code changes beyond the join.
| Slice | What ships | Notes |
|---|---|---|
| **T1 — Tracker shell replaces the catalog page** | `/tools/procedures` now renders: sticky find header (search + Forum/Verfahren/Partei pills + Akte picker + global Stichtag), N-proceeding render (one card per matched proceeding), inline forks (scenario flags + optionals visible as checkboxes on the gating node), aux proceedings inline-expandable at spawn points, cold-open with 6 curated defaults (Q4), default = Gewählt. The 4 entry-mode tabs are deleted in the same PR; URL params `?mode=proceeding\|search\|wizard\|akte` 301-redirect or drop. URL anchor `?event=<rule_id>` scroll-highlights the matching node (no zoom yet). | Replaces catalog UI; users see the new tracker immediately. |
| **T2 — Anchor pin + zoom + multi-proceeding scope** | Anchor pin (lime band + DU BIST HIER divider), `[Fokus]` chip on anchored node toggles zoom (§6.2), URL state `?event=…&zoom=1`. Multi-proceeding auto-collapse rule (§6.5) kicks in when an anchor is set. Click-to-pin on any node. | Layered on T1's existing render. |
| **T3 — Akte landing + actuals overlay** | `?project=<uuid>` derives anchor from latest `status='done'` deadline (Q5), backward walk overlays `paliad.deadlines` actuals as status badges (§6.4), scenario_flags load from project, fork write-back via existing `PATCH /api/projects/{id}/scenario-flags` + `POST /api/projects/{id}/deadlines/bulk`. | The first slice that exercises the project hookup end-to-end. |
| **T4 — Appeal-target + court-set choices + polish** | Wire `applies_to_target` array forks on appeal proceedings, `choices_offered` shapes (`appellant`, `include_ccr`, `skip`), court-set date override from `appointments` table, cross-party muted treatment per §3.6. Per-proceeding "Alle Optionen" toggle (§3.4). | Polish + the edge-case fork shapes. |
| **T5 — Cleanup** | Dead-code removal: legacy `procedures.ts` tab toggling, `fristenrechner-mode-a.ts` / `fristenrechner-wizard.ts` / `fristenrechner-result.ts` / `verfahrensablauf.ts` if no longer referenced (verify with grep before deletion). Sidebar/cmd-K unchanged (URL same). | No user-visible change. |
### §9.1 Constraint: T1 is the new floor
Because there's no flag, **T1 must not regress** from today's catalog UI in any non-trivial way. The catalog today serves four user workflows:
1. **Pick a proceeding, see its full Verfahrensablauf** → T1 covers via "Verfahren" pill click → that proceeding renders alone.
2. **Search for an event** → T1 covers via search input + auto-pin.
3. **Wizard from R1-R5** → T1 covers via Forum/Verfahren/Partei pills + search (the wizard's narrowing is just a sequence of filter applications).
4. **Enter via Akte** → T1 covers via the Akte picker; full actuals overlay arrives in T3 (open/done badges may render partial in T1, but the Akte's scenario_flags + proceeding pre-load works).
If T1 reviewing exposes a regression, T1 holds (the issue blocks merge) — m's PR review gates the slice landing.
### §9.2 What stays unchanged
- URL: `/tools/procedures` keeps it.
- Sidebar entry "Verfahren & Fristen" keeps it.
- cmd-K palette keeps it.
- All other tools, calendar, projects, deadlines surfaces — untouched.
- Calculator (`pkg/litigationplanner.CalculateRule`) — untouched.
### §9.3 Out-of-band dependencies
- The compound-predecessors editorial column is owned by curie's t-paliad-333. Tracker reads whatever lands. If it slips past T4, compound rules render via their primary parent_id only (today's shape) — degraded but still correct on that path. No tracker re-render needed when curie ships.
- The Akte actuals overlay (T3) reads `paliad.deadlines.sequencing_rule_id` — column exists, nothing new.
### §9.4 Test surface per slice
- **T1**: cold-open 6 curated defaults render; search narrows to single proceeding; pill toggles change render; `?project=` loads Akte filters (no actuals yet); URL deep-link `?event=` highlights matching node; legacy `?mode=` redirects.
- **T2**: click-to-pin sets anchor with lime band; `[Fokus]` zoom collapses siblings; un-zoom restores; multi-proceeding auto-collapse when anchor active; URL state survives reload.
- **T3**: Akte landing auto-pins latest done deadline; status badges render on each node from `paliad.deadlines`; fork tick writes to `scenario_flags`; "In Akte speichern" persists.
- **T4**: appeal-target chips switch the rule set rendered on appeal proceedings; `choices_offered` per-node chip groups visible + functional; "Alle Optionen" reveals hidden conditional rules with greyed state.
- **T5**: production deploy unchanged surface; no live regression; deleted files don't break build.
---
## §10 Open questions for m
Seven questions in 2 batches (4 + 3) for `AskUserQuestion`. Tier 1 = how the per-node fork UI feels + how zoom interacts with multi-proceeding pages. Tier 2 = cold-open content + Akte default + Stichtag scope + migration cadence.
m's picks fold back into §11 below before the "TRACKER DESIGN READY FOR REVIEW" signal.
### Batch 1 — fork UI + zoom + cross-party
- **Q1 (Fork-cluster shape on a node)** — when a node has 2-4 forks (e.g. Klageerwiderung: `with_ccr` + `with_amend` + Vorl. Einwend.) — (a) inline checkbox list below the node header (current sketch), (b) collapsed "Optionen (3) ▾" affordance that expands on hover/click, (c) chip strip on the same line as the node header.
- **Q2 (Zoom interaction)** — `[Fokus]` chip on the anchored node — (a) collapses siblings to one-line summaries (current sketch), (b) outright hides siblings, breadcrumb-only, (c) split-view (zoomed pane below original full tree).
- **Q3 (Anchor scope on a multi-proceeding page)** — when 3 timelines are visible and the user pins an anchor in one — (a) the other 2 timelines stay expanded normally (no zoom effect on them), (b) the other 2 timelines auto-collapse to header-only ("upc.rev.cfi ▸ ausblenden — [zeigen]"), (c) the other 2 timelines reorder to bottom of page (anchored proceeding floats to top).
- **Q4 (Cold-open default content)** — opening `/tools/procedures` with no URL params and no Akte — (a) the 6-curated-default-proceedings sketch (Verletzung UPC + DE LG, Nichtigkeit UPC, Berufung UPC, EPA-Einspruch, DPMA-Einspruch), (b) all ~46 proceedings rendered with all forks off (lots of scrolling), (c) empty state with a "Filter wählen, um Verfahren einzublenden" prompt.
### Batch 2 — Akte semantics + Stichtag + migration
- **Q5 (Akte landing — default anchor)** — `?project=<uuid>` — (a) auto-pin to latest `status='done'` deadline (current sketch), (b) auto-pin to next-open deadline (forward-looking), (c) no auto-pin, just pre-fill filters + actuals overlay, user picks anchor.
- **Q6 (Stichtag scope)** — date input in the find header — (a) global, feeds all visible proceedings' computed dates (current sketch — useful for browsing "if today were the trigger"), (b) per-proceeding (each timeline carries its own date input), (c) only valid in single-proceeding mode (hidden when the page shows >1 proceeding).
- **Q7 (Migration cadence)** — (a) flag-gated dev under `?tracker=1`, T1-T4 ship, T5 hard-cut (current sketch, cronus precedent), (b) direct replace at T1 (no flag — every slice ships visibly to users), (c) parallel URL `/tools/procedures-v2` until hard-cut.
---
## §11 m's decisions (2026-05-27)
All 7 questions answered via `AskUserQuestion` in 2 batches (4 + 3) at 21:0?. 5 picks on-recommendation, 2 diverged. Decisions below; the underlying question list lives in §10 above as the historical record.
### Tier 1 — fork UI + zoom + cross-party
- **Q1 (Fork cluster on a node): Inline checkbox list below node header.** [= REC] **Locks §3.2.** Every fork on a given node renders as a checkbox in an "Optionen:" cluster line below the node header. Always visible, no hover, no extra click. Vertical real estate per node is acceptable because the default `Gewählt` mode keeps the tree compact (most events have zero forks).
- **Q2 (Zoom interaction): Collapse siblings to one-line summaries.** [= REC] **Locks §6.2.** `[Fokus]` chip on the anchored node folds sibling branches at each ancestor depth to a `… 4 weitere Schritte verborgen — [zeigen]` line. The anchored node's subtree renders full. Breadcrumb at the top of the proceeding card. Toggle off restores everything.
- **Q3 (Multi-proceeding anchor scope): Other timelines auto-collapse to header-only.** [≠ REC; m diverged from "stay expanded"] **Locks new §6.5.** When an anchor is pinned on a multi-proceeding page, the non-anchored proceedings fold to a one-line header card (`upc.rev.cfi ▸ ausblenden — [zeigen]`). Clicking the header line restores that proceeding's full timeline. Rationale (interpreted): with an anchor pinned, the page is *about* that anchor — having other proceedings full-render in parallel competes for attention without earning it. The header card preserves the find-header result count and offers a one-click escape if the user wants to compare.
- **Q4 (Cold open content): 6 curated default proceedings.** [= REC] **Locks §8.** No URL params + no Akte → render `upc.inf.cfi`, `upc.rev.cfi`, `upc.apl.merits`, `de.inf.lg`, `epa.opp.opd`, `dpma.opp.dpma` stacked vertically, all forks off, no anchor. Hint: "Suche oder filtere, um andere Verfahren zu sehen."
### Tier 2 — Akte + Stichtag + migration
- **Q5 (Akte default anchor): Latest `status='done'` deadline.** [= REC] **Locks §6.4 + §9.** `?project=<uuid>` → derive anchor by `SELECT … FROM paliad.deadlines WHERE project_id=$p AND sequencing_rule_id IS NOT NULL ORDER BY completed_at DESC NULLS LAST LIMIT 1`. Fallback: next open deadline → proceeding root. The backward chain reads as Akte history; the anchor itself is the most recently completed work; forward is upcoming.
- **Q6 (Stichtag scope): Global, feeds all visible proceedings.** [= REC] **Locks §2.1 + §7.** One date input in the find header. All visible proceedings compute dates against it. When the user has an Akte loaded, the Stichtag pre-fills from the project's latest trigger date but is overrideable. When the anchor is pinned to a `status='done'` deadline, the date input shows that deadline's `completed_at` but can still be overridden for "what-if" exploration.
- **Q7 (Migration cadence): Direct replace at T1 — no flag.** [≠ REC; m diverged from flag-gated dev] **Rewrites §9.** Every slice ships visibly to users at /tools/procedures. T1 must be at minimum equivalent to today's catalog browser (so the slicing has to front-load find header + multi-proceeding render + forks inline + aux inline). The flag-gated dev plan is dropped. cronus's Q11 hard-cut precedent extends here: m would rather ship per-slice visibly than carry a parallel surface during dev. Rationale (interpreted): partial-tracker > no-tracker, and ~50 internal lawyers absorb the per-slice deltas through release comms.
### §11.1 Changes triggered by m's divergences
**Q3 divergence — multi-proceeding anchor scope.** New §6.5 added below. The header-card-only render for non-anchored proceedings preserves filter compose (you can still see "upc.rev.cfi matched the filter") while clearing the page's vertical real estate for the anchor's full context.
**Q7 divergence — direct replace.** §9 rewritten end-to-end. T1 now ships the minimum-viable tracker (find header + multi-proceeding render + forks inline + aux subtrees inline + URL-anchor highlight), replacing the catalog UI at /tools/procedures from the moment it merges. T2-T4 layer zoom, Akte semantics, polish. T5 ("cleanup only") is now just dead-code removal.
### §11.2 What stays unchanged
The other 5 picks (Q1, Q2, Q4, Q5, Q6) ratified the inventor proposal. Inline checkbox forks per node, breadcrumb-collapse zoom, 6-curated cold open, latest-done-deadline Akte anchor, global Stichtag — all locked as drafted in §1-§8.
---
## §12 Out of scope
- Calculator changes.
- Editorial backfill (curie's t-paliad-333). Compound rules render inline as parent_id-chained rules with curie's annotation; no special tracker treatment.
- `/admin/procedural-events`, `/projects/{id}` Verlauf / SmartTimeline.
- youpc.org / Outlook / PDF export.
- Multi-project anchor comparison.
- Free-text scenario flag i18n.
---
## §13 Synthesis links
- **mBrian** (after m's ratification): file as `[synthesis]` linked `triggered_by` t-paliad-337; `related_to` cronus's unified-procedural-events-tool design + atlas's deadline-system-revision + cronus's earlier Fristenrechner overhaul.
- **Cross-refs in this repo**: `docs/design-unified-procedural-events-tool-2026-05-27.md` (cronus, U0-U4 shipped today), `docs/design-deadline-system-revision-2026-05-27.md` (atlas Phase 2), `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26).
- **Gitea**: m/paliad#152 (this design), m/paliad#151 (cronus U0-U4), m/paliad#149 (atlas Phase 2).
- **Coder phase** (deferred per inventor SKILL): runs after m ratifies §10 + §11. Slice ordering per §9. NOT atlas (parked at "TRACKER DESIGN READY FOR REVIEW"). Pattern-fluent Sonnet coder picks up T1 first.

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).

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,568 @@
# Design — Unified procedural-events tool (m/paliad#151)
**Task:** t-paliad-334
**Gitea:** m/paliad#151
**Inventor:** cronus (shift-1, fresh context — name-recycled, not the cronus from earlier today)
**Date:** 2026-05-27
**Branch:** `mai/cronus/inventor-unified`
**Status:** Draft — coder gate held; awaiting m's go on the unification approach
**Builds on:**
- `docs/assessment-deadline-system-2026-05-27.md` (athena, Phase 1 audit — premises)
- `docs/design-deadline-system-revision-2026-05-27.md` (atlas, model + per-surface revisions — pre-locked decisions)
- `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26 inventor, Mode A + B + result shipped via t-paliad-322 / m/paliad#146 S1-S6)
- `docs/design-event-card-choices-2026-05-25.md`, `docs/design-determinator-row-cascade-2026-05-13.md` (per-card choice + determinator routing — current Verfahrensablauf state)
m's framing (2026-05-27 19:13):
> There are many dimensions by which we can display and filter our procedural events. Maybe we should hire an inventor to find out the best methods from the ones we already have? It makes sense to narrow things, display them in sequence and context, make selections etc. It just needs to be done well, preferably in a unitary tool. There should be alternative means to derive at what you want to derive at.
---
## §0 Premises — what the inventor is and isn't doing
This is a **surface-layer** design. The **model layer** is locked by atlas's `design-deadline-system-revision-2026-05-27.md` (Q1-Q12 + 14:34/14:40 post-ratification additions, all m-decided 2026-05-27). The shipped Fristenrechner Mode A + Mode B + result view is the model-side foundation; the in-flight atlas P0-P5 train extends Verfahrensablauf and the scenario SSoT.
The inventor's question is **not** "how should the rule graph be modelled" — that's settled. It's: **of the 6 surfaces that read this model, do we have the right *surfaces*? Should they unify into one tool, two, or stay as today's set?**
Out of scope per the issue + paliadin/head brief:
- Calculator (`pkg/litigationplanner.CalculateRule`) — working.
- `/admin/procedural-events` as an editorial **write** surface — different audience, different action set, must stay separate.
- `/projects/{id}` Verlauf — per-Akte **actuals** surface, not the ablauf-tool. Sister tool, not subsumable.
- SmartTimeline projection — per-project read view that composes actuals + projections; sister to Verlauf, project-bound. Not subsumable.
- youpc.org/deadlines — cross-repo public surface. Snapshot consumer.
- Outlook / Calendar sync UI.
In-scope unification candidates (4 surfaces): the three Fristenrechner modes (A search + B wizard + result) **and** Verfahrensablauf — these read the *same model* (sequencing_rules + procedural_events + scenario_flags) to answer questions about the *same underlying graph*. The question is whether they're best presented as one URL with multi-mode entry, two URLs with shared vocabulary, or as today's split.
---
## §1 Audit of the 6 surfaces
For each surface: question it answers, dimensions it filters/anchors on, what it does well, what it does poorly, overlap with neighbours.
### §1.1 `/tools/fristenrechner` Mode A — "Direkt suchen" (shipped t-paliad-322 S3)
**Question shape:** "I know a procedural event happened (e.g. *Klageerwiderung*). What follow-ups come next?"
**Dimensions used:**
- *Filters* (top strip): `forum` (UPC/DE/EPA/DPMA), `proceeding_type`, `event_kind` (filing/hearing/decision/order), `primary_party`.
- *Anchor* (the search result): one `procedural_event` row → lock as trigger.
- *Inbox* secondary chip (CMS / beA / postal): auto-derives forum.
**Path:** Filter strip → free-text search → result row click → linear follow-up view (handed off to §1.3).
**Strengths:** Power-user surface; one box does everything; forgiving to misspellings via pg_trgm; deep-linkable via `?mode=search&q=…&forum=…`.
**Weaknesses:** Search returns *every* event including spawn-only and leaves (atlas §2.2 P0 fix in flight); no visualisation of *where* the picked event sits in the proceeding tree.
**Overlap:** Picks the same `procedural_event` rows that Mode B R4 lands on; picks the same proceeding chips that Verfahrensablauf shows. Filter strip is a subset of Verfahrensablauf's filter chips.
### §1.2 `/tools/fristenrechner` Mode B — "Geführt" wizard (shipped t-paliad-322 S4)
**Question shape:** Same as Mode A but for users who don't know how to phrase the question. Narrows by Q&A.
**Dimensions used:** All five Mode A filters reframed as wizard rows:
- R1 `event_kind` (Filter badge)
- R2 `forum` / jurisdiction (Filter, skipped if R1 narrows)
- R3 `proceeding_type` (Qualifier, auto-skipped on single match)
- R4 `procedural_event` (Qualifier — the landing question)
- R5 `primary_party` (Qualifier, only when follow-ups differ by side)
**Path:** Q-by-Q chip pick → R4 lock → linear follow-up view (handed off to §1.3).
**Strengths:** Onboarding-friendly; auto-prefills from Akte (`projects.proceeding_type_id` → R3, `projects.our_side` → R5); preserves compatible downstream picks on back-nav.
**Weaknesses:** No tree-context view of the answer; the user lands on a flat result with no zoom-out.
**Overlap:** Same R4 event set as Mode A's search results. Same downstream result view.
### §1.3 `/tools/fristenrechner` result view (shipped t-paliad-322 S2)
**Question shape:** Given a locked event + trigger date, what dated follow-ups exist?
**Dimensions used:**
- *Anchor:* one `sequencing_rule` (the trigger's anchor rule).
- *Linear walk:* one hop down via `parent_id` — children of the anchor, grouped by priority.
- *Display axes:* priority (4 groups: mandatory / recommended / optional / conditional), party, condition flag, court-set, spawn.
- *Persistent state:* per-rule checkboxes (selection for write-back), per-rule date overrides.
- *Write-back:* `POST /api/projects/{id}/deadlines/bulk` with audit_reason.
**Strengths:** Clear list + write-back footer; sticky trigger card; deep-linkable; cross-party detection in atlas P0 (S1 from t-paliad-327).
**Weaknesses:** Only shows *direct* children of the anchor. No visibility of where this slice fits in the proceeding's wider graph. No way to pivot to "show the whole ablauf around this".
**Overlap:** Selection state UI vocabulary (per-rule checkbox + chip) is conceptually identical to Verfahrensablauf's per-rule selection chips that atlas's P3 will ship.
### §1.4 `/tools/verfahrensablauf` (current state + atlas P3 in flight)
**Question shape (today):** "What does proceeding-type X look like in full?"
**Dimensions used:**
- *Anchor:* one `proceeding_type` (chip-picked).
- *Filters:* `side` (claimant/defendant), `target` (appeal-target — endentscheidung / kostenentscheidung / anordnung / schadensbemessung / bucheinsicht), `trigger_date`.
- *Scenario flags:* CCR / inf_amend / rev_amend / rev_cci, plus per-card choices (appellant / include_ccr / skip).
- *View toggle:* `columns` (3-column swimlane: Unsere Seite | Gericht | Gegnerseite) vs `timeline` (single-column chronological).
- *Detail-mode toggle (shipped today via m/paliad#149 P3):* `mandatory_only` / `selected` / `all_options`.
- *Per-card affordances:* `[Aufnehmen]` / `[Entfernen]` chips for optional/recommended rules, dotted-border for unselected, greyed for conditional-with-flag-off.
**Strengths:** The most data-rich surface — every rule for the proceeding rendered with computed dates against `trigger_date`. View-mode toggle gives detail-level control. URL params are clean (proceeding/side/target/trigger_date); noisy scenario flags live in localStorage (per `verfahrensablauf-state.ts`).
**Weaknesses:** The user must already know which proceeding to look at — no entry path from "an event happened" or "search by name". 3-column swimlane reads dense on desktop and unmanageably wide on mobile. Trigger-date is per-page (not per-rule), so the entire ablauf computes from one anchor — fine for kontextfrei browse, awkward for Akte where different rules have different real triggers.
**Overlap:** Detail-mode + per-rule selection chips share the design vocabulary that result view §1.3 *should* eventually adopt. Filter dimensions are a superset of Mode A's filter strip.
### §1.5 `/admin/procedural-events` (shipped, Slice B.5)
**Question shape:** "I need to edit / publish / audit rules."
**Dimensions used:** Lifecycle filter (draft/published/archived), proceeding chip, trigger-event filter, free-text. Per-row click → editor form. Separate tab for orphans (Slice 10 fuzzy-match staging).
**Strengths:** Lifecycle-aware; clone-publish workflow; audit log; orphan resolution.
**Weaknesses:** None for editors. *For readers,* it's the wrong tool — too much editor-state metadata in the table; no tree / sequence / dates / scenario filtering.
**Overlap:** None functional. Shares the rule corpus but its *action set* (edit/publish/audit/resolve-orphan) is disjoint from the reader surfaces.
**Verdict: keep separate.** Different audience (editors only — m today, the partner team eventually), different action set, different lifecycle vocabulary. Cross-linking is sufficient: every reader-surface row should have a "Diese Regel bearbeiten" link to `/admin/procedural-events/{id}/edit` for editor users.
### §1.6 `/projects/{id}` Verlauf — out of scope per brief
Project-bound timeline of *actual* deadlines + appointments + project_events for one Akte. Composes with SmartTimeline projections.
**Question shape:** "What's happened on my Akte and what's next *for this specific case*?"
This is conceptually downstream of the ablauf-tool: the ablauf-tool answers "what's the *shape* of proceeding X"; Verlauf answers "what's the *state* of *my Akte* that happens to be proceeding X". The shape becomes the actuals through user actions (write-back from Mode A result view, manual entry, CMS sync).
**Verdict: keep separate.** Different question, different data shape (instances vs templates).
### §1.7 SmartTimeline / `ProjectionService` — out of scope per brief
Per-project read view via `GET /api/projects/{id}/timeline` that returns merged actuals + projected future rows (via FristenrechnerService) + parent-node lane aggregation. The render shape is project-bound and lookahead-capped; the model knows about levels (Case / Patent / Litigation / Client) and bubble-up events.
**Verdict: keep separate.** SmartTimeline composes the ablauf-tool's output with project actuals; it's a consumer, not a peer.
### §1.8 `youpc.org/deadlines` — out of scope (cross-repo)
Public surface backed by the offline UPC snapshot (`cmd/gen-upc-snapshot`). Snapshot consumer only.
**Verdict: keep separate.** Different repo, different deploy.
---
## §2 The question→surface→dimension matrix
The single source of truth for "which dimension lives where". Two questions answer "which view does this surface show":
| User question | Today's surface | Anchor input | Output shape | Output detail |
|---|---|---|---|---|
| "What's the typical ablauf of upc.inf.cfi?" | Verfahrensablauf | `proceeding_type` | Tree-or-columns of all rules | Whole ablauf |
| "Was passiert nach Klageerhebung?" | Fristenrechner Mode A | `procedural_event` | Linear follow-ups (priority groups) | Slice through tree |
| "Was passiert nach… (don't know the event name)?" | Fristenrechner Mode B | Q&A → `procedural_event` | Same as Mode A | Same |
| "Welche Fristen für meine Akte ergeben sich?" | Fristenrechner Mode A/B + `?project=` | Akte + `procedural_event` | Linear follow-ups + write-back | Same + actions |
| "Wie sieht der gesamte Ablauf für meine Akte aus?" | Verfahrensablauf + `?project=` | Akte (derives `proceeding_type`) | Tree-or-columns + scenario | Whole ablauf + state |
| "Welche Regeln gibt's? Wie bearbeite ich sie?" | /admin/procedural-events | — | Editor table | Editor metadata |
| "Was steht auf meinem Akten-Plan?" | /projects/{id} Verlauf | Akte | Actuals timeline | Per-instance state |
Dimensions matrix — same dimension axis, varied surface presentation:
| Dimension | Cardinality | Mode A | Mode B | Result | Verfahrensablauf | Admin |
|---|--:|---|---|---|---|---|
| `forum` (jurisdiction) | 4 | top-chip filter | R2 | trigger-card badge | — (anchored by PT) | search facet |
| `proceeding_type` | 23 | top-chip filter | R3 (auto-skip on single) | trigger-card chip | chip strip (the anchor) | dropdown filter |
| `event_kind` | 5 | top-chip filter | R1 | trigger-card badge | — (in cards) | search facet |
| `primary_party` | 5 | top-chip filter | R5 (when needed) | per-rule chip | swimlane column / per-card | — |
| `priority` | 4 | — | — | group header | view-mode toggle + card style | column |
| `condition_expr` (gating) | bool | — | — | conditional group | greyed cards + flag strip | rule editor field |
| `is_spawn` | bool | hidden (atlas filter) | hidden | "⇲ Verfahren öffnen" CTA | leaf with ⇲ icon | column |
| `is_court_set` | bool | — | — | "vom Gericht" badge | greyed-date card | column |
| `parent_id` (chain depth) | derived | "Folgen: N" count | — | depth-1 only (children of anchor) | depth-N indentation / tree walk | "abhängig von" chip |
| selection state (scenario_flags `rule:<uuid>`) | per-rule | — | — | checkbox (write-back) | `[Aufnehmen]`/`[Entfernen]` chips | — |
| scenario flags (named: with_ccr, with_amend, …) | 3 | — | — | bound checkboxes (read-only) | flag strip (canonical edit surface) | rule editor field |
| view-mode (detail level) | 3 | — | — | — (always "selected") | top toggle | — |
| `trigger_date` | date | result view input | result view input | top of card | per-page input | — |
**Reading the matrix.** Every dimension lives at least two surfaces over. The user's mental model has to translate "the proceeding chip on Verfahrensablauf" to "R3 in Mode B" to "the proceeding filter strip in Mode A" — three names, same dimension. Same for forum, event_kind, party.
This is the friction m's framing pointed at: **the dimensions are shared, but the surface vocabulary is not.**
---
## §3 Consolidation proposal
### §3.1 The honest answer first
Of the 6 surfaces:
- **2 stay separate, correctly** — `/admin/procedural-events` (editorial audience) and `/projects/{id}` Verlauf + SmartTimeline (per-Akte actuals). They serve different question shapes and audiences. Cross-link liberally; do not merge.
- **4 are candidates for unification** — Fristenrechner Mode A + Mode B + result + Verfahrensablauf. Same underlying data, same dimensions, two zoom levels on one graph. Today they sit at two URLs (`/tools/fristenrechner` + `/tools/verfahrensablauf`) with separate filter vocabularies.
### §3.2 The unified surface: `/tools/procedures`
**Proposal:** consolidate the 4 reader surfaces into one page at `/tools/procedures` (the more general name; both "Fristenrechner" and "Verfahrensablauf" are sub-modes inside).
```
┌─────────────────────────────────────────────────────────────────────────┐
│ /tools/procedures │
│ ┌─ Akte / kontextfrei ─┐ ┌─ Filterleiste ────────────────────────────┐│
│ │ HL-2024-001 ▼ ohne │ │ Forum • Verfahren • event_kind • Partei ││
│ └──────────────────────┘ └───────────────────────────────────────────┘│
│ ┌─ Wie willst du einsteigen? ──────────────────────────────────────────┐│
│ │ (•) Verfahren wählen ( ) Direkt suchen ( ) Geführt ( ) Aus Akte ││
│ └─────────────────────────────────────────────────────────────────────┘│
│ ┌─ Ausgabe ── (Anzeige: Gewählt) ──────────────────────────────────────┐│
│ │ Either: TREE (proceeding-anchored) ││
│ │ │ 📥 Klageerhebung [claimant · M] ││
│ │ │ ├─ Klageerwiderung [defendant · M] ││
│ │ │ │ └─ Replik [claimant · M · ?with_ccr] ││
│ │ │ ├─ Widerklage [defendant · O · ?with_ccr] ││
│ │ │ └─ ⇲ Berufungsverfahren öffnen [SPAWN] ││
│ │ ││
│ │ Or: LINEAR (event-anchored, after locking) ││
│ │ │ 🎯 Klageerwiderung (defendant, 2026-04-01) ││
│ │ │ ───────────────────────────────────────────── ││
│ │ │ Pflicht: Replik (1 Monat) ☑ ││
│ │ │ Empfohlen: Vorl. Einwendungen ☑ ││
│ │ │ Optional: … ││
│ │ │ Bedingt: … ││
│ │ │ [In Akte speichern] ││
│ │ ││
│ │ Pivot: every card has "Im Ablauf zeigen" ↔ "Folge-Fristen anzeigen" ││
│ └─────────────────────────────────────────────────────────────────────┘│
└─────────────────────────────────────────────────────────────────────────┘
```
The page carries **one URL**, **one filter strip**, **one Akte picker**, **one selection-state store** (scenario_flags), **one view-mode toggle**, and **two output shapes** the user can toggle between:
1. **Tree output** (proceeding-anchored): the current Verfahrensablauf rendering — every rule of a proceeding, depth-indented via `parent_id`, with per-rule chips for selection and the three view-modes (Nur Pflicht / Gewählt / Alle Optionen).
2. **Linear output** (event-anchored): the current Mode A/B result view — sticky trigger card + 4 priority groups of follow-ups + write-back footer.
The **entry mode** selects *which output you land on*:
- "Verfahren wählen" + chip → tree of that proceeding.
- "Direkt suchen" + search → linear follow-ups of the picked event.
- "Geführt" wizard → linear follow-ups of the wizarded event.
- "Aus Akte" → tree of the Akte's proceeding, with scenario_flags pre-loaded.
The two outputs **share** the filter strip, the Akte context, the scenario state, the per-card UI vocabulary. Cross-pivoting is one click: from any rule card in the tree, "Folge-Fristen anzeigen" pivots to linear-from-that-anchor; from the linear view, "Im Ablauf zeigen" pivots back to the tree with the anchor highlighted.
### §3.3 Alternative — keep the URLs split, tighten alignment
The *minimum* unification, if m balks at folding two pages into one: keep `/tools/fristenrechner` and `/tools/verfahrensablauf` as distinct URLs but:
- Standardise the filter strip vocabulary (same chip names, same order, same colour coding).
- Share the entry-mode dropdown / tab UI components.
- Mutual deep-links: every result-view row has "Im Ablauf zeigen" → Verfahrensablauf URL with anchor; every Verfahrensablauf tree node has "Folge-Fristen" → Fristenrechner URL with event locked.
- Selection state already shared via `projects.scenario_flags` from atlas P0.
This is the conservative path. It preserves URL stability but accepts that "which tool for which question" remains a learned concept rather than a single-doorway tool.
### §3.4 Inventor's recommendation
**Unify (§3.2)** — m's framing ("preferably in a unitary tool") + the dimension matrix showing 6+ shared filters argue strongly. The cost of two URLs is two filter vocabularies, two mental models, two cmd-K targets. Folding them is a few weeks of frontend work after atlas's P3 lands; the data layer is already ready.
The risk is *not* the merge — it's the rename. `/tools/fristenrechner` is the name lawyers know. Naming choices in §11.Q2 below.
---
## §4 Multi-dimensional filter spec
Where each dimension lives in the unified surface. Categories: **anchor** (the thing the output is rooted on), **filter** (narrows what's rendered), **qualifier** (refines the anchor), **display** (per-card affordance), **state** (persists across surface).
| Dimension | Category | Where (entry mode) | Where (tree output) | Where (linear output) |
|---|---|---|---|---|
| `forum` | filter | top strip chip | top strip chip (narrows PT chips) | top strip chip + trigger-card badge |
| `proceeding_type` | anchor (tree) / filter (linear) | "Verfahren wählen" chip-grid; "Direkt"/"Geführt" filter strip | The anchor — header above tree | trigger-card chip |
| `event_kind` | filter | "Geführt" R1; Mode A filter chip | per-card icon | per-rule row icon |
| `primary_party` | filter | "Geführt" R5; Mode A filter chip; Akte (`our_side`) | swimlane column OR per-card chip (view-mode-dependent) | per-rule chip + Gegenseitig badge |
| `priority` | display | — | view-mode toggle + per-card style | group header (4 groups) |
| `condition_expr` (gating) | state | — | greyed + flag-strip activation | conditional group + read-only checkbox |
| `is_spawn` | display | filtered out of pickers (atlas §2.2) | leaf with ⇲ icon | "⇲ Verfahren öffnen" CTA, no date |
| `is_court_set` | display | — | greyed-date card with "vom Gericht" badge | "vom Gericht" badge, no date |
| `parent_id` (chain depth) | display | — | tree indentation | hidden (linear shows depth-1 only) |
| selection state `rule:<uuid>` | state | — | `[Aufnehmen]`/`[Entfernen]` chips | checkbox (write-back) |
| named scenario flags (`with_ccr`, …) | state | — | flag strip above tree | read-only mirror in conditional group |
| view-mode (detail level) | display | — | three-way segmented top toggle | — (always Gewählt) |
| `trigger_date` | anchor (linear) / display (tree) | linear: result view date input | tree: optional per-page input, defaults today | linear: top-card date input (canonical) |
| `is_cross_party` (derived) | display | — | muted style + Gegenseitig badge | muted style + Gegenseitig badge |
**Design principle:** dimensions stay in the **same chip / control**, regardless of which output is showing. The user learns the filter strip once. The output reacts.
---
## §5 Alternative paths spec — four ways to derive at the same outcome
m's "alternative means to derive at what you want" rendered explicitly. All four paths converge on the same underlying rule-set view; only the *entry experience* differs.
```
Path 1: PROCEEDING-FIRST (German-lawyer approach)
"Ich öffne ein UPC-Verletzungsverfahren — wie sieht das aus?"
1. Page open → "Verfahren wählen" tab (default if no Akte)
2. Chip-grid: pick `upc.inf.cfi`
3. Tree renders. User sees full ablauf.
4. (Optional) Click rule → drill to linear follow-ups of that rule.
Path 2: EVENT-FIRST (UPC-lawyer / paralegal)
"Das Gericht hat einen Hinweisbeschluss erlassen — was bedeutet das?"
1. Page open → "Direkt suchen" tab
2. Filter strip: Forum=UPC + event_kind=order
3. Search "Hinweis" → 3 hits
4. Click `upc.inf.cfi.cmo_review` → linear follow-ups (Antrag CMO-Überprüfung etc.)
Path 3: GUIDED (trainee PA)
"Es ist etwas passiert; ich weiß nicht wie die Frist heißt"
1. Page open → "Geführt" tab
2. R1 event_kind: filing
3. R2 forum: UPC (or skipped if R1 narrowed)
4. R3 proceeding_type: upc.inf.cfi (auto-skipped if only one)
5. R4 event chip-strip: pick the relevant event
6. R5 perspective (only if follow-ups differ)
7. Linear follow-ups render.
Path 4: AKTE-FIRST (senior partner / paralegal with project context)
"Auf HL-2024-001 ist heute Klageerwiderung zugegangen — was nun?"
1. Page open → Akte picker → HL-2024-001
2. Page auto-derives `proceeding_type` + `our_side` + `scenario_flags`
3. Default landing: TREE of upc.inf.cfi, scenario flags pre-loaded
4. Click "Klageerwiderung" card → linear follow-ups, write-back footer enabled
5. Tick rules → "In Akte speichern" → POST /api/projects/.../deadlines/bulk
```
All four paths share:
- the same filter strip (forum / proceeding / event_kind / party — values persist across paths in URL)
- the same view-mode toggle (when tree is showing)
- the same scenario_flags (when Akte is loaded)
- the same per-card vocabulary (`[Aufnehmen]` / `[Entfernen]` / `[Bedingt]` / `[Gegenseitig]` / `⇲`)
- the same cross-pivot affordance ("Im Ablauf zeigen" / "Folge-Fristen anzeigen")
The user can switch paths mid-task: started in Path 4, lost in the Akte's tree, jump to Path 2 (search) to find a specific event, then jump back to the tree via the cross-pivot. Tab state preserved.
---
## §6 Selection state spec
Already locked by atlas's `design-deadline-system-revision-2026-05-27.md` §2.3 + §2.4a. Briefly, in the unified tool's context:
- **Named flags** (`with_ccr`, `with_amend`, `with_cci`, plus catalog extensions) — top "Szenario-Flags" strip when proceeding is locked. Edits write to `projects.scenario_flags` (Akte) or localStorage (kontextfrei) and dispatch `scenario-flag-changed` CustomEvent. Both tree and linear views listen and re-render.
- **Per-rule deviations** (`rule:<uuid> = true|false`) — `[Aufnehmen]` / `[Entfernen]` chips on each tree card; identical to the result-view checkboxes in linear mode (linear's "checked" state literally is `rule:<uuid>=true`).
- **Default population:** none on project create. The flat-map only stores deviations from priority defaults.
**Cross-view sync.** When the user toggles "Klageerwiderung" in linear write-back, the tree's corresponding card immediately re-renders with the chip state updated — same CustomEvent. When the user clicks `[Aufnehmen]` on the tree's "Antrag CMO-Überprüfung", switching to linear shows it pre-checked.
**Kontextfrei vs Akte:** kontextfrei writes to `localStorage["paliad.verfahren.scenario.<proceeding_code>"]` (per-proceeding key — different proceedings have different selection sets, matching the existing `paliad.verfahrensablauf.scenario.*` convention). Akte writes to the DB column.
---
## §7 Sequence visualisation
Three candidate shapes. Issue brief lists "vertical tree, horizontal timeline, collapsible groups, per-priority lanes" as options. Today's surfaces use:
| Shape | Where today | What it does well | What it does poorly |
|---|---|---|---|
| **3-column swimlane** (Unsere / Gericht / Gegenseite) | Verfahrensablauf default view | Reads side-of-table cleanly; left = our action, right = opponent's | Dense at depth; mobile-hostile; cross-party hops zig-zag across columns |
| **Single-column linear timeline** | Verfahrensablauf alt view | Mobile-friendly; chronological | Loses parent-chain structure visually |
| **Vertical tree (indented)** | atlas P3 proposal; ASCII trees in design docs | Shows chain depth; clean on desktop + mobile; matches mental model | Less easy to read date-order at a glance |
| **Priority groups** | Mode A/B result view | Highlights what's urgent | Loses sequence; only works for one anchor |
**Recommendation:** make the tree the canonical desktop shape (atlas P3); the 3-column swimlane becomes an optional view ("Schwimmbahnen") when the user wants side-comparison; mobile defaults to the single-column linear timeline collapsed by depth. Per-priority groups stay as the linear-output sub-shape (only when an event is locked).
This is a strict superset of today's options — no shape is removed.
**Concrete rendering rules:**
- Each card carries 4 axes: priority, selection state, conditional gate, cross-party. Visual style composes them: priority = colour stripe; selection = solid vs dotted border; conditional-flag-off = greyed; cross-party = muted + Gegenseitig badge.
- Spawn rules render as **leaf chips** with `⇲` icon. In Akte mode, the chip becomes a CTA: click → create child project of the spawn target's PT, link via `parent_project_id`. Already wired via `/api/projects/{id}/timeline/counterclaim` for the CCR case.
- Court-set rules carry a "vom Gericht bestimmt" badge in place of the computed date. The card is still rendered (it's still part of the ablauf), just without a date column entry.
- Chain depth is rendered via **indentation + connector lines**, capped at depth-5 (today's max is 4 for the upc.inf.cfi CCR branch). Beyond depth-3 the lines fold to a "in 3 weiteren Schritten" collapsible hint — keeps long chains from running off the screen.
---
## §8 Context preservation when drilling
m: "when a user drills into a single rule from one entry, how to keep the surrounding sequence visible".
Three options:
1. **Split-pane** — left: tree of the proceeding; right: linear follow-ups of the focused rule. Tree highlights the focused node.
2. **Inline drawer** — clicking a rule expands an inline drawer beneath it showing follow-ups; tree stays in place; drawer is collapsible.
3. **Breadcrumb pivot** — single output shape at a time; pivoting linear→tree shows a breadcrumb chain "upc.inf.cfi > Klageerhebung > Klageerwiderung > [Klageerwiderung is here]"; tree renders with the breadcrumb highlighted.
**Inventor pick: option 2 (inline drawer)** for desktop, **option 3 (breadcrumb)** for mobile. Reasons:
- Split-pane (option 1) is the cleanest visualisation but burns half the screen on context the user might not want. Optional via a "Zwei Spalten" toggle for power users.
- Inline drawer (option 2) keeps everything in one column with progressive disclosure; the user scrolls through the tree, expands the rule they care about, sees follow-ups, collapses, moves on. Matches how the existing `<details>` flow already works on /admin pages.
- Breadcrumb (option 3) is the only sensible mobile pattern — split panes can't, drawers nest awkwardly.
When in the inline drawer, the focused rule's follow-ups render in the same priority-group shape as the linear view; the per-rule `[Aufnehmen]` / `[Entfernen]` chips work identically; write-back to Akte works identically. The drawer is the linear view embedded.
---
## §9 Mobile / narrow viewport
Today's Verfahrensablauf 3-column swimlane is desktop-heavy. The tree-output proposal collapses better, but still needs careful narrow-viewport rules.
Layout breakpoints:
- **< 640px (phone):** single-column. Filter strip collapses to a sticky "Filter" button bottom-sheet panel with the same chips. Entry-mode picker collapses to a sticky dropdown ("Verfahren wählen ▾"). Tree renders with no indentation lines; depth-N items get a leading "└ ".indent decoration only. Per-card chips ([Aufnehmen] etc.) move to a "..." menu on each card. View-mode toggle moves to a single icon button cycling PflichtGewähltAlle.
- **640-1024px (tablet):** filter strip stays at top but wraps; entry-mode picker becomes tabs; tree renders with proper indentation. View-mode toggle and Akte picker stay inline.
- **> 1024px (desktop):** full layout per §3.2. Optional "Zwei Spalten" toggle for the split-pane variant (§8.1).
**Mobile drill-down (§8 option 3):** clicking a card on phone pushes a new route `?focus=<rule_id>` and renders the linear follow-up view full-screen with a back-arrow breadcrumb. Back arrow restores the tree at the previous scroll position.
**Filter persistence across viewports:** URL params survive resize, the bottom-sheet panel reflects the same state as the desktop top-strip — same state machine.
---
## §10 Worked examples — 3 personas
### §10.1 Trainee PA — "what's next after Klageerwiderung?"
Persona: Anna, 6-month PA trainee, doesn't know which proceeding "Klageerwiderung" belongs to.
1. Opens `/tools/procedures`. No Akte. Lands on "Verfahren wählen" tab (default) but she doesn't want to browse — she wants to find one event.
2. Clicks "Geführt" tab. R1: was hat sich ereignet → **filing**. R2: forum → **UPC**. R3: proceeding_type → **upc.inf.cfi** (the only filing-forum option that has "Klage" in its events). R4: event chip-strip → **Klageerwiderung**. R5: perspective — wizard asks because the follow-ups differ → **defendant**.
3. Lands on linear follow-ups view. Sees: Pflicht: Replik (claimant, 1 Monat); Empfohlen: Vorl. Einwendungen; Optional: Widerklage; Bedingt: Antrag auf Patentänderung (greyed, with_amend off).
4. Wants to know: where does Klageerwiderung sit in the bigger picture? Clicks "Im Ablauf zeigen". Tree renders, with Klageerwiderung highlighted; she sees the SoC root above it, the CCR branch beside it, the cascade of Replik/Duplik below.
5. Anna learns the shape. Back to her task — she copies the Replik date into her notes.
### §10.2 Senior partner — brief client on full upc.inf.cfi ablauf
Persona: Dr. Becker, senior litigator, briefing a client on Friday about a new UPC matter that hasn't been filed yet.
1. Opens `/tools/procedures`. No Akte (matter not in Paliad yet).
2. Tab: "Verfahren wählen" → clicks `upc.inf.cfi` chip.
3. Tree renders. View-mode at default **Gewählt** — shows mandatory + recommended. Becker flips to **Alle Optionen** to brief the client on the full set including conditional branches.
4. CCR branch greyed (with_ccr off by default in kontextfrei). Becker ticks `with_ccr` in the flag strip. Tree re-renders; CCR branch lights up.
5. Becker wants to print this. Cmd-P / "PDF exportieren" (out of scope for this design but flagged). Tree-with-current-state renders cleanly because nothing depends on viewport hover.
6. After the call, Becker creates the Akte in Paliad. Returns to the page with `?project=HL-2025-031`. Same state preserved into the new project — `scenario_flags = {with_ccr: true}` writes to DB on first PATCH.
### §10.3 Paralegal — enter CMS-received Hinweisbeschluss into Akte
Persona: Sandra, paralegal, daily CMS triage. Today: a Hinweisbeschluss arrived on HL-2024-001 (upc.inf.cfi).
1. Opens `/tools/procedures` → picks HL-2024-001 from Akte picker.
2. Page auto-derives proceeding = upc.inf.cfi, our_side = claimant, scenario_flags = {with_ccr: true} (already on this matter).
3. Default landing: TREE of upc.inf.cfi, scenario state loaded. Sandra sees the full ablauf with the matter's actual selections.
4. She knows the event is a Hinweisbeschluss → uses the search box (top right corner of the unified page, available in any mode) → types "Hinweis".
5. Search popover shows 1 result: `upc.inf.cfi.cmo_review` (Antrag auf CMO-Überprüfung). Sandra clicks → tree scrolls + highlights the rule; drawer expands beneath it showing the follow-up rule `upc.inf.cfi.cmo_review_resp` with computed date (today + R.333.2 duration).
6. Drawer footer has "In Akte speichern" button. Sandra ticks the follow-up rule, sets trigger date = today, audit reason = "CMS-Hinweisbeschluss eingegangen", saves.
7. Deadline inserted into HL-2024-001. Sandra returns to her queue.
Total clicks: 5 (open tool, search, click result, tick, save). No mode-switching, no URL-jumping, no two-tab juggling.
---
## §11 Migration plan
Five-slice train. Each slice ships as one PR. P0 is the model layer atlas already designed; everything below is surface-layer on top.
| Slice | Mig | What ships | Reversible? |
|---|---|---|---|
| **U0 — Shared filter-strip component** | — | Extract Mode A's filter strip + Verfahrensablauf's filter chips into one `<FilterStrip>` component used by both pages (still two URLs). Standardise chip names, order, colour. Cross-link buttons in both directions. | Yes — code-only |
| **U1 — New unified page at `/tools/procedures`** | — | New route + page shell. Carries Akte picker, filter strip, entry-mode tab control. Initially shows TREE view only (lifts from /tools/verfahrensablauf without removing the original). | Yes — route addition |
| **U2 — Linear output + drawer + cross-pivot** | — | Embed the Mode A/B result-view rendering as an inline drawer in U1. Cross-pivot "Im Ablauf zeigen" / "Folge-Fristen anzeigen" wired. Search box top-right available in all modes. | Yes — code-only |
| **U3 — Entry mode tabs (Direkt / Geführt / Verfahren / Aus Akte)** | — | Wire Mode A search + Mode B wizard as additional entry tabs on `/tools/procedures`. All four entry paths converge on either tree or linear output depending on what the user picked. | Yes — code-only |
| **U4 — Redirects + deprecation** | — | **Per m's Q11 (§11.5): hard cut, no dual-shipping.** `/tools/fristenrechner?…` → 301 → `/tools/procedures?mode=direkt&…` (preserve query params). `/tools/verfahrensablauf?…` → 301 → `/tools/procedures?mode=ablauf&…`. Sidebar + cmd-K updated in the same PR. Old `*.tsx` files deleted. No `?legacy=1` escape. | Reversible only by revert PR |
**Constraint:** U0-U3 are independent of atlas P0-P3 and can ship in parallel (different files). U4 should land after atlas P3 (`/tools/verfahrensablauf` tree) so the redirect target carries the full tree shape from day 1. If atlas P3 slips, U4 stays in the queue.
**No DB migration.** All state lives in `projects.scenario_flags` (atlas P0) + localStorage. URL param schema is additive.
**Pre-deploy gauntlet:** kontextfrei + Akte modes × each entry path × tree + linear output = 16 path/output combinations. Plus mobile narrow viewport for all 4 entry paths. Plus URL deep-link restore for each saved-state shape.
---
## §11.5 m's decisions (2026-05-27)
All 12 questions answered via `AskUserQuestion` in 3 batches of 4. 9 picks on-recommendation; 3 diverged from the inventor pick. Decisions below; raw question list preserved in §12 as the historical record.
### Tier 1 — does the unification happen at all & what does it look like?
- **Q1 (Unify vs Align): Full unification — one URL.** [= recommendation] **Locks §3.2.** The four reader surfaces (Fristenrechner Mode A + Mode B + result + Verfahrensablauf) fold into a single page with entry-mode tabs and two output shapes. Aligned-but-separate (§3.3) is dropped from the plan.
- **Q2 (URL/Name): `/tools/procedures` — English.** [≠ recommendation; m diverged from inventor's `/tools/verfahren` pick] m's verbatim:
> just one, but english name - call it tools/procedures ...
**Locks §3.2 + §11 (renames `/tools/verfahren` → `/tools/procedures` throughout).** Rationale: the codebase convention is "English in code, German in UI" (project CLAUDE.md: "All code, table names, Go types, service names, URL paths, API endpoints, file names — English"). `/tools/procedures` follows that rule; the inventor's `/tools/verfahren` strawman broke it. The German sidebar entry stays "Verfahren & Fristen" (Q12) — the URL is the developer surface, the label is the user surface.
- **Q3 (Default entry / search shape): All entry modes as tabs + text search combined with dimension filters.** [≠ recommendation; m reframed the question] m's verbatim:
> yeah, different tabs, right?! I think we need to have all of the named ones. And we can combine a text search with filters for the dimensions of the event
**Locks §3.2 + §5 + reshapes §4.** All four named entry paths (Verfahren wählen / Direkt suchen / Geführt / Aus Akte) are visible as tabs simultaneously. The search box is part of the filter strip at the top of the page and composes with the chip filters (Forum / Verfahren / event_kind / Partei) at all times. The "Direkt suchen" tab still exists for the explicit search-first workflow, but the search input is also live in tree mode (top-of-page filter strip) — meaning a user browsing a proceeding can refine the tree's rendered set by typing into the same search box that filters Mode A. The default landing question ("which tab is active first") becomes a secondary concern: any of the four tabs is one click away. Default behaviour: first tab in the strip ("Verfahren wählen") is selected on cold open with no Akte, but the URL preserves the user's last-active tab if returning via a deep-link.
- **Q4 (Akte default behaviour): TREE of the Akte's proceeding.** [= recommendation] **Locks §3.2 + §10.3.** Akte picker triggers auto-derivation of `proceeding_type` + `our_side` + `scenario_flags`, lands on the tree view with the matter's state loaded.
### Tier 2 — tree mechanics + visual style
- **Q5 (Tree shape): Both vertical tree + 3-column swimlane, with a toggle.** [= recommendation] **Locks §7.** Default desktop = vertical indented tree (clean chain depth, mobile-translatable); "Schwimmbahnen" toggle reveals the 3-column swimlane (Unsere Seite | Gericht | Gegnerseite) for side-comparison. Toggle state in `localStorage["procedures:tree_shape"]` (per-user, not per-Akte).
- **Q6 (Cross-pivot): Inline drawer beneath the card.** [= recommendation] **Locks §8.** Clicking a rule card expands an inline drawer with the linear follow-up view (priority groups + write-back footer). Tree stays in place above. Multiple drawers can be open. Drawer carries the same per-rule selection chips as the tree, so writes propagate to scenario_flags identically.
- **Q7 (Search position): Always-visible search bar in the filter strip.** [= recommendation] **Locks §4 + §3.2.** Search input lives in the top filter strip next to the chip groups; available in every output mode. Composes with chip filters via AND semantics (chip filters narrow the corpus, search ranks within the narrowed set). This is what m's Q3 reframe asked for.
- **Q8 (Cross-party rows in tree): Show with Gegenseitig badge + muted style.** [= recommendation] **Locks §7.** Tree renders the full graph including opponent rows, muted + badged consistently with the linear view. Identical to atlas's locked treatment for the linear view (`design-deadline-system-revision-2026-05-27.md` §2.4).
### Tier 3 — mobile + migration
- **Q9 (Mobile tree shape): Single-column with `└` indent decorator.** [= recommendation] **Locks §9.** Phone-narrow render keeps depth via leading-marker indentation; SVG connector lines drop; cards stack vertically. Resize back to tablet/desktop restores the full tree with connector lines.
- **Q10 (Mobile drill): Push new route with breadcrumb back.** [= recommendation] **Locks §9.** Clicking a card on phone pushes `?focus=<rule_id>` and renders the full-screen linear follow-up view with a back-arrow breadcrumb. Tree scroll position preserved on back. Inline drawer is desktop-only.
- **Q11 (Migration window): Hard cut — no dual-shipping window.** [≠ recommendation; m diverged from "2 weeks 302"] m's verbatim:
> not at all
**Locks §11 (rewrites the U4 slice).** When `/tools/procedures` ships, `/tools/fristenrechner` and `/tools/verfahrensablauf` flip directly to redirects (301 permanent, no `?legacy=1` escape hatch). Sidebar entries swap to the new entry in the same release. cmd-K palette swaps to the new entry. No 2-week dual-shipping window. Rationale (interpreted): the audience is internal HLC lawyers (~50 users, all on the same release rhythm). A 2-week dual ship adds complexity for almost no benefit; m would rather flip and fix any broken bookmark via direct comm.
- **Q12 (Sidebar): One entry "Verfahren & Fristen".** [= recommendation] **Locks §11.** Single sidebar item (German label) pointing at `/tools/procedures` (English URL). cmd-K palette updated to one entry "Verfahren & Fristen" with `/tools/procedures` as the action.
### §11.5.1 Changes triggered by m's divergences
Three picks changed the design beyond ratification. Summarised here so the coder reads the *current* design, not the pre-grilling strawman.
1. **URL rename `/tools/verfahren` → `/tools/procedures`** (Q2). Replaces every URL reference in §3.2, §4, §5, §10, §11, §14. Page name in the codebase: `frontend/src/procedures.tsx`. Sidebar label stays German ("Verfahren & Fristen"). Internal Go types stay English (`ProceduresPage`, etc.).
2. **All-tabs-visible + search-as-filter** (Q3). Replaces the strawman's "pick a single default tab" wording in §3.2 + §4. The unified page now renders all four entry-mode tabs at all times (Verfahren wählen / Direkt suchen / Geführt / Aus Akte). The search box is in the filter strip alongside the chip filters and composes with them in every output mode (tree + linear). The "Direkt suchen" tab remains, but its function shifts: it's the *search-first cold start* tab; once the user has any output (tree or linear), the search box at the top of the page is the canonical re-narrowing affordance. The wizard tab ("Geführt") and the Akte tab still exist as explicit workflows.
3. **Hard cut, no dual-ship** (Q11). Slice U4 in §11 is rewritten: 301 redirects on `/tools/fristenrechner` + `/tools/verfahrensablauf` to the new page; no `?legacy=1` escape; the old `*.tsx` files are deleted in the same PR. Bookmarks resolve via the 301; no in-product affordance points at the legacy URL after the merge.
### §11.5.2 What stays unchanged
The other 9 picks (Q1, Q4-Q10, Q12) ratified the inventor proposal. The full unification at a single URL with two output shapes (tree + linear drawer), four entry paths, shared selection state via `projects.scenario_flags`, vertical tree + swimlane toggle, mobile `└` decorator + breadcrumb-back drill-down, single sidebar entry — all locked as drafted in §1-§11.
---
## §12 Open questions for m
Twelve questions, batched 4 + 4 + 4 for `AskUserQuestion`. The first batch is **must-answer** (decides the unification's existence + URL shape); the second is **shape** (tree mechanics + visual style); the third is **mobile + migration** (operational).
Will be answered via `AskUserQuestion` per the inventor SKILL; m's picks fold back into a `§12.5 m's decisions (2026-05-27)` section at the top of this file before the "DESIGN READY FOR REVIEW" signal.
### Batch 1 — does the unification happen at all & what does it look like?
- **Q1 (Unify vs Align):** Fold the four reader surfaces into `/tools/procedures` (full unification §3.2), or keep `/tools/fristenrechner` and `/tools/verfahrensablauf` as separate URLs and just tighten alignment (§3.3)?
- **Q2 (Naming):** If unifying — what's the page name? `/tools/verfahren` (generic German, my original pick), `/tools/fristenrechner` (lawyers know this one — repurpose as the supermarket), or `/tools/ablauf` (closest to what it does)? (m diverged with `/tools/procedures` — see §11.5.)
- **Q3 (Default entry mode):** When the user opens `/tools/procedures` with no URL params and no Akte, which entry tab is active? "Verfahren wählen" (browse, my pick), "Direkt suchen" (power), "Geführt" (onboarding).
- **Q4 (Akte default behaviour):** When user picks an Akte from the picker, default landing — TREE of the Akte's proceeding (my pick) or "remember last view" per-user.
### Batch 2 — tree mechanics + visual style
- **Q5 (Tree shape):** Desktop tree rendering — vertical indented tree (my pick), 3-column swimlane (current Verfahrensablauf default), or both with a "Schwimmbahnen" toggle.
- **Q6 (Cross-pivot affordance):** When clicking a rule card in the tree to see its follow-ups — inline drawer beneath the card (my pick), split-pane (tree left + linear right), or full-page push (replaces tree, breadcrumb back).
- **Q7 (Mode A search location):** The free-text "Direkt suchen" entry — only as a top-tab (my pick, with a small search icon always available in tree mode), always-visible search bar at top, or only inside the "Direkt" tab.
- **Q8 (Cross-party rows in linear):** Atlas locked "show with Gegenseitig badge, unchecked default, unconditionally excluded from write-back". In tree mode, same treatment (my pick) or hide cross-party rows entirely by default and surface via "Gegenseite einblenden" toggle.
### Batch 3 — mobile + migration
- **Q9 (Mobile tree shape):** On phones (< 640px) single-column indented list with leading "└" decorator (my pick), single-column flat list (no indentation), or chronological-timeline view (auto-pivots when narrow).
- **Q10 (Mobile drill-down):** Clicking a card on phone push new route with breadcrumb-back (my pick), inline drawer (cramped on small screens), or modal sheet.
- **Q11 (Migration window):** After the unified page ships 2-week dual-shipping with 302 redirects (my pick, matches t-paliad-322 S5 pattern), 1-week, or 4-week.
- **Q12 (Sidebar entries):** Sidebar today has "Fristenrechner" + "Verfahrensablauf" as separate items. Post-merge one entry "Verfahren & Fristen" (my pick), keep both with both same URL, or pick one ("Fristenrechner" or "Verfahrensablauf") as the canonical name.
---
## §13 Out of scope
- Calculator changes (`pkg/litigationplanner.CalculateRule`). Working.
- Editorial backfill (curie owns t-paliad-333 in parallel).
- /admin/procedural-events as a read surface different audience.
- /projects/{id} Verlauf per-Akte actuals; sister tool.
- SmartTimeline / `ProjectionService` per-project read view, downstream consumer.
- youpc.org/deadlines cross-repo snapshot consumer.
- Outlook / Calendar sync UI.
- PDF export of the tree (mentioned in §10.2 but not designed here).
- Bulk-write affordances beyond the existing `/deadlines/bulk` endpoint.
- Multi-project comparison views (would belong in SmartTimeline at Patent / Litigation / Client level, not in `/tools/procedures`).
- Translation between languages of free-text scenario flag names.
---
## §14 Synthesis links
- mBrian: file as `[synthesis]` linked `triggered_by` t-paliad-334; `related_to` athena's assessment + atlas's deadline-system-revision design + cronus's earlier Fristenrechner overhaul design.
- Cross-refs in this repo: `docs/assessment-deadline-system-2026-05-27.md` (athena), `docs/design-deadline-system-revision-2026-05-27.md` (atlas), `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26), `docs/design-event-card-choices-2026-05-25.md` (existing per-card choice).
- Gitea: m/paliad#151 (this design), m/paliad#149 (atlas Phase 2), m/paliad#146 (cronus 2026-05-26 Fristenrechner overhaul, S1-S6 shipped).
- Coder phase (deferred per inventor SKILL): runs after m ratifies via AskUserQuestion. Slice ordering per §11. NOT cronus (parked at "DESIGN READY FOR REVIEW"). A pattern-fluent Sonnet coder picks up U0 first; U1-U3 sequential; U4 gated on atlas P3 landing.

View File

@@ -0,0 +1,685 @@
# PRD — Procedures: Litigation Builder (m/paliad#153)
**Task:** t-paliad-339
**Gitea:** m/paliad#153
**Inventor:** edison (shift-1, Opus)
**Date:** 2026-05-27
**Branch:** `mai/edison/inventor-prd-columnar`
**Status:** Draft — DESIGN READY FOR REVIEW. Coder gate held.
**Builds on (read before extending this PRD):**
- `docs/design-procedures-workflow-tracker-2026-05-27.md` — atlas's reverted tracker design (m/paliad#152). The anchor+scope idea did not land; understand *why* before re-proposing.
- `docs/design-unified-procedural-events-tool-2026-05-27.md` — cronus's U0-U4 catalog, currently live on main @ `ed3c5d1` post-revert. Visual baseline for filter strip + tab control.
- `docs/design-deadline-system-revision-2026-05-27.md` — atlas Phase 2 model layer (scenario_flags SSoT, view-mode toggle, per-rule selection chips). Model layer is locked; this PRD is purely surface + new persistence tables.
- `docs/design-fristenrechner-overhaul-2026-05-26.md` — cronus 2026-05-26 inventor-pass (Mode A + B + result, shipped via t-paliad-322).
**Predecessor takeaway (atlas's debrief on #152):**
> "When the architecture is novel, default to grilling m in prose FIRST. The doc rewrites cost a commit; the bigger cost would have been wasting m's question-batch on the wrong architecture."
Followed here. This PRD captures the architecture m chose through **20 chip-picker decisions across 5 batches**, not an inventor-first strawman.
---
## §0 Premises
### §0.1 What is `/tools/procedures` today (live, post-revert)
The current page is cronus's 4-tab catalog (U0-U4, shipped via m/paliad#151):
- Sticky filter strip (search box + 4 chip rows: Forum / Verfahren / Ereignisart / Partei).
- 4 solid tabs: `Verfahren wählen` / `Direkt suchen` / `Geführt` / `Aus Akte`.
- Default-active tab = "Verfahren wählen" renders `VerfahrensablaufBody` (the legacy Verfahrensablauf wizard: proceeding picker → perspective + date → 3-step wizard → result in 3-column "Spalten" or single-column "Zeitstrahl").
- Other 3 tab panels are stubs (search/wizard/akte never wired in U0-U3).
m's blocking feedback (verbatim, 2026-05-27 22:18):
> I like to keep our current columnar layout with proactive / court / reactive. And it is good if we can select which side we want to simulate. […] There are basically three main approaches I see to this: Get an overview over proceedings, play around with options, build Scenarios. Another one where something specific happened and we just want to know what deadlines we need to note […]. A third one from a specific proceeding / case file where things take place / have taken place.
And the architecture-shifting follow-ups (2026-05-27 22:35-22:36, mid-grilling):
> I would prefer to have an interface where not every constellation is in the URL by the way. That seems limiting.
> We could just have a litigation builder. Sometimes we build a full scenario with multiple instances etc, sometimes we just want the next step.
> we should have ways to save these "litigation constellations" where we save which proceedings we have and which state they are in, which submissions were or were not filed. A small Scenario DB could work, dont you think?
These three statements upgraded the brief from "redesign a catalog" to "build a Litigation Builder backed by a Scenario DB". The PRD below is shaped by them.
### §0.2 Locked constraints (m's words, brief in #153)
- Columnar layout: `proaktiv | court | reaktiv` (perspective-flippable).
- Three approaches as entry modes: overview/scenarios, event-triggered, case-file driven.
- Filtering across all dimensions + text search.
- Optional follow-ups: toggleable, highlightable, with display-count setting.
- Modular *where it actually helps* (m: "I don't know — generally does not super apply here." — drop modular as a load-bearing goal).
- UPC v1, expand later.
### §0.3 Live data the builder works against
Verified 2026-05-27 against `paliad.sequencing_rules` (231 published / 242 total):
- 110 chained (`parent_id` not null).
- 78 trigger-rooted, 4 spawns (cross-PT), 47 court-set, 18 conditional.
- ~46 proceeding types total (UPC 35 / DE 5 / EPA 3 / DPMA 3). v1 focuses on UPC.
- `paliad.proceeding_types.kind` discriminator (atlas's t-paliad-324) filters non-proceeding rows (phases/side_actions/meta) from the picker.
- `paliad.deadlines` carries both `procedural_event_id` and `sequencing_rule_id` → Akte actuals overlay is a direct join.
- `paliad.projects.scenario_flags` jsonb (atlas P0) is the SSoT for project-level scenario state; the new `paliad.scenario_proceedings.scenario_flags` mirrors this shape per-proceeding-per-scenario.
### §0.4 Scope (in / out)
**In:**
- Replace `/tools/procedures` with the Litigation Builder.
- New `paliad.scenarios` + `paliad.scenario_proceedings` + `paliad.scenario_events` + `paliad.scenario_shares` tables.
- Promote-to-project flow (scenario → `paliad.projects` row).
- Bidirectional link from `/projects/{id}` (button: "Im Builder öffnen" — exports project state to a builder session).
**Out (deferred or owned elsewhere):**
- Calculator (`pkg/litigationplanner.CalculateRule`) — working.
- Editorial backfill (curie's t-paliad-333 owns the 7 compound rules + R.109).
- `/admin/procedural-events` (editor surface; different audience).
- `/projects/{id}` Verlauf / SmartTimeline (per-Akte actuals; sister tool).
- youpc.org / Outlook / PDF export.
- Multi-jurisdiction expansion (DE/EPA/DPMA) — UPC v1 first.
- Cross-proceeding peer triggers (UPC-inf judgment → EPA opp choice deadline) — v1.1.
- Multi-user concurrent editing on the same scenario (out of scope; sharing is read-only).
---
## §1 Goals
1. **One canvas, three entry modes.** Unify the 3 approaches into a single Litigation Builder surface. The entry modes (`Übersicht / Ereignis / Aus Akte`) shape the *initial* state of the canvas; once the user is working, the canvas itself is what they interact with.
2. **Persisted constellations.** A user can save a "litigation constellation" — multiple parallel proceedings with their flags, filed/skipped/planned event states, dates, and notes — as a named scenario. Scenarios live in the DB (not the URL).
3. **Auto-save by default.** No "unsaved changes" modals. The active scenario auto-persists. Anonymous scratch scenarios convert to named ones when the user clicks "Benennen".
4. **Promote-to-project.** A scenario can be turned into a real `paliad.projects` row via a 3-step wizard. Procedural shape, placeholder parties, notes, and filed-state all carry over; the user fleshes out client-bound metadata during the wizard.
5. **Share read-only with the team.** Each scenario is private by default; explicit "An Team teilen" grants named HLC users read-only access. Original owner stays sole editor.
6. **Columnar geometry restored.** The current "Spalten" view (claimant | court | defendant) returns as the canonical render — but now per-proceeding-triplet within a scenario, with perspective ("our side") flippable per proceeding so `proaktiv | court | reaktiv` reads correctly across multi-proceeding constellations.
7. **Per-event-card optional horizon.** Each event card on the canvas can dial in how many optional follow-ups to surface. Cards are the unit of optional-display control.
---
## §2 User journeys
### §2.1 Journey A — Cold-open builder ("Übersicht / Scenarios")
**Persona:** Dr. Becker, senior partner. Friday afternoon. New UPC matter not yet committed; she's briefing a client on Monday on the full procedural shape.
1. Opens `/tools/procedures`. No `?scenario` param. Cold-open canvas: empty workbench with a "Neues Szenario starten" CTA and a short list of her 5 most-recent scenarios.
2. Clicks the CTA → inline picker (Forum chip row → Verfahren chip row → `Hinzufügen`). Picks UPC + `upc.inf.cfi`.
3. Canvas now renders one proceeding triplet (`proaktiv | court | reaktiv`). Default perspective is empty (no party selected) — both sides render equally; the perspective radio in the page header sits unset.
4. She picks defendant perspective at the page header → triplet flips. The defendant column becomes `proaktiv` (her side); claimant becomes `reaktiv`.
5. She adds a second proceeding via `+ Verfahren hinzufügen` at the bottom: EPA `epa.opp.opd`. New triplet stacks below the first. New triplet's perspective defaults to "patentee" inheriting from her client's role across the two; she flips per-proceeding via the triplet header.
6. She turns on `with_ccr` on the UPC inf triplet's per-proceeding flag strip. The CCR child triplet auto-expands inline below the parent at the spawn node.
7. Auto-save kicks in (debounced 500ms). The page header shows "Gespeichert in Scratch · Benennen".
8. She clicks "Benennen", enters "Becker — UPC + EPA defensive". Side panel "Meine Szenarien" updates.
9. On Monday she opens the scenario from her recent list, walks the client through it, hits "Als Projekt anlegen" (when the client commits). 3-step wizard fires (§5.4).
### §2.2 Journey B — Event-triggered lookup ("Ereignis")
**Persona:** Sandra, paralegal. Today: a Hinweisbeschluss arrived on a CMS queue. She doesn't know yet which Akte it belongs to.
1. Opens `/tools/procedures`. Picks "Ereignis" entry mode at the top.
2. Page-header search box auto-focuses. She types "Hinweis" → universal search drops down: `5 Ereignisse · 1 Szenario · 0 Akten`. Picks the event `upc.inf.cfi.cmo_review` (Antrag CMO-Überprüfung).
3. Canvas renders one triplet of `upc.inf.cfi` with the Hinweisbeschluss event card auto-anchored (lime band + `━━ DU BIST HIER ━━` divider above the next-coming events).
4. She reads the follow-ups: "Antrag auf CMO-Überprüfung (claimant, R.333.2 · 1 Monat)" and 2 optional follow-ups. The Stichtag input in the page header defaults to today; she leaves it.
5. She doesn't save anything — this was a quick lookup. Scratch scenario auto-persists but she doesn't name it; it'll fall off her recent list after a while.
6. Later she identifies the matter (HL-2024-001), switches to "Aus Akte" mode, and continues there.
### §2.3 Journey C — Case-file driven ("Aus Akte")
**Persona:** Anna, senior associate. Working on HL-2024-001 (UPC infringement). The client just confirmed they want to file a CCR.
1. Opens `/tools/procedures`. Page-header Akte picker shows recent projects; she picks HL-2024-001.
2. Page header auto-fills: proceeding = `upc.inf.cfi`, perspective = defendant (from `projects.our_side`), scenario_flags = `{with_ccr: false}` (current state).
3. Builder loads: one `upc.inf.cfi` triplet, perspective-flipped. Event cards overlay actuals from `paliad.deadlines``Klageerhebung` is filed (2026-01-15), `Klageerwiderung` is planned (2026-04-01, computed), others are planned.
4. She turns on `with_ccr` on the triplet's flag strip. The CCR child triplet expands inline. **Crucially:** the scenario is *project-backed* — the flag write also patches `projects.scenario_flags` (via existing `PATCH /api/projects/{id}/scenario-flags` from atlas P0). When she walks away, the project's deadlines + flags reflect the builder's state.
5. She marks the `Widerklage auf Nichtigkeit` event card as "filed" with today's date. Builder writes a `paliad.deadlines` row with `status='done'` + `completed_at=today`, audit_reason "via Litigation Builder". Project's Verlauf reflects this.
6. The CCR child triplet's `Antrag Patentänderung (R.30)` event card surfaces. She marks it "planned" and ticks the per-card optional horizon to "+2" → 2 more optional R.30-adjacent rules surface.
7. Exit: she closes the tab. Project state persists in `paliad.projects` + `paliad.deadlines` as before; the scenario row tracks the builder-session view (so when she returns, the canvas state is restored — including her per-card optional-horizon picks).
### §2.4 Journey D — Promote scratch to a real project
**Persona:** Dr. Becker, follow-up from Journey A. The client committed; she wants to convert the scenario into a real matter.
1. With "Becker — UPC + EPA defensive" loaded, she clicks "Als Projekt anlegen" in the page header.
2. **Wizard step 1: Bestätigen.** Read-only summary of what's about to be promoted: 2 proceedings (UPC inf + EPA opp), CCR child, 3 scenario flags set, 0 events filed, 5 events planned, 2 notes. "Weiter".
3. **Wizard step 2: Parteien ergänzen.** Each proceeding's parties section shows whatever placeholder names she sketched in the scenario ("Klg X" / "Bekl Y"). She edits each into the real names. (Per m's Q11 pick — full carry — placeholder strings come in; the wizard's job is to clean them.)
4. **Wizard step 3: Akte-Metadaten.** Case number, client, litigation parent project (optional), our_side (auto-set from the scenario's primary triplet), team selection. "Anlegen".
5. New `paliad.projects` row written with `origin_scenario_id = <scenario.id>`. Scenario row's `status` flips to `promoted`, `promoted_project_id` points back. Builder navigates to `/projects/<new-id>`.
6. The scenario stays read-only in her "Meine Szenarien" list under "Promoted", reachable for historical reference (cf. "this is what we planned at briefing time").
### §2.5 Journey E — Share a scenario with a colleague
**Persona:** Anna shares the HL-2024-001 builder session with Dr. Becker (her supervising partner) for review before committing to the CCR strategy.
1. Anna opens the scenario, clicks "Teilen" in the page header.
2. Side panel slides in with a user-picker (HLC user search). She picks "Dr. Becker", clicks "Schreibgeschützt teilen".
3. `paliad.scenario_shares` row written. Anna remains sole editor.
4. Dr. Becker opens the tool. Her side panel "Meine Szenarien" has a new bucket "Geteilt mit mir"; Anna's scenario is listed. She opens it: canvas renders the same view but every mutating affordance (add proceeding, flag toggle, file/skip, promote, share) is disabled. Watermark: "Geteilt von Anna · schreibgeschützt".
5. Becker reads, drops Anna a note via existing comment infrastructure (out of scope — separate ticket). Decision made out-of-band. Anna proceeds.
---
## §3 The canvas shape
### §3.1 ASCII sketch
```
┌─────────────────────────────────────────────────────────────────────────────────────┐
│ Paliad · Verfahren & Fristen — Litigation Builder [Mein Konto ▾] │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ Szenario: [Becker — UPC + EPA def. ▼] Gespeichert ✓ · [Benennen] [Teilen] [Als Projekt] │
│ Akte: [— ohne — ▼] Stichtag: [2026-04-01] │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ Filter: [🔍 Klageerwiderung, Hinweis, HL-2024… ] │
│ Forum [● UPC] [DE] [EPA] [DPMA] Verfahren [● upc.inf.cfi …] │
│ Partei [Klg] [● Bekl] Ereignisart [filing] [hearing] [decision] │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ Einstieg: [ Übersicht ● ][ Ereignis ○ ][ Aus Akte ○ ] │
├─────────────────────────────────────────────────────────────────────────────────────┤
│ │
│ ┌─ upc.inf.cfi · Verletzungsverfahren UPC Bekl-Sicht [▾] [Detailgrad: Gewählt ▾]│
│ │ Optionen: ☑ with_ccr ☐ with_amend ☐ with_cci [─][×] │
│ ├─────────────────┬────────────────────┬─────────────────────────────────────────┤
│ │ Proaktiv (Bekl) │ Gericht │ Reaktiv (Klg) │
│ │ ┌─────────────┐ │ │ ┌─────────────┐ │
│ │ │ Klageerw. │ │ │ │ Klageerh. │ │
│ │ │ R.23 │ │ │ │ R.13 │ │
│ │ │ planned │ │ │ │ filed │ │
│ │ │ 2026-04-01 │ │ │ │ 2026-01-15 │ │
│ │ │ +3 Optionen ▾│ │ │ │ │ │
│ │ └─────────────┘ │ │ └─────────────┘ │
│ │ │ ┌──────────────┐ │ │
│ │ │ │ Mündl. Verh. │ │ │
│ │ │ │ planned │ │ │
│ │ │ │ [Gericht] │ │ │
│ │ │ └──────────────┘ │ │
│ │ ━━━━━━━━━ DU BIST HIER (Klageerwiderung) ━━━━━━━━━ │
│ └─────────────────┴────────────────────┴─────────────────────────────────────────┘ │
│ │
│ ┌── (spawn child) upc.ccr.cfi · Widerklage auf Nichtigkeit Klg-Sicht [▾] ───────┐│
│ │ Optionen: ☐ with_amend [─][×]││
│ ├────────────────────┬─────────────────┬───────────────────────────────────────┐ ││
│ │ Proaktiv (Klg) │ Gericht │ Reaktiv (Bekl) │ ││
│ │ ┌─────────────┐ │ │ │ ││
│ │ │ CCR-Antrag │ │ │ │ ││
│ │ │ R.49 │ │ │ │ ││
│ │ │ planned │ │ │ │ ││
│ │ └─────────────┘ │ │ │ ││
│ └────────────────────┴─────────────────┴───────────────────────────────────────┘ ││
│ │
│ ┌─ epa.opp.opd · Einspruchsverfahren EPA PatInh-Sicht [▾] [Detailgrad: Gewählt ▾]│
│ │ Optionen: (keine flags für EPA Opp) [─][×] │
│ ├─────────────────┬────────────────────┬─────────────────────────────────────────┤
│ │ Proaktiv │ EPA │ Reaktiv (Einsprechende) │
│ │ ┌─────────────┐ │ │ │
│ │ │ Erwiderung │ │ │ │
│ │ │ R.79(1) EPÜ │ │ │ │
│ │ │ planned │ │ │ │
│ │ └─────────────┘ │ │ │
│ └─────────────────┴────────────────────┴─────────────────────────────────────────┘ │
│ │
│ [ + Verfahren hinzufügen ] │
│ │
└──────────────────────────────────────────────────────────────────────────────────────┘
Side panel (collapsible, right-edge):
┌──── Meine Szenarien ────┐
│ ● Aktiv │
│ ▸ Becker — UPC+EPA def │ ← current
│ ▸ Test-CCR-Patent-X │
│ ○ Geteilt mit mir │
│ ▸ Becker UPC ply │
│ ○ Promoted │
│ ▸ HL-2023-118 │
│ ○ Archiviert (3) │
│ [+ Neues Szenario] │
└──────────────────────────┘
```
### §3.2 What each element does
| Element | Read | Write | Persists in |
|---|---|---|---|
| Page-header scenario picker | Current `scenarios.id` + `name` | Switch scenarios | URL `?scenario=<id>` + DB |
| `[Benennen]` button | Anonymous → named | `scenarios.name`, `status='active'` | DB |
| `[Teilen]` button | — | `scenario_shares` row(s) | DB |
| `[Als Projekt]` button | — | Opens promote wizard | (wizard → DB on commit) |
| Akte picker | User's projects | Loads project state into builder | URL `?project=<id>` + DB |
| Stichtag input | Scenario-level default | `scenarios.stichtag` | DB |
| Filter strip (search + chips) | Free-text + dimension filters | UI state | URL `?q`, `?forum`, … per-mode |
| Einstieg mode radio | Current entry mode | Resets filter strip on change | URL `?mode=` |
| Triplet header (jurisdiction badge + name + perspective + Detailgrad) | `scenario_proceedings.{primary_party, detailgrad}` | Edit | DB |
| Triplet flag strip | `scenario_proceedings.scenario_flags` | Toggle flags | DB |
| Event card (state, date, notes, optional-horizon) | `scenario_events.*` | Edit per-card | DB |
| `+ Verfahren hinzufügen` | — | New `scenario_proceedings` row | DB |
| Side panel | User's scenarios + shared scenarios | Switch + create + archive | DB |
### §3.3 Columns: `proaktiv | court | reaktiv`
The 3-column layout returns as the canonical desktop shape. Per m's locked constraint (and brief #153), it is a **stance grouping**, not a sequence anchor — time flows top-to-bottom (chronological), columns express *who is acting*.
- **Proaktiv**: the column for events the active perspective's party initiates (their `primary_party` matches the event's `primary_party`).
- **Court**: court-set events (`is_court_set=true`), neutral column.
- **Reaktiv**: the column for events the opposing party initiates.
The perspective is per-proceeding (per-triplet, via `scenario_proceedings.primary_party`). When no perspective is set (`null`), both party columns render equally with their natural party labels (Klg / Bekl), not Proaktiv / Reaktiv. This means kontextfrei browsing reads as "claimant column | court | defendant column" until the user picks a side.
This addresses m's reverted-design bug #3 verbatim: "Proaktiv/Gericht/Reaktiv columns are a stance grouping, not a sequence anchor." Time = vertical. Stance = horizontal. The triplet is the unit; multiple proceedings stack vertically.
### §3.4 Event card anatomy
```
┌─────────────────────┐
│ Klageerwiderung │ ← event name (procedural_event.name)
│ R.23 │ ← rule code
│ planned │ ← state: planned / filed / skipped
│ 2026-04-01 │ ← date (computed for planned, actual for filed)
│ +3 Optionen ▾ │ ← per-card optional horizon (only when card has optionals)
└─────────────────────┘
```
State machine (m's Q10 pick — 3-state):
- `planned` (default): future event, date is computed from anchor + duration_value + duration_unit. Click → choose `filed` or `skipped`.
- `filed`: past event, `actual_date` is set (defaults to computed, user can override). Visual: ✓ checkmark, slightly muted "past" tone.
- `skipped`: user chose not to file. Visual: strikethrough text + optional `skip_reason` (textarea). Optional rules are commonly skipped without rationale; mandatory rules with `skipped` state flag the scenario as "non-standard" but don't block.
No `overdue` state — user does the date arithmetic by eye against today. (Mandatory cards rendered in red when `actual_date < today AND state=planned` is a **render hint**, not a stored state.)
**Per-card optional horizon (m's Q4 pick).** Each card with children at `priority IN ('optional','recommended-skip-by-default')` carries a chip `+N Optionen ▾`. Default N=0 (hidden). Clicking opens an inline list of the optional children with `+`/`-` controls to surface/hide them on the canvas. Per-card horizon persists as `scenario_events.horizon_optional int`.
Filed-state cards persist the date in `scenario_events.actual_date date`. The card's notes field (textarea, lazy-loaded) lives in `scenario_events.notes text`.
### §3.5 Court-set events
`is_court_set=true` rules don't compute a date until the court picks one. Card renders with `[Gericht]` badge in place of the date and a small "Datum eintragen" affordance. Clicking `filed` opens a date picker (date is required for `filed` state when `is_court_set=true` — the user is asserting "the court set this date").
Downstream events that anchor on a court-set event render their dates as `[abhängig von <event>]` until the court date is filed, then auto-recompute.
### §3.6 Spawn (child) proceedings
When a triplet has a `with_<flag>` enabled and the flag's gating rule has `is_spawn=true`, the child proceeding (e.g. `upc.ccr.cfi` for `with_ccr` on `upc.inf.cfi`) renders inline as a child triplet **immediately below the parent triplet** in the canvas stack — visually nested via the spawn note in the parent triplet's header band.
`scenario_proceedings.parent_scenario_proceeding_id` FK self-references for the nesting; `scenario_proceedings.spawn_anchor_event_id` points at the gating sequencing_rule so the UI knows where in the parent the spawn happened.
The child triplet has its own perspective, scenario flags, Stichtag override, Detailgrad. It can itself spawn (depth N supported; today's data is 2-deep at most).
Cross-proceeding peer triggers (`upc.inf judgment → epa.opp choice deadline`) are **out of scope for v1** (m's Q14 pick). v1 ships independent triplets stacked vertically; the user mentally tracks cross-dependencies. A future `scenario_event_links` table is the path to peer triggers in v1.1.
---
## §4 Hard decisions table — m's 20 picks
| # | Topic | Pick | Locks |
|---|---|---|---|
| Q1 | Modular meaning | "doesn't super apply" — drop modular as a load-bearing goal | §0.2 |
| Q2 | Tab state semantics | Shared anchor + Akte across modes; filters reset per mode | §3.1, §3.2, §6 |
| Q3 | Case-file integration | Page-header Akte picker, persistent across modes | §3.1, §3.2, §2.3 |
| Q4 | Optional-display horizon | Per-event-card | §3.4 |
| Q5 | Builder shape | Unified builder, 3 entry modes (cold-open / event-triggered / Akte) | §0, §1, §2, §3 |
| Q6 | Scenario↔project relationship | Separate `paliad.scenarios` table + promote-to-project action | §5, §2.4 |
| Q7 | Scenario contents | Multi-proceeding constellation per scenario | §3, §5 |
| Q8 | Save model | Auto-save active scenario + "Meine Szenarien" list | §1, §3, §6.4 |
| Q9 | Multi-proceeding render | Vertical stacked column-triplets | §3 |
| Q10 | Per-event state | 3-state: planned / filed / skipped (no `overdue` state) | §3.4 |
| Q11 | Promote-to-project carry | Everything (incl. placeholder parties + free-form notes) | §2.4, §5.4 |
| Q12 | Sharing model | Private by default + explicit team-share (read-only) | §1, §5, §2.5 |
| Q13 | Scenario flags placement | Per-proceeding (each triplet owns its `scenario_flags`) | §5.1 |
| Q14 | Cross-proceeding peer triggers | Out of scope for v1 (defer to v1.1) | §3.6, §7 |
| Q15 | Perspective scope | Per-proceeding (each triplet has its own `primary_party`) | §3.3, §5.1 |
| Q16 | Add-proceeding flow | `+ Verfahren hinzufügen` button below the last triplet, inline picker | §3, §3.1 |
| Q17 | Cold-open canvas | Empty canvas + "Neues Szenario" CTA + recent-list | §2.1, §3 |
| Q18 | Search scope | Universal: events + scenarios + Akten, scoped by result type | §3.1, §6 |
| Q19 | Promote-to-project flow | 3-step wizard (Bestätigen → Parteien ergänzen → Akte-Metadaten) | §2.4, §5.4 |
| Q20 | Mobile treatment | Desktop v1, mobile basic-read (mutating actions prompt "Auf größerem Bildschirm öffnen") | §3, §7 |
### §4.1 Divergences from inventor recommendations
Three picks diverged from my recommendation. Captured here so future readers (m, the coder) see the *current* design, not the strawman.
- **Q1 — Modular.** Inventor recommended "plug-in widgets". m: "I don't know — generally does not super apply here." Modular is dropped as a goal; the natural decomposition (BuilderCanvas → ProceedingTriplet → EventCard → ScenarioListPanel → PromoteWizard) is documented in §6.2 as build hygiene, not as a load-bearing constraint.
- **Q10 — Event state.** Inventor recommended 4-state (planned / filed / skipped / overdue). m picked 3-state — no `overdue` enum. Rationale (interpreted): `overdue` is derived from `date < today AND state=planned`, not stored; this avoids stale state when the date is edited.
- **Q11 — Promote carry.** Inventor recommended carrying procedural shape + flags + filed-state + notes but **not** placeholder parties/case_number/billing. m picked "everything carries" — placeholder parties come in. Mitigation: Q19's 3-step wizard's step 2 (Parteien ergänzen) gives the user a chance to clean placeholders before commit, so the safety net m wanted on Q11 is folded into Q19.
### §4.2 Inventor picks not formally asked
A few decisions are inventor-set because they're either: (a) implementation details that don't change the architecture, or (b) clean defaults that match existing patterns. Listed here so they're visible; m can flag any.
- **Detailgrad ("Gewählt" / "Alle Optionen") scope**: per-proceeding (matches today's Verfahrensablauf pattern). State in `scenario_proceedings.detailgrad`.
- **Akte picker shape**: flat dropdown sorted by recently-viewed first, with a typeahead filter for case numbers/names. Same shape as today's project picker on /agenda.
- **Notes**: per-event-card (textarea on each card, lazy-loaded). Scenario-level notes also exist (`scenarios.notes text`) for cross-cutting commentary.
- **Read-only shared state UI**: every mutating affordance is disabled (greyed, no click handlers). Watermark "Geteilt von <X> · schreibgeschützt" at the top of the canvas. No "Fork to my workspace" affordance in v1.
- **URL contract**: minimal, view-state only — `?scenario=<id>&mode=<entry>&event=<sequencing_rule_id>` (deep-link to a specific anchor). Filter pills + chip state get URL params *per active entry mode* but explicitly NOT the constellation data (per m's "not every constellation in URL" guidance). The constellation lives in `paliad.scenario_*` tables.
- **Auto-save granularity**: debounced 500ms on every change. Indicator near scenario name: `Gespeichert ✓` (last successful save < 5s ago), `Speichert…` (in flight), `Letzte Speicherung fehlgeschlagen — erneut versuchen` (on error).
- **Soft delete**: archived scenarios stay in DB with `status='archived'`. No hard delete in v1.
- **Audit**: no audit log on scenario edits (they're exploratory). Audit on promote-to-project goes via the existing `projects.audit_log`.
- **Concurrent editing**: single-editor model. Owner is sole editor; shares are read-only. No locking / merge conflict UI needed in v1.
- **Bilingual**: German primary, English via existing `i18n.ts`. Scenario names: user-chosen, any language. Skip reasons + notes: free-text, any language.
---
## §5 Data model deltas
All new tables live in `paliad.*` schema, alongside existing `paliad.projects` / `paliad.deadlines` / `paliad.sequencing_rules`.
### §5.1 New tables
```sql
-- Scenario header. One row per saved scenario (named or scratch).
CREATE TABLE paliad.scenarios (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
owner_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
name text NOT NULL DEFAULT 'Unbenanntes Szenario',
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','archived','promoted')),
origin_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
-- set when scenario was exported from a project
promoted_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
-- set when scenario was promoted to a project
stichtag date NULL,
-- scenario-level default Stichtag; per-triplet overrides take precedence
notes text NULL,
-- free-form scenario-level commentary
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX scenarios_owner_status_idx ON paliad.scenarios(owner_id, status);
CREATE INDEX scenarios_updated_idx ON paliad.scenarios(owner_id, updated_at DESC);
-- One row per proceeding inside a scenario. Multiple per scenario for
-- multi-proceeding constellations. parent_scenario_proceeding_id self-refs
-- for spawned children (CCR child of UPC inf etc.).
CREATE TABLE paliad.scenario_proceedings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_id uuid NOT NULL REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
proceeding_type_id uuid NOT NULL REFERENCES paliad.proceeding_types(id),
primary_party text NULL
CHECK (primary_party IN ('claimant','defendant')),
-- per-proceeding perspective; null = no perspective picked yet
scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb,
-- per-proceeding flags: {with_ccr: true, with_amend: false, …}
parent_scenario_proceeding_id uuid NULL REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
-- self-ref for spawned children (CCR child of UPC inf etc.)
spawn_anchor_event_id uuid NULL REFERENCES paliad.sequencing_rules(id),
-- which rule of the parent caused this spawn (for UI placement)
ordinal int NOT NULL DEFAULT 0,
-- stack order on canvas (top to bottom)
stichtag date NULL,
-- per-proceeding Stichtag override; falls back to scenarios.stichtag
detailgrad text NOT NULL DEFAULT 'selected'
CHECK (detailgrad IN ('selected','all_options')),
appeal_target text NULL,
-- applies_to_target for appeal proceedings; null for non-appeal triplets
collapsed boolean NOT NULL DEFAULT false,
-- user-collapsed triplet header (UI state)
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX scenario_proceedings_scenario_idx ON paliad.scenario_proceedings(scenario_id, ordinal);
CREATE INDEX scenario_proceedings_parent_idx ON paliad.scenario_proceedings(parent_scenario_proceeding_id);
-- One row per event card on the canvas. Captures the card's state +
-- per-card attributes (filed date, skip reason, notes, optional horizon).
-- Most cards are sequencing-rule-backed; free-form events have a null
-- sequencing_rule_id and a non-null procedural_event_id (or text label).
CREATE TABLE paliad.scenario_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_proceeding_id uuid NOT NULL REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
sequencing_rule_id uuid NULL REFERENCES paliad.sequencing_rules(id),
procedural_event_id uuid NULL REFERENCES paliad.procedural_events(id),
-- one of {sequencing_rule_id, procedural_event_id, custom_label} must be set
custom_label text NULL,
-- free-form event name when neither sequencing_rule nor procedural_event apply
state text NOT NULL DEFAULT 'planned'
CHECK (state IN ('planned','filed','skipped')),
actual_date date NULL,
-- set when state='filed'; can also be set for state='planned' (court-set override)
skip_reason text NULL,
-- optional rationale when state='skipped'
notes text NULL,
-- per-card free-form
horizon_optional int NOT NULL DEFAULT 0,
-- per-card "show N more optionals" affordance
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (scenario_proceeding_id, sequencing_rule_id) WHERE sequencing_rule_id IS NOT NULL
);
CREATE INDEX scenario_events_proceeding_idx ON paliad.scenario_events(scenario_proceeding_id);
-- Read-only team shares. Owner is sole editor; shares grant view-only.
CREATE TABLE paliad.scenario_shares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_id uuid NOT NULL REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
shared_with_user_id uuid NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid NOT NULL REFERENCES auth.users(id),
UNIQUE (scenario_id, shared_with_user_id)
);
CREATE INDEX scenario_shares_user_idx ON paliad.scenario_shares(shared_with_user_id);
```
### §5.2 Additions to existing tables
```sql
-- One nullable FK on paliad.projects to track which scenario spawned this
-- project (set on promote-to-project). Auditable origin trail.
ALTER TABLE paliad.projects
ADD COLUMN origin_scenario_id uuid NULL
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
CREATE INDEX projects_origin_scenario_idx ON paliad.projects(origin_scenario_id)
WHERE origin_scenario_id IS NOT NULL;
```
No other changes to existing schema. `paliad.deadlines` continues to be the authoritative source for project-bound actuals; the builder writes to `paliad.deadlines` (not `scenario_events`) when working in Akte mode against a project-backed scenario.
### §5.3 RLS
Same pattern as existing `paliad.projects`:
- `scenarios` readable by `owner_id` OR by users with a matching `scenario_shares.shared_with_user_id` row.
- `scenarios` writable only by `owner_id` (and only when `status != 'promoted'`).
- `scenario_proceedings` + `scenario_events` cascade from scenario visibility.
- `scenario_shares` readable by `shared_with_user_id` or `created_by`; writable only by the scenario owner.
Helper function `paliad.can_see_scenario(scenario_id)` mirrors the existing `paliad.can_see_project(project_id)` shape.
### §5.4 Promote-to-project: data flow
```
[Wizard step 1: Bestätigen]
Read: scenarios + scenario_proceedings + scenario_events
Action: none (read-only summary)
[Wizard step 2: Parteien ergänzen]
Read: scenario_proceedings.scenario_flags (for hints about placeholder party names)
Action: builds an in-memory parties payload (per proceeding, per role)
[Wizard step 3: Akte-Metadaten]
Read: user's clients + litigations + project tree (existing /projects API)
Action: builds an in-memory project metadata payload
[Commit]
Transaction:
1. INSERT into paliad.projects (carrying step-2 + step-3 payloads, + scenario notes)
SET origin_scenario_id = <scenario.id>
2. INSERT into paliad.project_parties from step-2 payload
3. For each scenario_proceeding (depth-first, parent before child):
a. INSERT scenario_flags as projects.scenario_flags (parent-level only;
children become sub-projects via parent_project_id)
b. For each filed scenario_event: INSERT paliad.deadlines row with
status='done', completed_at=actual_date, audit_reason='via Litigation Builder promotion'
c. For each planned scenario_event: INSERT paliad.deadlines row with
status='open', due_date=computed (or actual_date override)
d. Skipped events: not inserted (no deadline row)
4. UPDATE paliad.scenarios SET status='promoted', promoted_project_id=<new>
5. Navigate to /projects/<new>
```
The deadlines write uses existing `POST /api/projects/{id}/deadlines/bulk` semantics under the hood no new bulk-deadline-from-scenario endpoint needed.
---
## §6 Modular boundaries (light)
m said modular "doesn't super apply" dropped as a load-bearing goal. The natural decomposition below is build-hygiene documentation, not a constraint the coder must enforce.
### §6.1 Front-end components
| Component | File | Responsibility |
|---|---|---|
| `BuilderCanvas` | `frontend/src/components/BuilderCanvas.tsx` | Root render of the builder. Receives the active scenario, renders triplet stack + cold-open empty state |
| `ProceedingTriplet` | `frontend/src/components/ProceedingTriplet.tsx` | One proceeding's render: header strip (jurisdiction + name + perspective + Detailgrad + collapse + remove) + flag strip + 3 columns + spawn child triplets recursively |
| `EventCard` | `frontend/src/components/EventCard.tsx` | One card in a column lane. State / date / optional-horizon / notes affordances |
| `ScenarioFlagsStrip` | `frontend/src/components/ScenarioFlagsStrip.tsx` | Per-triplet flag toggles. Reads scenario_flag_catalog, applies to scenario_proceedings.scenario_flags |
| `AddProceedingPicker` | `frontend/src/components/AddProceedingPicker.tsx` | Inline picker triggered by `+ Verfahren hinzufügen`. Forum chip row Verfahren chip row `Hinzufügen` |
| `ScenarioListPanel` | `frontend/src/components/ScenarioListPanel.tsx` | Side panel: Aktiv / Geteilt / Promoted / Archiviert buckets + new-scenario CTA |
| `PromoteToProjectWizard` | `frontend/src/components/PromoteToProjectWizard.tsx` | 3-step modal: Bestätigen / Parteien / Metadaten |
| `PageHeaderControls` | `frontend/src/components/PageHeaderControls.tsx` | Scenario picker + Benennen/Teilen/Promote buttons + Akte picker + Stichtag input |
| `EntryModeChrome` | `frontend/src/components/EntryModeChrome.tsx` | Cold-open / event-triggered / Akte mode radio; ephemeral UI affordance that fades into canvas state |
### §6.2 Client TS files
Mirror the React-ish component split:
- `frontend/src/client/builder.ts` root orchestrator (auto-save loop, URL state, mode routing, scenario fetch)
- `frontend/src/client/builder-scenario.ts` scenario CRUD against `/api/scenarios`
- `frontend/src/client/builder-event-card.ts` per-card state machine + optional-horizon control
- `frontend/src/client/builder-promote-wizard.ts` 3-step wizard state machine
- `frontend/src/client/builder-search.ts` universal search (events + scenarios + Akten)
- `frontend/src/client/builder-shares.ts` share-with-team UI
### §6.3 Backend services + routes
| Service | File | Endpoints |
|---|---|---|
| `ScenarioService` | `internal/services/scenario_service.go` | List / Get / Create / Update / Archive / Promote |
| `ScenarioProceedingService` | `internal/services/scenario_proceeding_service.go` | Add / Remove / Update (flags, perspective, ordinal, detailgrad) |
| `ScenarioEventService` | `internal/services/scenario_event_service.go` | List / Update state / Set date / Set notes / Set horizon |
| `ScenarioShareService` | `internal/services/scenario_share_service.go` | List / Add / Remove shares |
| `ScenarioPromoteService` | `internal/services/scenario_promote_service.go` | Wizard-driven transactional promote |
Routes (added under existing API namespace):
```
GET /api/scenarios — list user's scenarios (filtered by status)
POST /api/scenarios — create new scenario
GET /api/scenarios/{id} — get scenario + proceedings + events (deep)
PATCH /api/scenarios/{id} — update name / stichtag / notes / status
DELETE /api/scenarios/{id} — archive (soft delete; status='archived')
POST /api/scenarios/{id}/proceedings — add proceeding to scenario
PATCH /api/scenarios/{id}/proceedings/{pid} — update flags / perspective / ordinal / detailgrad
DELETE /api/scenarios/{id}/proceedings/{pid} — remove proceeding (cascades to events)
PATCH /api/scenarios/{id}/events/{eid} — update state / date / notes / horizon
POST /api/scenarios/{id}/shares — share with user (read-only)
DELETE /api/scenarios/{id}/shares/{sid} — revoke share
POST /api/scenarios/{id}/promote — promote to project (3-step wizard payload)
POST /api/scenarios/from-project/{project_id} — export project to a new scenario (what-if)
GET /api/search — universal search (events + scenarios + Akten)
```
Existing endpoints used unchanged:
- `GET /api/tools/fristenrechner/search?kind=events` for the events corpus.
- `GET /api/projects` Akte picker source.
- `POST /api/projects/{id}/deadlines/bulk` promotion writes deadlines through this.
- `PATCH /api/projects/{id}/scenario-flags` Akte-mode flag sync.
---
## §7 Migration plan from current live shape
Current live (`/tools/procedures` on main @ `ed3c5d1`) = cronus's U0-U4 4-tab catalog. Migration is a 6-slice train, every slice ships visibly. No feature flag (m's pattern preference per #152 Q7).
### §7.1 Slice train
| Slice | What ships | DB | Visible to user |
|---|---|---|---|
| **B0 — Scenario DB foundation** | New tables (scenarios + scenario_proceedings + scenario_events + scenario_shares) + RLS + minimal API (list / create / get). Scenarios writable from a developer-only test route at first. | Mig #N (new tables + RLS + `paliad.projects.origin_scenario_id`) | No user-visible change. |
| **B1 — Builder shell + cold-open mode** | New `/tools/procedures` page replaces the 4-tab catalog. Renders: page header (scenario picker + Akte picker + Stichtag + search), entry-mode radio (cold-open active), filter strip, empty canvas + "Neues Szenario starten" CTA + recent list. Add-proceeding picker works; first triplet renders with the existing Verfahrensablauf-core calc. Auto-save active scenario. Side panel "Meine Szenarien" with Aktiv bucket only. | | New page visible. Single triplet works end-to-end. |
| **B2 — Multi-triplet + spawn nesting + per-event state** | Vertical multi-triplet stack with `+ Verfahren hinzufügen`. Per-triplet perspective + flag strip. Spawn child triplets render inline. Event cards get the 3-state machine (planned/filed/skipped) + date editor + per-card optional horizon chip. Page-header Stichtag drives default dates. | | Full scenario builder works without Akte integration. |
| **B3 — Event-triggered mode + universal search** | "Ereignis" entry mode wires the search box to land on a single-triplet anchored view (scratch scenario). Universal search returns events + scenarios + Akten with type-scoped result groups. Filter pills (forum/proc/party/kind) reset on mode switch. | | Event lookup works. |
| **B4 — Akte mode + project-backed scenarios** | "Aus Akte" entry mode + page-header Akte picker. Loads project state into the builder (proceeding + perspective + scenario_flags + deadlines actuals). Akte-backed scenarios write through to `paliad.deadlines` + `paliad.projects.scenario_flags`; non-Akte scenarios write to `paliad.scenario_events`. Cross-surface scenario-flag-changed event listener reused from #152 T3. | | Akte integration works end-to-end. |
| **B5 — Share + Promote-to-project wizard** | "Teilen" button + user picker + share row. "Geteilt mit mir" bucket in side panel. "Als Projekt anlegen" opens the 3-step wizard (Bestätigen Parteien ergänzen Akte-Metadaten). Successful commit creates project + cascades deadlines + sets `origin_scenario_id`, navigates to /projects/{id}. "Promoted" bucket in side panel. | | Sharing + promotion work. |
| **B6 — Mobile basic-read + cleanup + i18n polish** | Mobile (<640px) shows scenarios + cards read-only; mutating affordances prompt "Auf größerem Bildschirm öffnen". Cleanup: delete dead U0-U4 catalog code (4-tab control, legacy `verfahrensablauf.ts`, etc.). All i18n keys finalised (DE + EN). | | Mobile works; codebase cleaner. |
### §7.2 Why this train shape
- **B0 is DB-only**. The schema can land independently and be exercised via test routes / Supabase MCP before any UI sees it. Keeps mig risk isolated.
- **B1-B2 are the MVP**. After B2, a user can build and save a multi-proceeding scenario fully kontextfrei. That alone replaces 60% of today's catalog use.
- **B3 adds the lookup path**. After B3, "what's next after Klageerwiderung?" works without saving.
- **B4 makes it real**. Akte integration is the load-bearing piece for daily use; ships once the foundation is stable.
- **B5 unlocks team value**. Sharing + promotion are the difference between "personal tool" and "team tool". Ship after the core works.
- **B6 is cleanup**. Mobile read + dead code removal land last to avoid coupling to in-flight features.
### §7.3 What stays unchanged
- URL `/tools/procedures` keeps it (the new builder lives there).
- Sidebar entry "Verfahren & Fristen" keeps it.
- cmd-K palette keeps it.
- `/tools/fristenrechner` + `/tools/verfahrensablauf` legacy redirects (from cronus's U4) stay alive: 301 `/tools/procedures` (the builder).
- `pkg/litigationplanner.CalculateRule` untouched.
- `/admin/procedural-events` untouched.
- `/projects/{id}` Verlauf untouched (new "Im Builder öffnen" button is the only addition).
### §7.4 Cleanup at B6
Dead code to delete (verify with grep before deletion):
- `frontend/src/components/VerfahrensablaufBody.tsx` (replaced by ProceedingTriplet)
- `frontend/src/client/verfahrensablauf.ts` (replaced by builder.ts orchestration)
- `frontend/src/client/views/verfahrensablauf-state.ts` (replaced by scenario-backed state)
- `frontend/src/client/views/verfahrensablauf-state.test.ts`
- `frontend/src/client/verfahrensablauf-detail-mode.ts` (replaced by per-triplet Detailgrad)
- Existing scratch tab content in `frontend/src/client/procedures.ts` (4-tab toggling logic, mode routing)
**Kept**:
- `frontend/src/client/views/verfahrensablauf-core.ts` (calculation engine; reused by EventCard + ProceedingTriplet)
- Legacy URL redirects in Go (`/tools/fristenrechner` + `/tools/verfahrensablauf` `/tools/procedures`)
---
## §8 Open follow-ups (out of scope for v1)
Tracked for v1.1 / future tickets:
- **Cross-proceeding peer triggers** (UPC-inf judgment EPA opp choice deadline). New `paliad.scenario_event_links` table. UI: trigger-picker chip on event cards.
- **DE / EPA / DPMA full expansion**. v1 supports EPA + DPMA proceedings at the data layer (calc engine handles them), but the spawn flags and CCR-style nestings are UPC-specific. Other jurisdictions get proper coverage in v1.1.
- **Scenario versioning / snapshots**. m's Q8 alternative ("versioned snapshots") deferred. Add when scenarios start driving client briefings.
- **Multi-user concurrent editing**. Out of scope. Single-editor model with read-only shares is sufficient until usage shows otherwise.
- **Fork-a-shared-scenario**. Read-only sharing in v1 doesn't expose "fork into my workspace". Add when team usage demands it.
- **Comments on scenarios / event cards**. Out of scope (separate ticket).
- **PDF export of a scenario for client briefings**. Out of scope.
- **Mobile-parity edits**. v1.1 full mobile interaction loop.
- **Audit log on scenario edits**. Out of scope (exploratory data).
- **Cross-scenario comparison view**. ("Compare planned vs actual" lives on the project page via promote-then-compare; explicit comparison tool is v2.)
---
## §9 Synthesis links
- **mBrian**: file as `[synthesis]` linked `triggered_by` t-paliad-339; `related_to` atlas's reverted tracker design, cronus's unified-procedural-events-tool design, atlas's deadline-system-revision.
- **Cross-refs in this repo**: `docs/design-procedures-workflow-tracker-2026-05-27.md` (atlas, reverted), `docs/design-unified-procedural-events-tool-2026-05-27.md` (cronus, live), `docs/design-deadline-system-revision-2026-05-27.md` (atlas Phase 2), `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26).
- **Gitea**: m/paliad#153 (this PRD), m/paliad#152 (atlas's tracker, reverted), m/paliad#151 (cronus U0-U4 shipped), m/paliad#149 (atlas Phase 2 in flight).
- **Coder phase** (deferred per inventor SKILL): runs after m ratifies this PRD. Slice ordering per §7.1. NOT edison (parked at DESIGN READY FOR REVIEW). NOT atlas (just-rejected tracker framing bias). NOT cronus (parked on Fristenrechner inventor branch). A pattern-fluent Sonnet coder picks up B0 first.
---
## §10 Coder hand-off notes
(Pre-emptive for whoever picks up B0.)
- **Migration number**: check `internal/db/migrations/` for the max slot at coder shift start. Two recent migrations (curie's t-paliad-336, ritchie's t-paliad-149 P0) are in flight; coordinate via paliadin/head before claiming a slot.
- **Akte integration nuance**: when the builder is in Akte mode and the scenario is project-backed, writes flow to `paliad.deadlines` / `paliad.projects.scenario_flags` instead of `paliad.scenario_*` tables the scenario row itself just records the canvas view-state (which triplets are visible, ordinal, collapsed state, per-card horizon). This dual-write rule is the load-bearing complexity of B4; design tests for it explicitly.
- **Auto-save throttling**: 500ms debounce per change. Avoid PATCH-per-keystroke on notes textareas (use blur-trigger + 2s debounce there).
- **Search performance**: universal search (events + scenarios + Akten) needs to stay snappy. Events corpus is ~3000 rows; scenarios/Akten are per-user. Use existing trgm indexes; avoid joining across all three for ranking.
- **B5 transactional promotion**: do the wizard's commit in a single Postgres transaction. If any of (project insert / parties / deadlines / scenario status update) fails, roll back atomically. No partial promotions.
- **Mobile rendering**: B6 is meant to be cheap. Column-triplet CSS grid that collapses to single-column at `@media (max-width: 640px)`. Mutating affordances get `pointer-events: none` + a click-handler that surfaces the "Auf größerem Bildschirm öffnen" toast keeps the desktop interaction code paths unchanged.
- **i18n keys**: every user-facing string gets `data-i18n` from B1. Don't accumulate i18n debt across slices.

280
exports/gen-deadline-list.py Executable file
View File

@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""Generate a markdown deadline-list export for UPC PA training (work/head delegation #2572).
Sorts by proceeding-type display_order then sequence_order. Sections by proceeding.
t-paliad-348 / yoUPC#178 update: matches the engine's `IncludeOptional=false`
default (`pkg/litigationplanner/engine.go`). Optional rules (priority='optional')
are SUPPRESSED by default so the manuscript shows the same "naked proceeding
backbone" the UI now renders. Pass `--include-optional` to opt back in for an
exhaustive catalog dump.
Usage:
uv run exports/gen-deadline-list.py [--include-optional] [-o OUT]
"""
# /// script
# requires-python = ">=3.10"
# dependencies = ["psycopg2-binary"]
# ///
import argparse
import os
import sys
from datetime import date
from pathlib import Path
import psycopg2
import psycopg2.extras
DSN = os.environ.get(
"PALIAD_DEADLINE_EXPORT_DSN",
"postgres://postgres:rpsak3yf4lu1izgefx9p9xweg3qroojw@100.99.98.201:11833/postgres?sslmode=disable",
)
# `priority` filter is wired at the SQL level (not post-filter in Python) so
# the row counter in the markdown header reflects what's actually in the
# manuscript — matching what the lawyer sees on /tools/procedures.
SQL_TEMPLATE = """
SELECT
pt.code AS pt_code,
pt.display_order,
COALESCE(pt.name_en, pt.name) AS pt_label_en,
pt.name AS pt_label_de,
COALESCE(pe.name_en, pe.name) AS event_en,
pe.name AS event_de,
sr.duration_value,
sr.duration_unit,
sr.timing,
sr.alt_duration_value,
sr.alt_duration_unit,
sr.combine_op,
sr.rule_code,
COALESCE(te.name, te.name_de) AS trigger_label,
te.code AS trigger_code,
sr.primary_party,
sr.is_court_set,
sr.is_spawn,
sr.priority,
sr.deadline_notes_en,
sr.deadline_notes,
sr.condition_expr,
sr.sequence_order
FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
LEFT JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
LEFT JOIN paliad.trigger_events te ON te.id = sr.trigger_event_id
WHERE sr.lifecycle_state = 'published'
AND sr.is_active = true
AND pt.id IS NOT NULL
{priority_filter}
ORDER BY pt.display_order NULLS LAST, pt.code, sr.sequence_order NULLS LAST, sr.rule_code, pe.name;
"""
def format_frist(duration_value, duration_unit, timing, alt_value, alt_unit, combine_op):
"""Format the deadline duration cleanly."""
if duration_value is None or duration_unit is None:
return ""
unit_map = {
"days": "d",
"weeks": "w",
"months": "M",
"years": "y",
"calendar_days": "CD",
"working_days": "WD",
}
unit = unit_map.get(duration_unit, duration_unit)
main = f"{duration_value} {unit}"
if alt_value is not None and alt_unit is not None:
alt_unit_short = unit_map.get(alt_unit, alt_unit)
op = combine_op or "or"
main = f"{main} {op} {alt_value} {alt_unit_short}"
if timing == "before":
main = f"{main} before"
elif timing == "after":
main = f"{main} after"
return main
def format_party(primary_party, is_court_set):
if is_court_set:
return "court-set"
if primary_party == "claimant":
return "claimant"
if primary_party == "defendant":
return "defendant"
if primary_party == "both":
return "either"
if primary_party == "court":
return "court"
return primary_party or ""
def detect_r94(notes_en, notes_de):
"""Flag R.9.4 non-extendable from notes text (heuristic — no DB field)."""
blobs = " ".join(filter(None, [notes_en or "", notes_de or ""])).lower()
if "r.9.4" in blobs or "r 9.4" in blobs or "r9.4" in blobs:
return ""
if "non-extendable" in blobs or "nicht verlängerbar" in blobs or "nicht verlaengerbar" in blobs:
return ""
return ""
def conditional_marker(condition_expr):
if condition_expr in (None, "", {}):
return ""
# condition_expr is JSONB → returns dict
if isinstance(condition_expr, dict):
if "flag" in condition_expr:
return f"if `{condition_expr['flag']}`"
if condition_expr.get("op") == "and" and "args" in condition_expr:
flags = [a.get("flag", "?") for a in condition_expr["args"]]
return "if " + " & ".join(f"`{f}`" for f in flags)
if condition_expr.get("op") == "or" and "args" in condition_expr:
flags = [a.get("flag", "?") for a in condition_expr["args"]]
return "if " + " | ".join(f"`{f}`" for f in flags)
return "cond"
def md_escape(s):
if s is None:
return ""
return str(s).replace("|", "\\|").replace("\n", " ")
def render(rows, *, include_optional: bool, generated_for: str) -> str:
by_pt = {}
for r in rows:
key = (r["display_order"] or 9999, r["pt_code"], r["pt_label_de"], r["pt_label_en"])
by_pt.setdefault(key, []).append(r)
out = []
today = date.today().isoformat()
out.append(f"# UPC + DE/EP Deadline Catalog — Stand {today}")
out.append("")
out.append(f"Source: `paliad.sequencing_rules` (lifecycle_state=published, is_active=true).")
out.append(f"Generated for {generated_for}. {len(rows)} rules across {len(by_pt)} proceedings.")
if include_optional:
out.append("")
out.append(
"**Mode:** `--include-optional` — every published rule, including "
"`priority='optional'` rules suppressed by the engine's default "
"(`IncludeOptional=false`). This is the exhaustive catalog dump."
)
else:
out.append("")
out.append(
"**Mode:** default — matches the engine's `IncludeOptional=false` "
"behaviour (pkg/litigationplanner/engine.go). `priority='optional'` "
"rules are suppressed; the manuscript shows only the mandatory "
"backbone the lawyer sees by default on /tools/procedures. "
"Re-run with `--include-optional` for the full catalog. "
"(t-paliad-348 / yoUPC#178)"
)
out.append("")
out.append("**Spalten:**")
out.append("- **Phase/Event** = procedural event (German primary)")
out.append("- **Frist** = duration + timing (`d` days, `w` weeks, `M` months, `CD` calendar days, `WD` working days; `before` = relative to anchor)")
out.append("- **Rule** = legal source (RoP / § ZPO / § PatG / Art. EPÜ)")
out.append("- **Anchor** = trigger event the deadline runs from")
out.append("- **Seite** = filing party (claimant / defendant / either / court-set)")
out.append("- **Priorität** = mandatory / recommended / optional / informational (only when `--include-optional`)")
out.append("- **R.9.4** = ✗ marked non-extendable in notes (heuristic — confirm against rule text)")
out.append("- **Bedingung** = scenario flag(s) that must be set for the rule to fire (blank = always)")
out.append("")
out.append("---")
out.append("")
for (order, pt_code, pt_de, pt_en) in sorted(by_pt.keys()):
prules = by_pt[(order, pt_code, pt_de, pt_en)]
out.append(f"## {pt_de} · `{pt_code}`")
out.append("")
if pt_en and pt_en != pt_de:
out.append(f"*{pt_en}*")
out.append("")
if include_optional:
out.append("| # | Phase / Event | Frist | Rule | Anchor | Seite | Priorität | R.9.4 | Bedingung |")
out.append("|---:|---|---|---|---|---|---|:---:|---|")
else:
out.append("| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |")
out.append("|---:|---|---|---|---|---|:---:|---|")
for i, r in enumerate(prules, 1):
event = md_escape(r["event_de"] or r["event_en"] or "")
frist = md_escape(
format_frist(
r["duration_value"], r["duration_unit"], r["timing"],
r["alt_duration_value"], r["alt_duration_unit"], r["combine_op"],
)
)
rule = md_escape(r["rule_code"] or "")
anchor = md_escape(r["trigger_label"] or "")
party = format_party(r["primary_party"], r["is_court_set"])
r94 = detect_r94(r["deadline_notes_en"], r["deadline_notes"])
cond = md_escape(conditional_marker(r["condition_expr"]))
spawn_marker = "" if r["is_spawn"] else ""
if include_optional:
priority = md_escape(r["priority"] or "")
out.append(
f"| {i} | {event}{spawn_marker} | {frist} | {rule} | {anchor} | {party} | {priority} | {r94} | {cond} |"
)
else:
out.append(
f"| {i} | {event}{spawn_marker} | {frist} | {rule} | {anchor} | {party} | {r94} | {cond} |"
)
out.append("")
out.append("---")
out.append("")
out.append("**Lesehilfe:**")
out.append("- ⤴ Spawn-Marker: event opens a sub-proceeding (e.g. CCR forks revocation track)")
out.append("- `with_ccr` = Widerklage auf Nichtigkeit gefilt | `with_amend` = Patentänderungsantrag | `with_cci` = Widerklage auf Verletzung (in rev.cfi)")
out.append("- Catalog ist work-in-progress: 7 compound-name rules + Patentänderung-Duplikation noch in m's split-review backlog (m/paliad#149).")
return "\n".join(out)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--include-optional",
action="store_true",
help="Include priority='optional' rules. Default false matches the engine's IncludeOptional=false default.",
)
parser.add_argument(
"-o",
"--out",
default="exports/upc-deadlines-2026-05-28.md",
help="Output path (relative to repo root).",
)
parser.add_argument(
"--generated-for",
default="PA-Schulung 2026-05-28",
help="Free-text label rendered in the markdown header.",
)
args = parser.parse_args()
priority_filter = "" if args.include_optional else "AND sr.priority != 'optional'"
sql = SQL_TEMPLATE.format(priority_filter=priority_filter)
conn = psycopg2.connect(DSN)
try:
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
cur.execute(sql)
rows = cur.fetchall()
finally:
conn.close()
md = render(rows, include_optional=args.include_optional, generated_for=args.generated_for)
# Resolve out path relative to the repo root (= the script's grandparent).
out_path = Path(args.out)
if not out_path.is_absolute():
out_path = Path(__file__).resolve().parent.parent / out_path
out_path.parent.mkdir(parents=True, exist_ok=True)
out_path.write_text(md)
n_pt = len({(r["display_order"] or 9999, r["pt_code"]) for r in rows})
print(
f"WROTE {out_path} ({len(rows)} rules, {n_pt} proceedings, "
f"include_optional={args.include_optional})"
)
if __name__ == "__main__":
sys.exit(main())

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

View File

@@ -0,0 +1,378 @@
# UPC + DE/EP Deadline Catalog — Stand 2026-05-28
Source: `paliad.sequencing_rules` (lifecycle_state=published, is_active=true).
Generated for PA-Schulung 2026-05-28. 178 rules across 25 proceedings.
**Mode:** default — matches the engine's `IncludeOptional=false` behaviour (pkg/litigationplanner/engine.go). `priority='optional'` rules are suppressed; the manuscript shows only the mandatory backbone the lawyer sees by default on /tools/procedures. Re-run with `--include-optional` for the full catalog. (t-paliad-348 / yoUPC#178)
**Spalten:**
- **Phase/Event** = procedural event (German primary)
- **Frist** = duration + timing (`d` days, `w` weeks, `M` months, `CD` calendar days, `WD` working days; `before` = relative to anchor)
- **Rule** = legal source (RoP / § ZPO / § PatG / Art. EPÜ)
- **Anchor** = trigger event the deadline runs from
- **Seite** = filing party (claimant / defendant / either / court-set)
- **Priorität** = mandatory / recommended / optional / informational (only when `--include-optional`)
- **R.9.4** = ✗ marked non-extendable in notes (heuristic — confirm against rule text)
- **Bedingung** = scenario flag(s) that must be set for the rule to fire (blank = always)
---
## Verletzungsverfahren · `upc.inf.cfi`
*Infringement Action*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Klageerhebung | 0 M after | RoP.013.1 | | claimant | | |
| 2 | Klageerwiderung | 3 M after | RoP.023 | | defendant | | |
| 3 | Replik | 2 M or 2 M after | RoP.029.b | | claimant | | if `with_ccr` |
| 4 | Duplik | 1 M or 2 M after | RoP.029.c | | defendant | | if `with_ccr` |
| 5 | Erwiderung auf Nichtigkeitswiderklage | 2 M after | RoP.029.a | | claimant | | if `with_ccr` |
| 6 | Replik auf Erwiderung zur Nichtigkeitswiderklage | 2 M after | RoP.029.d | | defendant | | if `with_ccr` |
| 7 | Duplik auf Replik zur Erwiderung Nichtigkeitswiderklage | 1 M after | RoP.029.e | | claimant | | if `with_ccr` |
| 8 | Antrag auf Patentänderung | 2 M after | RoP.030.1 | | claimant | | if `with_ccr` & `with_amend` |
| 9 | Erwiderung auf Patentänderungsantrag | 2 M after | RoP.032.1 | | defendant | | if `with_ccr` & `with_amend` |
| 10 | Replik auf Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | | claimant | | if `with_ccr` & `with_amend` |
| 11 | Duplik auf Replik zum Patentänderungsantrag | 1 M after | RoP.032.3 | | defendant | | if `with_ccr` & `with_amend` |
| 12 | Zwischenanhörung | 0 M after | RoP.105 | | court-set | | |
| 13 | Mitteilung Dolmetscherkosten | 2 w before | RoP.109.4 | Oral hearing | court | | |
| 14 | Übersetzungen einreichen | 2 w after | RoP.109.5 | | either | | |
| 15 | Mündliche Verhandlung | 0 M after | RoP.112 | | court-set | | |
| 16 | Entscheidung | 0 M after | RoP.118.1 | | court-set | | |
| 17 | Duplik zur Replik auf die Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | Reply to the Defence to an Application to amend the patent | defendant | | if `with_ccr` & `with_amend` |
| 18 | Einreichung von Übersetzungen von Schriftstücken | 1 M after | RoP.007.4 | Order of the judge-rapporteur to lodge translations | either | | |
| 19 | Antrag auf Simultanübersetzung | 1 M before | RoP.109.5 | Oral hearing | either | | |
| 20 | Antrag auf Folgemaßnahmen aus einer rechtskräftigen Validitätsentscheidung | 2 M after | RoP.118.4 | Final decision of the central division, Court of Appeal or EPO on the validity of the patent | either | | if `with_ccr` |
| 21 | Antrag auf Überprüfung einer verfahrensleitenden Anordnung | 15 d after | RoP.333 | Case management order (Service) | either | | |
| 22 | Mängelbeseitigung / Einreichung schriftlicher Stellungnahme | 14 d after | RoP.019 | Preliminary Objection | either | | |
| 23 | Mängelbeseitigung / Zahlung | 14 d after | RoP.016 | Notification by the Registry to correct deficiencies | either | | |
| 24 | Antrag auf Verweisung an die Zentralkammer | 10 d after | RoP.323 | Information by the Court not to approve Application to use the patent's language as language of the proceedings | either | | |
| 25 | Mitteilung über Beauftragung eines Dolmetschers auf Kosten der Partei | 2 w before | RoP.109.5 | Oral hearing | either | | |
| 26 | Klärung von Übersetzungsfragen | 2 w after | RoP.109 | Summons to Oral Hearing | court | | |
| 27 | Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit | 14 d after | RoP.262.2 | Opponent Submission | either | | |
| 28 | Wiedereinsetzungsantrag (UPC R.320) | 2 M after | RoP.320 | Removal of obstacle (UPC R.320) | either | | |
## Verletzungsverfahren (LG) · `de.inf.lg`
*Infringement (Regional Court)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Klageerhebung | 0 M after | § 253 ZPO | | claimant | | |
| 2 | Anzeige der Verteidigungsbereitschaft | 2 w after | § 276 ZPO | | defendant | | |
| 3 | Klageerwiderung | 6 w after | § 276 ZPO | | court-set | | |
| 4 | Replik | 4 w after | § 282 ZPO | | court-set | | |
| 5 | Duplik | 4 w after | § 282 ZPO | | court-set | | |
| 6 | Haupttermin | 0 M after | § 279 ZPO | | court-set | | |
| 7 | Urteil | 0 M after | § 300 ZPO | | court-set | | |
| 8 | Berufungsfrist | 1 M after | § 517 ZPO | | either | | |
| 9 | Berufungsbegründung | 2 M after | § 520 ZPO | | either | | |
| 10 | Wiedereinsetzungsantrag (§ 233 ZPO) | 2 w after | § 233 ZPO | Removal of obstacle (ZPO §233) | — | | |
| 11 | Einspruch gegen Versäumnisurteil (§ 339 ZPO) | 2 w after | § 339 ZPO | Service of default judgment | — | | |
| 12 | Schriftsatznachreichung (§ 296a ZPO) | 3 w after | § 296a ZPO | End of oral hearing | — | | |
## Nichtigkeitsverfahren · `upc.rev.cfi`
*Revocation Action*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Nichtigkeitsklage | 0 M after | RoP.044 | | claimant | | |
| 2 | Klageerwiderung | 2 M after | RoP.049.1 | | defendant | | |
| 3 | Antrag auf Patentänderung | 0 M after | RoP.049.2.a | | defendant | | if `with_amend` |
| 4 | Verletzungswiderklage | 0 M after | RoP.049.2.b | | defendant | | if `with_cci` |
| 5 | Replik | 2 M after | RoP.051 | | claimant | | |
| 6 | Erwiderung auf Patentänderungsantrag | 2 M after | RoP.043.3 | | claimant | | if `with_amend` |
| 7 | Erwiderung auf Verletzungswiderklage | 2 M after | RoP.056.1 | | claimant | | if `with_cci` |
| 8 | Duplik | 1 M after | RoP.052 | | defendant | | |
| 9 | Replik auf Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | | defendant | | if `with_amend` |
| 10 | Replik auf Erwiderung zur Verletzungswiderklage | 1 M after | RoP.056.3 | | defendant | | if `with_cci` |
| 11 | Duplik auf Replik zum Patentänderungsantrag | 1 M after | RoP.032.3 | | claimant | | if `with_amend` |
| 12 | Duplik auf Replik zur Erwiderung Verletzungswiderklage | 1 M after | RoP.056.4 | | claimant | | if `with_cci` |
| 13 | Zwischenanhörung | 0 M after | RoP.105 | | court-set | | |
| 14 | Mündliche Verhandlung | 0 M after | RoP.112 | | court-set | | |
| 15 | Entscheidung | 0 M after | RoP.118.3 | | court-set | | |
| 16 | Duplik zur Replik auf die Erwiderung zur Nichtigkeitsklage | 1 M after | RoP.052 | Reply to the Defence to revocation | — | | |
| 17 | Verletzungswiderklage | 2 M after | RoP.053 | Statement for Revocation | — | | |
| 18 | Antrag auf Patentänderung | 2 M after | RoP.050 | Statement for Revocation | — | | |
## Nichtigkeitsverfahren (BPatG) · `de.null.bpatg`
*Nullity (Federal Patent Court)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Nichtigkeitsklage | 0 M after | § 81 PatG | | claimant | | |
| 2 | Klageerwiderung | 2 M after | § 82 Abs. 3 PatG | | defendant | | |
| 3 | Replik | 2 M after | § 83 PatG | | claimant | | |
| 4 | Hinweisbeschluss | 0 M after | § 83 PatG | | court-set | | |
| 5 | Stellungnahme zum Hinweisbeschluss | 0 M after | § 83 PatG | | either | | |
| 6 | Duplik | 1 M after | § 83 PatG | | defendant | | |
| 7 | Mündliche Verhandlung | 0 M after | § 80 PatG | | court-set | | |
| 8 | Urteil | 0 M after | § 84 PatG | | court-set | | |
| 9 | Berufungsfrist | 1 M after | § 110 PatG | | either | | |
| 10 | Berufungsbegründung | 3 M after | § 111 PatG | | either | | |
| 11 | Wiedereinsetzungsantrag (§ 123 PatG) | 2 M after | § 123 PatG | Removal of obstacle (PatG §123) | — | | |
## Einspruchsverfahren · `epa.opp.opd`
*Opposition Proceedings*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Veröffentlichung der Erteilung | 0 M after | Art. 97 EPÜ | | either | | |
| 2 | Einspruchsfrist | 9 M after | Art. 99 EPÜ | | either | | |
| 3 | Erwiderung des Patentinhabers | 4 M after | R. 79(1) EPÜ | | court-set | | |
| 4 | Entscheidung | 0 M after | Art. 102 EPÜ | | court-set | | |
| 5 | Beschwerdefrist | 2 M after | Art. 108 EPÜ | | either | | |
| 6 | Beschwerdebegründung | 4 M after | Art. 108 EPÜ | | either | | |
| 7 | Stellungnahme weiterer Beteiligter | 0 M after | R. 79 EPÜ | | either | | |
| 8 | Eingaben vor mündl. Verhandlung | 0 M after | R. 116 EPÜ | | either | | |
| 9 | Wiedereinsetzungsantrag (Art. 122 EPÜ) | 2 M after | Art. 122 EPÜ | Removal of obstacle (EPC Art.122) | — | | |
## Beschwerdeverfahren · `epa.opp.boa`
*Appeal Proceedings*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Zustellung der Beschwerdeentscheidung | 0 M after | R. 124 EPÜ | | either | | |
| 2 | Beschwerdeeinlegung | 2 M after | Art. 108 EPÜ | | either | | |
| 3 | Beschwerdebegründung | 4 M after | Art. 108 EPÜ | | either | | |
| 4 | Beschwerdeerwiderung | 4 M after | RPBA Art. 12 | | either | | |
| 5 | Mündliche Verhandlung | 0 M after | Art. 116 EPÜ | | court-set | | |
| 6 | Entscheidung | 0 M after | Art. 111 EPÜ | | court-set | | |
| 7 | Eingaben vor mündl. Verhandlung | 0 M after | R. 116 EPÜ | | either | | |
| 8 | Antrag auf Überprüfung | 2 M after | Art. 112a EPÜ | | either | | |
## Einspruchsverfahren DPMA · `dpma.opp.dpma`
*Opposition DPMA*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Veröffentlichung der Erteilung | 0 M after | § 58 PatG | | either | | |
| 2 | Einspruchsfrist | 9 M after | § 59 PatG | | either | | |
| 3 | Erwiderung des Patentinhabers | 4 M after | § 59(2) PatG | | court-set | | |
| 4 | DPMA-Entscheidung | 0 M after | § 61 PatG | | court-set | | |
| 5 | Wiedereinsetzungsantrag (DPMA) | 2 M after | § 123 PatG | Removal of obstacle (DPMA, PatG §123) | — | | |
## Berufungsverfahren · `upc.apl.merits`
*Appeal*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Berufungseinlegung | 2 M after | RoP.224.1.a | | either | | |
| 2 | Berufungsbegründung | 4 M after | RoP.224.2.a | | either | | |
| 3 | Berufungserwiderung | 3 M after | RoP.235.1 | | either | | |
| 4 | Mündliche Verhandlung | 0 M after | RoP.240 | | court-set | | |
| 5 | Entscheidung | 0 M after | RoP.235.4 | | court-set | | |
| 6 | Anschlussberufung | 3 M after | RoP.237 | | either | | |
| 7 | Erwiderung Anschlussberufung | 2 M after | RoP.238.1 | | either | | |
| 8 | Berufungsschrift gegen eine in Regel 220.1(a) und (b) genannte Entscheidung | 2 M after | RoP.224.1(a) | Decision referred to in Rule 220.1(a) and (b) | — | | |
| 9 | Berufungsbegründung gegen eine in Regel 220.1(a) und (b) genannte Entscheidung | 4 M after | RoP.224.1(a) | Decision referred to in Rule 220.1(a) and (b) | — | | |
| 10 | Anfechtung einer Entscheidung über die Verwerfung der Berufung als unzulässig | 1 M after | RoP.245 | Decision to reject an appeal as inadmissible | — | | |
| 11 | Antrag auf Wiederaufnahme (schwerwiegender Verfahrensmangel) | 2 M after | RoP.247.2 | Final decision (Service) / Discovery of the fundamental defect (whichever is later) | — | | |
| 12 | Antrag auf Wiederaufnahme (Straftat) | 2 M after | RoP.247.1 | Final decision (Service) / Court decision on criminal offence (whichever is later) | — | | |
## Berufungsverfahren OLG (Verletzung) · `de.inf.olg`
*Appeal OLG (Infringement)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Zustellung LG-Urteil | 0 M after | § 540 ZPO | | either | | |
| 2 | Berufungsschrift | 1 M after | § 517 ZPO | | either | | |
| 3 | Berufungsbegründung | 2 M after | § 520 ZPO | | either | | |
| 4 | Berufungserwiderung | 1 M after | § 521 ZPO | | either | | |
| 5 | Anschlussberufung | 0 M after | § 524 ZPO | | either | | |
| 6 | Mündliche Verhandlung | 0 M after | § 540 ZPO | | court-set | | |
| 7 | OLG-Urteil | 0 M after | § 540 ZPO | | court-set | | |
## Revisions-/NZB-Verfahren BGH (Verletzung) · `de.inf.bgh`
*Revision / Non-admission Appeal BGH (Infringement)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Zustellung OLG-Urteil | 0 M after | § 555 ZPO | | either | | |
| 2 | Nichtzulassungsbeschwerde | 1 M after | § 544 ZPO | | either | | |
| 3 | Nichtzulassungsbeschwerde-Begründung | 2 M after | § 544 ZPO | | either | | |
| 4 | Revisionsfrist | 1 M after | § 548 ZPO | | either | | |
| 5 | Revisionsbegründung | 2 M after | § 551 ZPO | | either | | |
| 6 | Revisionserwiderung | 1 M after | § 554 ZPO | | either | | |
| 7 | Mündliche Verhandlung BGH | 0 M after | § 555 ZPO | | court-set | | |
| 8 | BGH-Urteil | 0 M after | § 555 ZPO | | court-set | | |
## Berufungsverfahren BGH (Nichtigkeit) · `de.null.bgh`
*Appeal BGH (Nullity)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Zustellung BPatG-Urteil | 0 M after | § 110 PatG | | either | | |
| 2 | Berufungsschrift | 1 M after | § 110 PatG | | either | | |
| 3 | Berufungsbegründung | 3 M after | § 520 Abs. 2 ZPO i.V.m. § 117 PatG | | either | | |
| 4 | Berufungserwiderung | 2 M after | § 521 Abs. 2 ZPO i.V.m. § 117 PatG | | court-set | | |
| 5 | Mündliche Verhandlung BGH | 0 M after | § 121 PatG | | court-set | | |
| 6 | BGH-Urteil | 0 M after | § 122 PatG | | court-set | | |
## EP-Erteilungsverfahren · `epa.grant.exa`
*EP Grant Procedure*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Anmeldung | 0 M after | Art. 75 EPÜ | | claimant | | |
| 2 | Recherchenbericht | 6 M after | Art. 92 EPÜ | | court-set | | |
| 3 | Veröffentlichung (A1) | 18 M after | Art. 93 EPÜ | | court-set | | |
| 4 | Prüfungsantrag | 6 M after | R. 70(1) EPÜ | | claimant | | |
| 5 | Mitteilung nach R. 71(3) | 0 M after | R. 71(3) EPÜ | | court-set | | |
| 6 | Zustimmung + Übersetzung | 4 M after | R. 71(3) EPÜ | | claimant | | |
| 7 | Erteilung (B1) | 0 M after | Art. 97 EPÜ | | court-set | | |
## Beschwerdeverfahren BPatG (DPMA) · `dpma.appeal.bpatg`
*Appeal BPatG (against DPMA Decision)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Zustellung DPMA-Entscheidung | 0 M after | § 65 PatG | | either | | |
| 2 | Beschwerde | 1 M after | § 73 PatG | | either | | |
| 3 | Beschwerdebegründung | 1 M after | § 75 PatG | | court-set | | |
| 4 | Mündliche Verhandlung BPatG | 0 M after | § 78 PatG | | court-set | | |
| 5 | BPatG-Entscheidung | 0 M after | § 78 PatG | | court-set | | |
## Rechtsbeschwerdeverfahren BGH · `dpma.appeal.bgh`
*Legal Appeal BGH*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Zustellung BPatG-Entscheidung | 0 M after | § 100 PatG | | either | | |
| 2 | Rechtsbeschwerde | 1 M after | § 100 PatG | | either | | |
| 3 | Rechtsbeschwerdebegründung | 1 M after | § 102 PatG | | either | | |
| 4 | BGH-Entscheidung | 0 M after | § 100 PatG | | court-set | | |
## Berufungsverfahren Anordnungen · `upc.apl.order`
*Order Appeal (15-day track)*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Anordnung / angegriffene Entscheidung | 0 M after | RoP.220 | | court-set | | |
| 2 | Berufung mit Zulassung | 15 d after | RoP.220.2 | | either | | |
| 3 | Antrag auf Ermessensüberprüfung | 15 d after | RoP.220.3 | | either | | |
| 4 | Berufungsbegründung (Orders Track) | 15 d after | RoP.224.2.b | | either | | |
| 5 | Anschlussberufung | 15 d after | RoP.237 | | either | | |
| 6 | Erwiderung Anschlussberufung | 15 d after | RoP.238.2 | | either | | |
| 7 | Berufungsschrift gegen eine in Regel 220.1(c) genannte Anordnung oder eine in Regel 220.2 oder 221.3 genannte Entscheidung | 15 d after | RoP.224.1(b) | Order referred to in Rule 220.1(c) or a decision referred to in Rule 220.2 or 221.3 | — | | |
| 8 | Berufungsbegründung gegen eine in Regel 220.1(c) genannte Anordnung oder eine in Regel 220.2 oder 221.3 genannte Entscheidung | 15 d after | RoP.224.1(b) | Order referred to in Rule 220.1(c) or a decision referred to in Rule 220.2 or 221.3 | — | | |
## Schadensbemessungsverfahren · `upc.dmgs.cfi`
*Damages Determination*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Antrag auf Schadensbemessung | 0 M after | RoP.125 | | claimant | | |
| 2 | Klageerwiderung | 2 M after | RoP.137.2 | | defendant | | |
| 3 | Replik | 1 M after | RoP.139 | | claimant | | |
| 4 | Duplik | 1 M after | RoP.139 | | defendant | | |
## Bucheinsichtsverfahren · `upc.disc.cfi`
*Lay-open Books / Discovery*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Antrag auf Bucheinsicht | 0 M after | RoP.190 | | claimant | | |
| 2 | Klageerwiderung | 2 M after | RoP.142.2 | | defendant | | |
| 3 | Replik | 14 d after | RoP.142.3 | | claimant | | |
| 4 | Duplik | 14 d after | RoP.142.3 | | defendant | | |
## Einstweilige Maßnahmen · `upc.pi.cfi`
*Provisional Measures*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Antrag | 0 M after | RoP.206 | | claimant | | |
| 2 | Erwiderung | 0 M after | RoP.211.2 | | court-set | | |
| 3 | Mündliche Verhandlung | 0 M after | RoP.195 | | court-set | | |
| 4 | Mängelbeseitigung Antrag | 14 d after | RoP.207.6.a | | claimant | | |
| 5 | Beschluss | 0 M after | RoP.211 | | court-set | | |
| 6 | Klage in der Hauptsache erheben | 31 d max 20 WD after | RoP.213 | | claimant | | |
## Schutzschrift · `upc.pl.cfi`
*Protective Letter*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Einreichung der Schutzschrift | 0 M after | RoP.207 | | defendant | | |
| 2 | Erneuerung der Schutzschrift | 6 M after | RoP.207.9 | Protective Letter | — | | |
## Berufungsverfahren Kosten · `upc.apl.cost`
*Cost-Decision Appeal*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Kostenfestsetzungsbeschluss | 0 M after | RoP.221.4 | | court-set | | |
| 2 | Antrag auf Berufungszulassung | 15 d after | RoP.221.1 | | either | | |
| 3 | Antrag auf Berufungszulassung gegen Kostenentscheidungen | 15 d after | RoP.220.2 | Decision on fixation of costs (Rule 157) | — | | |
## Negative Feststellungsklage · `upc.dni.cfi`
*Declaration of Non-Infringement*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Klage auf negative Feststellung der Nichtverletzung | 0 M after | RoP.063 | | claimant | | |
| 2 | Erwiderung auf die negative Feststellungsklage | 2 M after | RoP.066 | Statement for a declaration of non-infringement | — | | |
| 3 | Replik auf die Erwiderung zur negativen Feststellungsklage | 1 M after | RoP.067 | Defence to the Statement for a declaration of non-infringement | — | | |
| 4 | Duplik zur Replik auf die Erwiderung zur negativen Feststellungsklage | 1 M after | RoP.068 | Reply to the Defence to the Statement for a declaration of non-infringement | — | | |
## Überprüfung von EPA-Entscheidungen · `upc.epo.review`
*Review of EPO decisions*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Antrag auf Überprüfung der EPA-Entscheidung | 0 M after | RoP.088 | | — | | |
| 2 | Antrag auf Aufhebung einer Entscheidung des EPA, mit der ein Antrag auf einheitliche Wirkung zurückgewiesen wurde | 3 w after | RoP.097 | Decision of the EPO not to grant unitary effect | — | | |
## Separate Kostenentscheidung · `upc.costs.cfi`
*Separate Cost Decision*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Antrag auf Kostenfestsetzung | 1 M after | RoP.151 | | claimant | | |
## Beweissicherung / saisie · `upc.bsv.cfi`
*Evidence Preservation*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Antrag auf Beweissicherung | 0 M after | RoP.192 | | court-set | | |
| 2 | Antrag auf Überprüfung der Beweissicherungsanordnung | 30 d after | RoP.197.3 | Execution of measures to preserve evidence | — | | |
| 3 | Beginn des Hauptsacheverfahrens | 31 d max 20 WD after | RoP.198 | Date specified in the Court's order to preserve evidence | — | | |
## Widerklage auf Nichtigkeit · `upc.ccr.cfi`
*Counterclaim for Revocation*
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|---:|---|---|---|---|---|:---:|---|
| 1 | Widerklage auf Nichtigkeit | 3 M after | RoP.025 | | defendant | | |
---
**Lesehilfe:**
- ⤴ Spawn-Marker: event opens a sub-proceeding (e.g. CCR forks revocation track)
- `with_ccr` = Widerklage auf Nichtigkeit gefilt | `with_amend` = Patentänderungsantrag | `with_cci` = Widerklage auf Verletzung (in rev.cfi)
- Catalog ist work-in-progress: 7 compound-name rules + Patentänderung-Duplikation noch in m's split-review backlog (m/paliad#149).

View File

@@ -3,8 +3,7 @@ import { join, relative } from "path";
import { renderIndex } from "./src/index";
import { renderLogin } from "./src/login";
import { renderKostenrechner } from "./src/kostenrechner";
import { renderFristenrechner } from "./src/fristenrechner";
import { renderVerfahrensablauf } from "./src/verfahrensablauf";
import { renderProcedures } from "./src/procedures";
import { renderDownloads } from "./src/downloads";
import { renderLinks } from "./src/links";
import { renderGlossary } from "./src/glossary";
@@ -40,6 +39,7 @@ import { renderAdminTeam } from "./src/admin-team";
import { renderAdminAuditLog } from "./src/admin-audit-log";
import { renderAdminPartnerUnits } from "./src/admin-partner-units";
import { renderAdminEmailTemplates } from "./src/admin-email-templates";
import { renderAdminSubmissionBuildingBlocks } from "./src/admin-submission-building-blocks";
import { renderAdminEmailTemplatesEdit } from "./src/admin-email-templates-edit";
import { renderAdminEventTypes } from "./src/admin-event-types";
import { renderAdminApprovalPolicies } from "./src/admin-approval-policies";
@@ -240,8 +240,7 @@ async function build() {
join(import.meta.dir, "src/client/index.ts"),
join(import.meta.dir, "src/client/login.ts"),
join(import.meta.dir, "src/client/kostenrechner.ts"),
join(import.meta.dir, "src/client/fristenrechner.ts"),
join(import.meta.dir, "src/client/verfahrensablauf.ts"),
join(import.meta.dir, "src/client/procedures.ts"),
join(import.meta.dir, "src/client/downloads.ts"),
join(import.meta.dir, "src/client/links.ts"),
join(import.meta.dir, "src/client/glossary.ts"),
@@ -278,6 +277,7 @@ async function build() {
join(import.meta.dir, "src/client/admin-partner-units.ts"),
join(import.meta.dir, "src/client/admin-email-templates.ts"),
join(import.meta.dir, "src/client/admin-email-templates-edit.ts"),
join(import.meta.dir, "src/client/admin-submission-building-blocks.ts"),
join(import.meta.dir, "src/client/admin-event-types.ts"),
join(import.meta.dir, "src/client/admin-approval-policies.ts"),
join(import.meta.dir, "src/client/admin-broadcasts.ts"),
@@ -367,8 +367,7 @@ async function build() {
await Bun.write(join(DIST, "index.html"), renderIndex());
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
await Bun.write(join(DIST, "kostenrechner.html"), renderKostenrechner());
await Bun.write(join(DIST, "fristenrechner.html"), renderFristenrechner());
await Bun.write(join(DIST, "verfahrensablauf.html"), renderVerfahrensablauf());
await Bun.write(join(DIST, "procedures.html"), renderProcedures());
await Bun.write(join(DIST, "downloads.html"), renderDownloads());
await Bun.write(join(DIST, "links.html"), renderLinks());
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
@@ -409,6 +408,7 @@ async function build() {
await Bun.write(join(DIST, "admin-partner-units.html"), renderAdminPartnerUnits());
await Bun.write(join(DIST, "admin-email-templates.html"), renderAdminEmailTemplates());
await Bun.write(join(DIST, "admin-email-templates-edit.html"), renderAdminEmailTemplatesEdit());
await Bun.write(join(DIST, "admin-submission-building-blocks.html"), renderAdminSubmissionBuildingBlocks());
await Bun.write(join(DIST, "admin-event-types.html"), renderAdminEventTypes());
await Bun.write(join(DIST, "admin-approval-policies.html"), renderAdminApprovalPolicies());
await Bun.write(join(DIST, "admin-broadcasts.html"), renderAdminBroadcasts());

View File

@@ -5,7 +5,7 @@ import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/rules/{id}/edit — Slice 11b (t-paliad-192). Form for the full
// /admin/procedural-events/{id}/edit — Slice 11b (t-paliad-192). Form for the full
// 37-column rule row plus a side panel with the preview widget and the
// audit-log timeline. Lifecycle action bar at the bottom adapts to the
// rule's current state (draft/published/archived). Every write goes
@@ -26,12 +26,12 @@ export function renderAdminRulesEdit(): string {
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.rules.edit.title">Regel bearbeiten &mdash; Paliad</title>
<title data-i18n="admin.procedural_events.edit.title">Regel bearbeiten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/rules" />
<BottomNav currentPath="/admin/rules" />
<Sidebar currentPath="/admin/procedural-events" />
<BottomNav currentPath="/admin/procedural-events" />
<main>
<section className="tool-page">
@@ -39,7 +39,7 @@ export function renderAdminRulesEdit(): string {
<div className="tool-header admin-rules-edit-header">
<div>
<p className="admin-rules-breadcrumb">
<a href="/admin/rules" data-i18n="admin.rules.edit.breadcrumb">&larr; Regeln verwalten</a>
<a href="/admin/procedural-events" data-i18n="admin.procedural_events.edit.breadcrumb">&larr; Regeln verwalten</a>
</p>
<h1 id="rules-edit-heading" data-i18n="admin.rules.edit.heading.loading">Regel laden...</h1>
<div className="admin-rules-edit-meta">
@@ -71,7 +71,7 @@ export function renderAdminRulesEdit(): string {
</div>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-submission-code" data-i18n="admin.rules.edit.field.submission_code">Submission Code / Einreichung-Kennung</label>
<label htmlFor="f-submission-code" data-i18n="admin.procedural_events.edit.field.code">Submission Code / Einreichung-Kennung</label>
<input type="text" id="f-submission-code" className="admin-rules-input" readonly placeholder="z. B. upc.inf.cfi.soc" />
</div>
<div className="form-field">
@@ -103,7 +103,7 @@ export function renderAdminRulesEdit(): string {
</div>
<div className="admin-rules-edit-row">
<div className="form-field">
<label htmlFor="f-parent" data-i18n="admin.rules.edit.field.parent">Parent-Regel (UUID)</label>
<label htmlFor="f-parent" data-i18n="admin.procedural_events.edit.field.parent">Parent-Regel (UUID)</label>
<input type="text" id="f-parent" className="admin-rules-input" placeholder="UUID oder leer" />
</div>
<div className="form-field">
@@ -184,7 +184,7 @@ export function renderAdminRulesEdit(): string {
<input type="text" id="f-primary-party" className="admin-rules-input" />
</div>
<div className="form-field">
<label htmlFor="f-event-type" data-i18n="admin.rules.edit.field.event_type">Event-Typ (frei)</label>
<label htmlFor="f-event-type" data-i18n="admin.procedural_events.edit.field.event_kind">Event-Typ (frei)</label>
<input type="text" id="f-event-type" className="admin-rules-input" />
</div>
</div>

View File

@@ -5,7 +5,7 @@ import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/rules — Slice 11b (t-paliad-192). Filterable rule table + an
// /admin/procedural-events — Slice 11b (t-paliad-192). Filterable rule table + an
// Orphans tab that surfaces the Slice 10 fuzzy-match staging rows so an
// admin can hand-bind each legacy deadline to one of the candidate
// rule_ids. Both surfaces share the same page shell to keep navigation
@@ -21,25 +21,25 @@ export function renderAdminRulesList(): string {
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.rules.list.title">Regeln verwalten &mdash; Paliad</title>
<title data-i18n="admin.procedural_events.list.title">Regeln verwalten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/rules" />
<BottomNav currentPath="/admin/rules" />
<Sidebar currentPath="/admin/procedural-events" />
<BottomNav currentPath="/admin/procedural-events" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.rules.list.heading">Regeln verwalten</h1>
<h1 data-i18n="admin.procedural_events.list.heading">Regeln verwalten</h1>
<p className="tool-subtitle" data-i18n="admin.rules.list.subtitle">
Fristen-Regeln anlegen, bearbeiten und freigeben. Lifecycle: draft &rarr; published &rarr; archived.
</p>
</div>
<div className="admin-rules-header-actions">
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.rules.list.new">
<button type="button" id="rules-new-btn" className="btn-primary" data-i18n="admin.procedural_events.list.new">
+ Neue Regel
</button>
</div>
@@ -77,9 +77,9 @@ export function renderAdminRulesList(): string {
<div className="admin-rules-filter admin-rules-filter-chips">
<span className="admin-rules-filter-label" data-i18n="admin.rules.filter.lifecycle">Lifecycle</span>
<div className="admin-rules-chips" id="rules-filter-lifecycle">
<button type="button" className="admin-rules-chip active" data-state="" data-i18n="admin.rules.filter.lifecycle.any">Alle</button>
<button type="button" className="admin-rules-chip" data-state="" data-i18n="admin.rules.filter.lifecycle.any">Alle</button>
<button type="button" className="admin-rules-chip" data-state="draft" data-i18n="admin.rules.lifecycle.draft">Draft</button>
<button type="button" className="admin-rules-chip" data-state="published" data-i18n="admin.rules.lifecycle.published">Published</button>
<button type="button" className="admin-rules-chip active" data-state="published" data-i18n="admin.rules.lifecycle.published">Published</button>
<button type="button" className="admin-rules-chip" data-state="archived" data-i18n="admin.rules.lifecycle.archived">Archived</button>
</div>
</div>
@@ -101,10 +101,10 @@ export function renderAdminRulesList(): string {
<table className="entity-table admin-rules-table">
<thead>
<tr>
<th data-i18n="admin.rules.col.submission_code">Submission Code</th>
<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

@@ -0,0 +1,77 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /admin/submission-building-blocks — Composer building-blocks library
// editor (t-paliad-315 Slice C). Three-pane layout: list on the left,
// edit form in the middle, version log on the right. Hydrated by
// client/admin-submission-building-blocks.ts from
// GET /api/admin/submission-building-blocks.
export function renderAdminSubmissionBuildingBlocks(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="admin.building_blocks.title">Bausteine &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/admin/submission-building-blocks" />
<BottomNav currentPath="/admin/submission-building-blocks" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div>
<h1 data-i18n="admin.building_blocks.heading">Bausteine</h1>
<p className="tool-subtitle" data-i18n="admin.building_blocks.subtitle">
Wiederverwendbare Textbausteine f&uuml;r Composer-Abschnitte.
</p>
</div>
<div className="tool-header-actions">
<button
type="button"
id="admin-bb-new-btn"
className="btn-primary btn-cta-lime"
data-i18n="admin.building_blocks.action.new">
+ Neuer Baustein
</button>
</div>
</div>
<div id="admin-bb-feedback" className="form-msg" style="display:none" />
<div className="admin-bb-layout">
<aside className="admin-bb-list" id="admin-bb-list">
<div className="admin-bb-loading" data-i18n="admin.building_blocks.loading">L&auml;dt&hellip;</div>
</aside>
<section className="admin-bb-editor" id="admin-bb-editor">
<p className="admin-bb-empty" data-i18n="admin.building_blocks.editor.empty">
W&auml;hlen Sie einen Baustein aus der Liste &mdash; oder erstellen Sie einen neuen.
</p>
</section>
<aside className="admin-bb-versions" id="admin-bb-versions" />
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/admin-submission-building-blocks.js"></script>
</body>
</html>
);
}

View File

@@ -95,7 +95,7 @@ export function renderAdmin(): string {
<h2 data-i18n="admin.card.approval_policies.title">Genehmigungspflichten</h2>
<p data-i18n="admin.card.approval_policies.desc">4-Augen-Pr&uuml;fung pro Projekt und Partner Unit konfigurieren.</p>
</a>
<a href="/admin/rules" className="card card-link">
<a href="/admin/procedural-events" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_TABLE }} />
<h2 data-i18n="admin.card.rules.title">Regeln verwalten</h2>
<p data-i18n="admin.card.rules.desc">Fristen-Regeln anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.</p>

View File

@@ -1,7 +1,7 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
// admin-rules-edit.ts — /admin/rules/{id}/edit. Loads a single rule
// admin-rules-edit.ts — /admin/procedural-events/{id}/edit. Loads a single rule
// row, drives every form field, the preview widget, the audit-log
// timeline and the lifecycle action bar. Every write is gated behind
// a reason modal — the ≥10-char rule is enforced client-side per
@@ -106,8 +106,10 @@ function fmtDateTime(iso: string): string {
}
function parseRuleIDFromPath(): string {
// /admin/rules/{uuid}/edit
const m = /^\/admin\/rules\/([^\/]+)\/edit\/?$/.exec(window.location.pathname);
// /admin/procedural-events/{uuid}/edit (canonical, post Slice B.6 rename)
// /admin/rules/{uuid}/edit (legacy, 301-redirected by the backend but
// still matched here in case a stale tab or bookmark hits it).
const m = /^\/admin\/(?:procedural-events|rules)\/([^\/]+)\/edit\/?$/.exec(window.location.pathname);
return m ? decodeURIComponent(m[1]) : "";
}
@@ -179,7 +181,7 @@ function fillProceedingSelect(selectId: string, list: ProceedingType[]) {
}
async function loadRule(): Promise<void> {
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`);
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}`);
if (!resp.ok) {
if (resp.status === 404) {
showFeedback(t("admin.rules.edit.error.not_found") || "Regel nicht gefunden.", true);
@@ -198,7 +200,7 @@ async function loadAudit(reset: boolean = true): Promise<void> {
auditEntries = [];
auditOffset = 0;
}
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/audit?offset=${auditOffset}&limit=${AUDIT_PAGE}`);
if (!resp.ok) return;
const body = await resp.json();
const rows = Array.isArray(body) ? body as AuditEntry[] : [];
@@ -508,7 +510,7 @@ async function doSaveDraft(reason: string) {
return;
}
payload.reason = reason;
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}`, {
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
@@ -530,7 +532,7 @@ async function doSaveDraft(reason: string) {
async function doLifecycle(op: "publish" | "archive" | "restore", reason: string) {
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/${op}`, {
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/${op}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
@@ -552,7 +554,7 @@ async function doLifecycle(op: "publish" | "archive" | "restore", reason: string
async function doClone(reason: string) {
const msg = document.getElementById("rules-action-modal-msg") as HTMLElement;
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/clone-as-draft`, {
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/clone-as-draft`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ reason }),
@@ -565,7 +567,7 @@ async function doClone(reason: string) {
return;
}
const newRule = await resp.json() as Rule;
window.location.href = `/admin/rules/${encodeURIComponent(newRule.id)}/edit`;
window.location.href = `/admin/procedural-events/${encodeURIComponent(newRule.id)}/edit`;
}
// --------------------------------------------------------------------
@@ -591,7 +593,7 @@ async function runPreview() {
if (flagsRaw) qs.set("flags", flagsRaw);
out.innerHTML = `<p class="admin-rules-loading">${esc(t("admin.rules.edit.preview.running") || "Berechne...")}</p>`;
out.style.display = "";
const resp = await fetch(`/admin/api/rules/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
const resp = await fetch(`/admin/api/procedural-events/${encodeURIComponent(ruleId)}/preview?${qs.toString()}`);
if (!resp.ok) {
const body = await resp.json().catch(() => ({ error: resp.statusText }));
out.innerHTML = `<p class="admin-rules-hint admin-rules-hint-error">${esc(body.error || (t("admin.rules.edit.preview.error") || "Preview fehlgeschlagen."))}</p>`;

View File

@@ -1,16 +1,23 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
// admin-rules-list.ts — /admin/rules. Drives the rule table (filterable
// admin-rules-list.ts — /admin/procedural-events. Drives the rule table (filterable
// by proceeding type, trigger event, lifecycle state, free-text query)
// plus the Orphans tab (Slice 10 backfill staging rows). Row click on
// a rule routes to /admin/rules/{id}/edit; orphan cards have their own
// a rule routes to /admin/procedural-events/{id}/edit; orphan cards have their own
// "Pick" affordance with an inline reason prompt that posts to
// /admin/api/orphans/{id}/resolve.
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`).
@@ -73,7 +80,7 @@ let triggerEvents: TriggerEvent[] = [];
let activeProceeding = "";
let activeTrigger = "";
let activeLifecycle = "";
let activeLifecycle = "published";
let activeQuery = "";
let searchDebounce: number | undefined;
@@ -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);
@@ -145,7 +165,7 @@ function buildFilterURL(): string {
if (activeLifecycle) qs.set("lifecycle_state", activeLifecycle);
if (activeQuery) qs.set("q", activeQuery);
qs.set("limit", "500");
return "/admin/api/rules?" + qs.toString();
return "/admin/api/procedural-events?" + qs.toString();
}
async function loadProceedings(): Promise<void> {
@@ -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>
@@ -248,7 +268,7 @@ function renderRulesTable() {
if (target && (target.closest("a") || target.closest("button"))) return;
const id = row.dataset.rowId;
if (!id) return;
window.location.href = `/admin/rules/${encodeURIComponent(id)}/edit`;
window.location.href = `/admin/procedural-events/${encodeURIComponent(id)}/edit`;
});
});
}
@@ -392,7 +412,7 @@ async function submitReasonModal(ev: Event) {
submit.disabled = false;
return;
}
const resp = await fetch("/admin/api/rules", {
const resp = await fetch("/admin/api/procedural-events", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -416,7 +436,7 @@ async function submitReasonModal(ev: Event) {
return;
}
const created = await resp.json();
window.location.href = `/admin/rules/${encodeURIComponent(created.id)}/edit`;
window.location.href = `/admin/procedural-events/${encodeURIComponent(created.id)}/edit`;
return;
}

View File

@@ -0,0 +1,429 @@
import { initI18n, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
function isEN(): boolean { return getLang() === "en"; }
// /admin/submission-building-blocks — Composer building-blocks admin
// editor (t-paliad-315 Slice C). Three-pane layout: list → editor →
// version log. CRUD via /api/admin/submission-building-blocks/*.
//
// Per Q2 ratification (m, 2026-05-26): building blocks are plain text
// paste sources. The editor here is curator-only — no per-section
// lineage to surface, no "where is this block used" view.
interface BuildingBlockJSON {
id: string;
slug: string;
firm?: string | null;
section_key: string;
proceeding_family?: string | null;
title_de: string;
title_en: string;
description_de?: string | null;
description_en?: string | null;
content_md_de: string;
content_md_en: string;
author_id?: string | null;
visibility: string;
is_published: boolean;
created_at: string;
updated_at: string;
}
interface VersionJSON {
id: string;
building_block_id: string;
content_md_de: string;
content_md_en: string;
title_de: string;
title_en: string;
edited_by?: string | null;
note?: string | null;
created_at: string;
}
const VISIBILITIES = ["private", "team", "firm", "global"];
// Section keys must match what the Composer base spec declares for
// each section (see internal/db/migrations/146_submission_bases.up.sql).
const SECTION_KEYS = [
"letterhead", "caption", "introduction", "requests",
"facts", "legal_argument", "evidence", "exhibits",
"closing", "signature",
];
const state = {
blocks: [] as BuildingBlockJSON[],
selectedID: null as string | null,
versions: [] as VersionJSON[],
dirty: false,
};
async function boot(): Promise<void> {
initI18n();
initSidebar();
await loadList();
document.getElementById("admin-bb-new-btn")?.addEventListener("click", onNew);
}
async function loadList(): Promise<void> {
try {
const res = await fetch("/api/admin/submission-building-blocks", { credentials: "include" });
if (!res.ok) {
feedback(`HTTP ${res.status}`, true);
return;
}
const body = await res.json() as { blocks?: BuildingBlockJSON[] };
state.blocks = body.blocks ?? [];
paintList();
} catch (err) {
feedback(String(err), true);
}
}
function paintList(): void {
const host = document.getElementById("admin-bb-list");
if (!host) return;
host.innerHTML = "";
if (state.blocks.length === 0) {
const empty = document.createElement("p");
empty.className = "admin-bb-empty";
empty.textContent = isEN() ? "No blocks yet." : "Noch keine Bausteine.";
host.appendChild(empty);
return;
}
for (const b of state.blocks) {
const row = document.createElement("button");
row.type = "button";
row.className = "admin-bb-list-row";
if (b.id === state.selectedID) row.classList.add("admin-bb-list-row--active");
const title = isEN() ? b.title_en : b.title_de;
row.innerHTML = `
<span class="admin-bb-list-title">${escapeHTML(title || b.slug)}</span>
<span class="admin-bb-list-meta">
<span class="admin-bb-list-section">${escapeHTML(b.section_key)}</span>
<span class="admin-bb-list-vis admin-bb-list-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span>
${b.is_published ? "" : `<span class="admin-bb-list-draft">${isEN() ? "draft" : "Entwurf"}</span>`}
</span>`;
row.addEventListener("click", () => onSelect(b.id));
host.appendChild(row);
}
}
async function onSelect(id: string): Promise<void> {
state.selectedID = id;
state.dirty = false;
paintList();
const b = state.blocks.find(x => x.id === id);
if (!b) return;
paintEditor(b);
await loadVersions(id);
}
function onNew(): void {
state.selectedID = null;
state.versions = [];
state.dirty = false;
paintList();
paintEditor(null);
paintVersions();
}
function paintEditor(b: BuildingBlockJSON | null): void {
const host = document.getElementById("admin-bb-editor");
if (!host) return;
const isNew = b === null;
const data = b ?? {
id: "",
slug: "",
firm: "",
section_key: "requests",
proceeding_family: "",
title_de: "",
title_en: "",
description_de: "",
description_en: "",
content_md_de: "",
content_md_en: "",
visibility: "firm",
is_published: false,
} as Partial<BuildingBlockJSON>;
host.innerHTML = "";
const form = document.createElement("form");
form.className = "admin-bb-form";
form.addEventListener("submit", (e) => { e.preventDefault(); onSave(isNew); });
form.appendChild(textField("slug", isEN() ? "Slug" : "Slug", data.slug ?? "", true));
form.appendChild(textField("firm", "Firm", data.firm ?? "", false, isEN() ? "leer = firmenagnostisch" : "leer = firmenagnostisch"));
form.appendChild(selectField("section_key", isEN() ? "Section key" : "Abschnitts-Slug", data.section_key ?? "requests", SECTION_KEYS, false));
form.appendChild(textField("proceeding_family", isEN() ? "Proceeding family" : "Verfahrensfamilie", data.proceeding_family ?? "", false, "z. B. de.inf.lg"));
form.appendChild(textField("title_de", "Titel (DE)", data.title_de ?? "", true));
form.appendChild(textField("title_en", "Title (EN)", data.title_en ?? "", true));
form.appendChild(textareaField("description_de", "Beschreibung (DE)", data.description_de ?? "", 2));
form.appendChild(textareaField("description_en", "Description (EN)", data.description_en ?? "", 2));
form.appendChild(textareaField("content_md_de", isEN() ? "Content (DE Markdown)" : "Inhalt (DE Markdown)", data.content_md_de ?? "", 10));
form.appendChild(textareaField("content_md_en", isEN() ? "Content (EN Markdown)" : "Inhalt (EN Markdown)", data.content_md_en ?? "", 10));
form.appendChild(selectField("visibility", isEN() ? "Visibility" : "Sichtbarkeit", data.visibility ?? "firm", VISIBILITIES, false));
form.appendChild(checkboxField("is_published", isEN() ? "Published" : "Veröffentlicht", Boolean(data.is_published)));
if (!isNew) {
form.appendChild(textField("note", isEN() ? "Save note (optional)" : "Speicher-Notiz (optional)", "", false));
}
const actions = document.createElement("div");
actions.className = "admin-bb-form-actions";
const save = document.createElement("button");
save.type = "submit";
save.className = "btn-primary btn-cta-lime";
save.textContent = isEN() ? "Save" : "Speichern";
actions.appendChild(save);
if (!isNew) {
const del = document.createElement("button");
del.type = "button";
del.className = "btn-link-danger";
del.textContent = isEN() ? "Delete" : "Löschen";
del.addEventListener("click", () => onDelete());
actions.appendChild(del);
}
form.appendChild(actions);
host.appendChild(form);
}
function textField(name: string, label: string, value: string, required: boolean, hint?: string): HTMLElement {
const wrap = document.createElement("label");
wrap.className = "admin-bb-form-row";
const lab = document.createElement("span");
lab.textContent = label + (required ? " *" : "");
wrap.appendChild(lab);
const input = document.createElement("input");
input.type = "text";
input.name = name;
input.className = "entity-form-input";
input.value = value;
if (required) input.required = true;
wrap.appendChild(input);
if (hint) {
const h = document.createElement("small");
h.className = "admin-bb-form-hint";
h.textContent = hint;
wrap.appendChild(h);
}
return wrap;
}
function textareaField(name: string, label: string, value: string, rows: number): HTMLElement {
const wrap = document.createElement("label");
wrap.className = "admin-bb-form-row";
const lab = document.createElement("span");
lab.textContent = label;
wrap.appendChild(lab);
const ta = document.createElement("textarea");
ta.name = name;
ta.className = "entity-form-input";
ta.rows = rows;
ta.value = value;
wrap.appendChild(ta);
return wrap;
}
function selectField(name: string, label: string, value: string, options: string[], required: boolean): HTMLElement {
const wrap = document.createElement("label");
wrap.className = "admin-bb-form-row";
const lab = document.createElement("span");
lab.textContent = label + (required ? " *" : "");
wrap.appendChild(lab);
const sel = document.createElement("select");
sel.name = name;
sel.className = "entity-form-input";
for (const opt of options) {
const o = document.createElement("option");
o.value = opt;
o.textContent = opt;
if (opt === value) o.selected = true;
sel.appendChild(o);
}
wrap.appendChild(sel);
return wrap;
}
function checkboxField(name: string, label: string, value: boolean): HTMLElement {
const wrap = document.createElement("label");
wrap.className = "admin-bb-form-row admin-bb-form-row--checkbox";
const input = document.createElement("input");
input.type = "checkbox";
input.name = name;
input.checked = value;
wrap.appendChild(input);
const lab = document.createElement("span");
lab.textContent = label;
wrap.appendChild(lab);
return wrap;
}
async function onSave(isNew: boolean): Promise<void> {
const form = document.querySelector(".admin-bb-form") as HTMLFormElement | null;
if (!form) return;
const data = new FormData(form);
const payload: Record<string, unknown> = {};
for (const key of ["slug", "section_key", "title_de", "title_en", "content_md_de", "content_md_en", "visibility"]) {
const v = data.get(key);
if (v !== null) payload[key] = String(v);
}
for (const key of ["firm", "proceeding_family", "description_de", "description_en"]) {
const v = data.get(key);
if (v !== null) {
const s = String(v).trim();
payload[key] = s === "" ? null : s;
}
}
payload.is_published = (data.get("is_published") === "on");
if (!isNew) {
const note = data.get("note");
if (note) payload.note = String(note);
}
try {
const url = isNew
? "/api/admin/submission-building-blocks"
: `/api/admin/submission-building-blocks/${state.selectedID}`;
const method = isNew ? "POST" : "PATCH";
const res = await fetch(url, {
method,
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.json().catch(() => ({} as { error?: string }));
feedback(body.error ?? `HTTP ${res.status}`, true);
return;
}
const saved = await res.json() as BuildingBlockJSON;
feedback(isEN() ? "Saved." : "Gespeichert.", false);
await loadList();
state.selectedID = saved.id;
paintList();
paintEditor(saved);
await loadVersions(saved.id);
} catch (err) {
feedback(String(err), true);
}
}
async function onDelete(): Promise<void> {
if (!state.selectedID) return;
const sure = confirm(isEN() ? "Delete this block?" : "Diesen Baustein löschen?");
if (!sure) return;
try {
const res = await fetch(`/api/admin/submission-building-blocks/${state.selectedID}`, {
method: "DELETE",
credentials: "include",
});
if (!res.ok && res.status !== 204) {
feedback(`HTTP ${res.status}`, true);
return;
}
feedback(isEN() ? "Deleted." : "Gelöscht.", false);
state.selectedID = null;
await loadList();
paintEditor(null);
state.versions = [];
paintVersions();
} catch (err) {
feedback(String(err), true);
}
}
async function loadVersions(blockID: string): Promise<void> {
try {
const res = await fetch(`/api/admin/submission-building-blocks/${blockID}/versions`, { credentials: "include" });
if (!res.ok) {
state.versions = [];
paintVersions();
return;
}
const body = await res.json() as { versions?: VersionJSON[] };
state.versions = body.versions ?? [];
paintVersions();
} catch {
state.versions = [];
paintVersions();
}
}
function paintVersions(): void {
const host = document.getElementById("admin-bb-versions");
if (!host) return;
host.innerHTML = "";
if (state.versions.length === 0) return;
const h = document.createElement("h3");
h.textContent = isEN() ? "History" : "Verlauf";
host.appendChild(h);
for (const v of state.versions) {
const row = document.createElement("div");
row.className = "admin-bb-version-row";
const date = new Date(v.created_at).toLocaleString();
row.innerHTML = `
<div class="admin-bb-version-meta">${escapeHTML(date)}${escapeHTML(v.note ?? "")}</div>`;
const btn = document.createElement("button");
btn.type = "button";
btn.className = "btn-small btn-secondary";
btn.textContent = isEN() ? "Restore" : "Wiederherstellen";
btn.addEventListener("click", () => onRestore(v.id));
row.appendChild(btn);
host.appendChild(row);
}
}
async function onRestore(versionID: string): Promise<void> {
if (!state.selectedID) return;
try {
const res = await fetch(
`/api/admin/submission-building-blocks/${state.selectedID}/restore/${versionID}`,
{ method: "POST", credentials: "include" },
);
if (!res.ok) {
feedback(`HTTP ${res.status}`, true);
return;
}
const restored = await res.json() as BuildingBlockJSON;
feedback(isEN() ? "Restored." : "Wiederhergestellt.", false);
paintEditor(restored);
await loadVersions(restored.id);
await loadList();
} catch (err) {
feedback(String(err), true);
}
}
function feedback(msg: string, isError: boolean): void {
const host = document.getElementById("admin-bb-feedback");
if (!host) return;
host.style.display = "";
host.className = "form-msg " + (isError ? "form-msg--error" : "form-msg--ok");
host.textContent = msg;
if (!isError) {
setTimeout(() => { host.style.display = "none"; }, 3000);
}
}
function escapeHTML(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Silence unused-import warning when t() isn't called directly — i18n
// is initialised so data-i18n attrs render on first paint.
void t;
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot);
} else {
void boot();
}

View File

@@ -0,0 +1,147 @@
// Add-proceeding inline picker for the Litigation Builder.
//
// PRD §3 + §3.1: "+ Verfahren hinzufügen" button at the bottom of the
// triplet stack opens an inline picker. Forum chip row (UPC for v1)
// gates the Verfahren chip row, click → callback. Designed for B1's
// single-triplet flow and B2's multi-triplet stacking with no shape
// change between slices.
import { t } from "./i18n";
export interface ProceedingTypeMeta {
id: number;
code: string;
name: string;
nameEN: string;
// group / jurisdiction. The proceeding-types API returns "UPC" /
// "DE" / etc. as the canonical jurisdiction; for v1 the picker
// only renders UPC.
group?: string;
jurisdiction?: string;
}
type OnPick = (meta: ProceedingTypeMeta) => void | Promise<void>;
let activePopover: HTMLElement | null = null;
export function mountAddProceedingPicker(
anchor: HTMLElement,
types: ProceedingTypeMeta[],
onPick: OnPick,
): void {
closeActive();
const pop = document.createElement("div");
pop.className = "builder-picker-popover";
pop.setAttribute("role", "dialog");
pop.setAttribute("aria-label", t("builder.picker.aria"));
const header = document.createElement("div");
header.className = "builder-picker-header";
header.innerHTML = `
<strong class="builder-picker-title">${escHtml(t("builder.picker.title"))}</strong>
<button type="button" class="builder-picker-close" aria-label="${escAttr(t("builder.picker.close"))}">×</button>
`;
pop.appendChild(header);
// Forum row — UPC only for v1. Disabled chips render greyed.
const forumRow = document.createElement("div");
forumRow.className = "builder-picker-row";
forumRow.innerHTML = `
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.forum"))}</span>
<div class="builder-picker-chips">
<button type="button" class="builder-picker-chip is-active" data-forum="UPC">UPC</button>
<button type="button" class="builder-picker-chip" data-forum="DE" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DE</button>
<button type="button" class="builder-picker-chip" data-forum="EPA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">EPA</button>
<button type="button" class="builder-picker-chip" data-forum="DPMA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DPMA</button>
</div>
`;
pop.appendChild(forumRow);
const procRow = document.createElement("div");
procRow.className = "builder-picker-row";
procRow.innerHTML = `
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.proc"))}</span>
<div class="builder-picker-chips builder-picker-chips--wrap" id="builder-picker-proc-chips"></div>
`;
pop.appendChild(procRow);
const empty = document.createElement("p");
empty.className = "builder-picker-empty";
empty.hidden = true;
empty.textContent = t("builder.picker.empty");
pop.appendChild(empty);
const procHost = pop.querySelector("#builder-picker-proc-chips") as HTMLElement;
const lang = document.documentElement.lang === "en" ? "en" : "de";
for (const meta of types) {
const label = lang === "en" ? (meta.nameEN || meta.name) : meta.name;
const chip = document.createElement("button");
chip.type = "button";
chip.className = "builder-picker-chip builder-picker-chip--proc";
chip.setAttribute("data-code", meta.code);
chip.innerHTML = `<span class="builder-picker-chip-code">${escHtml(meta.code)}</span>
<span class="builder-picker-chip-name">${escHtml(label)}</span>`;
chip.addEventListener("click", () => {
closeActive();
void onPick(meta);
});
procHost.appendChild(chip);
}
if (types.length === 0) empty.hidden = false;
header.querySelector(".builder-picker-close")?.addEventListener("click", () => {
closeActive();
});
// Position the popover under the anchor button.
positionUnder(pop, anchor);
document.body.appendChild(pop);
activePopover = pop;
document.addEventListener("click", onOutsideClick, true);
document.addEventListener("keydown", onEscape, true);
}
function positionUnder(pop: HTMLElement, anchor: HTMLElement): void {
const rect = anchor.getBoundingClientRect();
pop.style.position = "absolute";
const top = rect.bottom + window.scrollY + 6;
// Default left = anchor's left; clamp so popover stays in viewport.
const left = Math.max(8, rect.left + window.scrollX);
pop.style.top = `${top}px`;
pop.style.left = `${left}px`;
pop.style.maxWidth = "min(640px, calc(100vw - 24px))";
pop.style.zIndex = "60";
}
function onOutsideClick(ev: Event): void {
if (!activePopover) return;
const target = ev.target as Node;
if (activePopover.contains(target)) return;
closeActive();
}
function onEscape(ev: KeyboardEvent): void {
if (ev.key === "Escape") closeActive();
}
function closeActive(): void {
if (activePopover) {
activePopover.remove();
activePopover = null;
}
document.removeEventListener("click", onOutsideClick, true);
document.removeEventListener("keydown", onEscape, true);
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View File

@@ -0,0 +1,412 @@
// Universal search dropdown for the Litigation Builder (m/paliad#153 B3).
//
// PRD §2.2 + §3.1 + §6.3: the page-header search box ("Suche") drives
// a typed dropdown returning grouped event / scenario / project hits.
// Picking an event lands the user on a scratch scenario with one
// triplet anchored on that event's proceeding type. Picking a scenario
// loads it; picking a project (Akte) is deferred to B4 (the dropdown
// row renders but pick falls through to a console hint until B4 wires
// project-backed scenarios).
//
// The controller is owned by builder.ts; this module exports
// `mountBuilderSearch` which wires the input + dropdown lifecycle and
// invokes the supplied callbacks. No module-level state — re-mounting
// is safe.
import { t } from "./i18n";
export interface EventSearchHit {
id: string;
code: string;
name_de: string;
name_en: string;
event_kind?: string | null;
primary_party?: string | null;
anchor_rule_id: string;
follow_up_count: number;
proceeding_type: {
id: number;
code: string;
name_de: string;
name_en: string;
jurisdiction?: string | null;
};
}
export interface ScenarioSearchHit {
id: string;
name: string;
status: string;
updated_at: string;
}
export interface ProjectSearchHit {
id: string;
type: string;
title: string;
reference?: string | null;
case_number?: string | null;
matter_number?: string | null;
client_number?: string | null;
}
export interface UniversalSearchResponse {
query: string;
events: EventSearchHit[];
scenarios: ScenarioSearchHit[];
projects: ProjectSearchHit[];
counts: { events: number; scenarios: number; projects: number };
}
export interface BuilderSearchCallbacks {
onPickEvent: (hit: EventSearchHit) => void | Promise<void>;
onPickScenario: (hit: ScenarioSearchHit) => void | Promise<void>;
onPickProject?: (hit: ProjectSearchHit) => void | Promise<void>;
}
interface Controller {
input: HTMLInputElement;
dropdown: HTMLElement;
open: boolean;
abort: AbortController | null;
debounceTimer: number | null;
lang: "de" | "en";
}
let active: Controller | null = null;
// mountBuilderSearch wires the universal search behavior onto an
// existing <input>. Idempotent — re-calling tears down the previous
// dropdown and rebinds. Returns a controller exposing focus() so the
// entry-mode toggle in builder.ts can land on the search input.
export function mountBuilderSearch(
input: HTMLInputElement,
cb: BuilderSearchCallbacks,
): { focus: () => void; close: () => void } {
teardown();
const lang: "de" | "en" = document.documentElement.lang === "en" ? "en" : "de";
// Single dropdown container, anchored under the input. Positioned
// absolutely so it floats above the canvas without reflowing layout.
const dropdown = document.createElement("div");
dropdown.className = "builder-search-dropdown";
dropdown.setAttribute("role", "listbox");
dropdown.hidden = true;
document.body.appendChild(dropdown);
active = {
input,
dropdown,
open: false,
abort: null,
debounceTimer: null,
lang,
};
input.addEventListener("input", onInput);
input.addEventListener("focus", onFocus);
input.addEventListener("keydown", onKeydown);
document.addEventListener("click", onOutsideClick, true);
window.addEventListener("resize", reposition);
window.addEventListener("scroll", reposition, true);
// Click handler is wired once on the dropdown root via event
// delegation; per-row data attributes identify the hit type.
dropdown.addEventListener("click", (ev) => {
const row = (ev.target as HTMLElement).closest<HTMLElement>(".builder-search-row");
if (!row) return;
const kind = row.getAttribute("data-hit-kind");
const payload = row.getAttribute("data-hit-payload");
if (!kind || !payload) return;
try {
const hit = JSON.parse(payload);
ev.stopPropagation();
closeDropdown();
if (kind === "event") void cb.onPickEvent(hit);
else if (kind === "scenario") void cb.onPickScenario(hit);
else if (kind === "project" && cb.onPickProject) void cb.onPickProject(hit);
} catch (err) {
console.error("builder-search: bad payload", err);
}
});
return {
focus: () => {
input.focus();
// Open the dropdown on focus even when input is empty — show the
// "start typing" hint per PRD §2.2 (search box auto-focuses).
openDropdown();
renderHint(t("builder.search.hint.start"));
},
close: closeDropdown,
};
}
function teardown(): void {
if (!active) return;
if (active.abort) active.abort.abort();
if (active.debounceTimer !== null) window.clearTimeout(active.debounceTimer);
active.dropdown.remove();
active.input.removeEventListener("input", onInput);
active.input.removeEventListener("focus", onFocus);
active.input.removeEventListener("keydown", onKeydown);
document.removeEventListener("click", onOutsideClick, true);
window.removeEventListener("resize", reposition);
window.removeEventListener("scroll", reposition, true);
active = null;
}
function onInput(): void {
if (!active) return;
const q = active.input.value.trim();
if (active.debounceTimer !== null) window.clearTimeout(active.debounceTimer);
if (q.length === 0) {
openDropdown();
renderHint(t("builder.search.hint.start"));
return;
}
if (q.length < 2) {
openDropdown();
renderHint(t("builder.search.hint.short"));
return;
}
active.debounceTimer = window.setTimeout(() => {
void runSearch(q);
}, 180);
}
function onFocus(): void {
if (!active) return;
const q = active.input.value.trim();
if (q.length === 0) {
openDropdown();
renderHint(t("builder.search.hint.start"));
} else if (q.length >= 2) {
void runSearch(q);
}
}
function onKeydown(ev: KeyboardEvent): void {
if (!active) return;
if (ev.key === "Escape") {
closeDropdown();
return;
}
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
const rows = Array.from(active.dropdown.querySelectorAll<HTMLElement>(".builder-search-row"));
if (rows.length === 0) return;
ev.preventDefault();
const current = active.dropdown.querySelector<HTMLElement>(".builder-search-row.is-focus");
let idx = current ? rows.indexOf(current) : -1;
idx = ev.key === "ArrowDown"
? Math.min(rows.length - 1, idx + 1)
: Math.max(0, idx - 1);
rows.forEach((r) => r.classList.remove("is-focus"));
rows[idx].classList.add("is-focus");
rows[idx].scrollIntoView({ block: "nearest" });
return;
}
if (ev.key === "Enter") {
const focused = active.dropdown.querySelector<HTMLElement>(".builder-search-row.is-focus");
if (focused) {
ev.preventDefault();
focused.click();
}
}
}
function onOutsideClick(ev: Event): void {
if (!active) return;
const target = ev.target as Node;
if (active.input.contains(target)) return;
if (active.dropdown.contains(target)) return;
closeDropdown();
}
async function runSearch(q: string): Promise<void> {
if (!active) return;
// Cancel any in-flight request so a slow earlier query can't clobber
// a faster newer one.
if (active.abort) active.abort.abort();
const ctl = new AbortController();
active.abort = ctl;
openDropdown();
renderHint(t("builder.search.hint.loading"));
try {
const url = "/api/builder/search?q=" + encodeURIComponent(q);
const resp = await fetch(url, { signal: ctl.signal });
if (!resp.ok) {
renderHint(t("builder.search.hint.error"));
return;
}
const data = (await resp.json()) as UniversalSearchResponse;
if (active.abort !== ctl) return;
renderResults(data);
} catch (err) {
if ((err as { name?: string })?.name === "AbortError") return;
console.error("builder-search error:", err);
renderHint(t("builder.search.hint.error"));
}
}
function renderHint(message: string): void {
if (!active) return;
active.dropdown.innerHTML = `<div class="builder-search-hint">${escHtml(message)}</div>`;
reposition();
}
function renderResults(data: UniversalSearchResponse): void {
if (!active) return;
const lang = active.lang;
const total = data.events.length + data.scenarios.length + data.projects.length;
if (total === 0) {
renderHint(t("builder.search.hint.empty"));
return;
}
// Result-count summary per PRD §2.2: "N Ereignisse · M Szenarios · K Akten"
const counts = `<div class="builder-search-summary">` +
escHtml(tCount("builder.search.summary.events", data.events.length)) +
` · ` +
escHtml(tCount("builder.search.summary.scenarios", data.scenarios.length)) +
` · ` +
escHtml(tCount("builder.search.summary.projects", data.projects.length)) +
`</div>`;
const sections: string[] = [counts];
if (data.events.length > 0) {
sections.push(renderGroup(
t("builder.search.group.events"),
data.events.map((e) => renderEventRow(e, lang)).join(""),
));
}
if (data.scenarios.length > 0) {
sections.push(renderGroup(
t("builder.search.group.scenarios"),
data.scenarios.map((s) => renderScenarioRow(s)).join(""),
));
}
if (data.projects.length > 0) {
sections.push(renderGroup(
t("builder.search.group.projects"),
data.projects.map((p) => renderProjectRow(p, lang)).join(""),
));
}
active.dropdown.innerHTML = sections.join("");
reposition();
}
function renderGroup(label: string, rowsHtml: string): string {
return `<section class="builder-search-group">` +
`<header class="builder-search-group-label">${escHtml(label)}</header>` +
rowsHtml +
`</section>`;
}
function renderEventRow(hit: EventSearchHit, lang: "de" | "en"): string {
const name = lang === "en" ? (hit.name_en || hit.name_de) : (hit.name_de || hit.name_en);
const ptName = lang === "en"
? (hit.proceeding_type.name_en || hit.proceeding_type.name_de)
: (hit.proceeding_type.name_de || hit.proceeding_type.name_en);
const party = hit.primary_party ? `<span class="builder-search-party">${escHtml(hit.primary_party)}</span>` : "";
const kind = hit.event_kind ? `<span class="builder-search-kind">${escHtml(hit.event_kind)}</span>` : "";
// Payload for the click handler — we embed the full hit so builder.ts
// doesn't need a second lookup. JSON-encoded into a data attribute,
// attr-escaped on the way in.
const payload = escAttr(JSON.stringify(hit));
return `<div class="builder-search-row" data-hit-kind="event" data-hit-payload="${payload}" tabindex="-1" role="option">` +
`<div class="builder-search-row-main">` +
`<span class="builder-search-pt-code">${escHtml(hit.proceeding_type.code)}</span>` +
`<span class="builder-search-event-name">${escHtml(name)}</span>` +
`</div>` +
`<div class="builder-search-row-meta">` +
`<span class="builder-search-pt-name">${escHtml(ptName)}</span>` +
kind + party +
`</div>` +
`</div>`;
}
function renderScenarioRow(hit: ScenarioSearchHit): string {
const payload = escAttr(JSON.stringify(hit));
return `<div class="builder-search-row" data-hit-kind="scenario" data-hit-payload="${payload}" tabindex="-1" role="option">` +
`<div class="builder-search-row-main">` +
`<span class="builder-search-scenario-name">${escHtml(hit.name)}</span>` +
`</div>` +
`<div class="builder-search-row-meta">` +
`<span class="builder-search-status">${escHtml(hit.status)}</span>` +
`</div>` +
`</div>`;
}
function renderProjectRow(hit: ProjectSearchHit, _lang: "de" | "en"): string {
const meta: string[] = [];
if (hit.case_number) meta.push(hit.case_number);
if (hit.matter_number) meta.push(hit.matter_number);
if (hit.client_number) meta.push(hit.client_number);
if (hit.reference) meta.push(hit.reference);
const metaText = meta.length > 0 ? meta.join(" · ") : "";
const payload = escAttr(JSON.stringify(hit));
return `<div class="builder-search-row" data-hit-kind="project" data-hit-payload="${payload}" tabindex="-1" role="option">` +
`<div class="builder-search-row-main">` +
`<span class="builder-search-project-type">${escHtml(hit.type)}</span>` +
`<span class="builder-search-project-title">${escHtml(hit.title)}</span>` +
`</div>` +
`<div class="builder-search-row-meta">${escHtml(metaText)}</div>` +
`</div>`;
}
function openDropdown(): void {
if (!active) return;
active.dropdown.hidden = false;
active.open = true;
reposition();
}
function closeDropdown(): void {
if (!active) return;
active.dropdown.hidden = true;
active.open = false;
if (active.abort) {
active.abort.abort();
active.abort = null;
}
}
function reposition(): void {
if (!active || !active.open) return;
const rect = active.input.getBoundingClientRect();
const top = rect.bottom + window.scrollY + 4;
const left = rect.left + window.scrollX;
const width = Math.max(rect.width, 380);
active.dropdown.style.position = "absolute";
active.dropdown.style.top = `${top}px`;
active.dropdown.style.left = `${left}px`;
active.dropdown.style.width = `${width}px`;
active.dropdown.style.zIndex = "60";
}
// tCount applies a simple plural pick: keys ".one" / ".other" carry
// the singular/plural variants; the caller's key is the bare stem.
function tCount(key: string, n: number): string {
const variant = n === 1 ? `${key}.one` : `${key}.other`;
return t(variant).replace("{n}", String(n));
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

View File

@@ -0,0 +1,271 @@
// ProceedingTriplet renderer for the Litigation Builder.
//
// PRD §3.3 + §3.4 + §6.1: one triplet = jurisdiction badge + name +
// perspective + Detailgrad + columnar `proaktiv | court | reaktiv`
// body.
//
// B2 wires the live controls — perspective radio, scenario-flag strip,
// remove button, collapse — and the per-event-card overlays (3-state
// machine, action buttons, optional-horizon chip). The 3-column body
// itself is still produced by verfahrensablauf-core.renderColumnsBody;
// per-card overlays are layered on top after innerHTML write via the
// data-rule-id hooks added in the same slice.
import { t, tDyn, getLang } from "./i18n";
import type { DeadlineResponse, Side } from "./views/verfahrensablauf-core";
import type { BuilderProceeding, BuilderEvent } from "./builder";
import type { ProceedingTypeMeta } from "./builder-picker";
export interface ScenarioFlagCatalogEntry {
flag_key: string;
label_de: string;
label_en: string;
description?: string;
hidden_unless_set: boolean;
}
export interface TripletViewInput {
proceeding: BuilderProceeding;
meta: ProceedingTypeMeta;
data: DeadlineResponse | null;
side: Side;
// Flag catalog filtered to the keys the active proceeding actually
// references via its rules' condition_expr. B2 passes the global
// catalog and lets the user toggle any — flags that don't gate any
// rule are simply no-ops on this triplet.
flagCatalog: ScenarioFlagCatalogEntry[];
// Map keyed by sequencing_rule_id (lowercased UUID) → BuilderEvent
// for the per-card state machine. Cards whose rule is absent default
// to "planned".
eventsByRule: Map<string, BuilderEvent>;
// Per-card optional-horizon registry. Each rule with optional
// children carries a `+N Optionen` chip; the chip's count comes from
// here (defaults to scenario_events.horizon_optional, falls back to
// proceeding-level when not stored per-card).
columnsHtml: string;
isChild: boolean;
}
// Triplet header + controls + columns body. Pure-string render; the
// caller (builder.ts) wires click handlers on top.
export function renderTriplet(input: TripletViewInput): string {
const lang = getLang();
const procLabel = lang === "en"
? (input.meta.nameEN || input.meta.name)
: (input.meta.name || input.meta.nameEN);
const flagsBadge = activeFlagsBadge(input.proceeding.scenario_flags);
const body = input.data
? input.columnsHtml
: `<div class="builder-triplet-loading">${escHtml(t("builder.triplet.loading"))}</div>`;
const controls = renderControls(input);
const flagStrip = renderFlagStrip(input);
return `
<header class="builder-triplet-header">
<span class="builder-triplet-jurisdiction">${escHtml(jurisdictionFor(input.meta))}</span>
<span class="builder-triplet-code">${escHtml(input.meta.code)}</span>
<span class="builder-triplet-name">${escHtml(procLabel)}</span>
${flagsBadge}
</header>
${controls}
${flagStrip}
<div class="builder-triplet-body">
${body}
</div>
`;
}
function renderControls(input: TripletViewInput): string {
const perspective = input.side ?? "";
const detailgrad = input.proceeding.detailgrad || "selected";
const radio = (value: string, key: string, current: string): string => {
const active = value === current ? " is-active" : "";
return `<button type="button" class="builder-triplet-perspective-btn${active}"
data-action="perspective" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
};
const detailBtn = (value: string, key: string, current: string): string => {
const active = value === current ? " is-active" : "";
return `<button type="button" class="builder-triplet-detailgrad-btn${active}"
data-action="detailgrad" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
};
return `<div class="builder-triplet-controls">
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.perspective.label"))}</span>
<div class="builder-triplet-perspective">
${radio("", "builder.triplet.perspective.none", perspective)}
${radio("claimant", "builder.triplet.perspective.claimant", perspective)}
${radio("defendant", "builder.triplet.perspective.defendant", perspective)}
</div>
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.detailgrad.label"))}</span>
<div class="builder-triplet-detailgrad">
${detailBtn("selected", "builder.triplet.detailgrad.selected", detailgrad)}
${detailBtn("all_options", "builder.triplet.detailgrad.all_options", detailgrad)}
</div>
<button type="button" class="builder-triplet-remove" data-action="remove">
${escHtml(t("builder.triplet.remove"))}
</button>
</div>`;
}
function renderFlagStrip(input: TripletViewInput): string {
// B2 ships the full global catalog. Flags that don't gate any of the
// active proceeding's rules are still toggle-able but have no effect
// on the calc result (the engine simply doesn't read them).
const lang = getLang();
const flags = input.proceeding.scenario_flags || {};
if (input.flagCatalog.length === 0) {
return `<div class="builder-triplet-flagstrip">
<span class="builder-triplet-flag-empty">${escHtml(t("builder.triplet.no_flags"))}</span>
</div>`;
}
const toggles = input.flagCatalog.map((entry) => {
const label = lang === "en" ? entry.label_en : entry.label_de;
const isOn = flags[entry.flag_key] === true;
return `<label class="builder-triplet-flag-toggle">
<input type="checkbox"
data-action="flag"
data-flag-key="${escAttr(entry.flag_key)}"
${isOn ? "checked" : ""} />
<span>${escHtml(label)}</span>
</label>`;
}).join("");
return `<div class="builder-triplet-flagstrip">${toggles}</div>`;
}
function jurisdictionFor(meta: ProceedingTypeMeta): string {
if (meta.jurisdiction) return meta.jurisdiction;
if (meta.group) return meta.group;
const dot = meta.code.indexOf(".");
if (dot > 0) return meta.code.slice(0, dot).toUpperCase();
return meta.code.toUpperCase();
}
function activeFlagsBadge(flags: Record<string, unknown>): string {
const active = Object.entries(flags).filter(([, v]) => v === true).map(([k]) => k);
if (active.length === 0) return "";
const label = t("builder.triplet.flags.label");
const chips = active.map((f) =>
`<span class="builder-triplet-flag-chip">${escHtml(f)}</span>`,
).join("");
return `<span class="builder-triplet-flags">${escHtml(label)} ${chips}</span>`;
}
// overlayEventStates walks the rendered .fr-col-item nodes and:
// - sets data-builder-state from eventsByRule lookup;
// - appends a per-card action row (file / skip / reset);
// - shows a +N Optionen chip when the rule has optional children
// (the chip placeholder; B2 ships the per-card horizon control —
// the actual horizon-count→render expansion lands when the calc
// engine surfaces "available optionals" for a parent rule, which
// pasteur's Options.IncludeOptional flag already exposes server-
// side; full wiring is a follow-up). Cards without optional
// children get no chip.
export function overlayEventStates(
root: HTMLElement,
eventsByRule: Map<string, BuilderEvent>,
on: {
onAction: (ruleId: string, action: "file" | "skip" | "reset", payload?: { date?: string; reason?: string }) => void;
onHorizon: (ruleId: string, delta: 1 | -1) => void;
},
): void {
const items = root.querySelectorAll<HTMLElement>(".fr-col-item[data-rule-id]");
items.forEach((item) => {
const ruleId = item.getAttribute("data-rule-id");
if (!ruleId) return;
const ev = eventsByRule.get(ruleId.toLowerCase());
const state = ev?.state || "planned";
item.setAttribute("data-builder-state", state);
// Append actions (idempotent: clear any prior overlay first).
item.querySelectorAll(".builder-event-actions, .builder-event-horizon-chip").forEach((n) => n.remove());
const actions = document.createElement("div");
actions.className = "builder-event-actions";
actions.innerHTML = actionButtonsHtml(state);
item.appendChild(actions);
actions.addEventListener("click", (ev) => {
const btn = (ev.target as HTMLElement).closest<HTMLElement>(".builder-event-action");
if (!btn) return;
const action = btn.getAttribute("data-action") as "file" | "skip" | "reset" | null;
if (!action) return;
ev.stopPropagation();
if (action === "file") {
const today = new Date().toISOString().slice(0, 10);
const v = window.prompt(t("builder.event.actual_date.prompt"), today);
if (v === null) return;
on.onAction(ruleId, "file", { date: v.trim() || today });
} else if (action === "skip") {
const reason = window.prompt(t("builder.event.skip_reason.prompt"), "");
if (reason === null) return;
on.onAction(ruleId, "skip", { reason: reason.trim() });
} else {
on.onAction(ruleId, "reset");
}
});
// Per-card optional horizon chip. The PRD §3.4 places the chip on
// every card with optional children; until the calc surface exposes
// an "optionals available count" on each parent rule, the chip is
// shown only when the card has a stored non-zero horizon (so the
// user can see and reduce a previously-set horizon). This is the
// graceful B2 baseline; the full surface lands once the engine
// emits an optionalsAvailable counter (PRD §3.4 follow-up).
const horizonCount = ev?.horizon_optional ?? 0;
if (horizonCount > 0) {
const chip = document.createElement("button");
chip.type = "button";
chip.className = "builder-event-horizon-chip";
chip.setAttribute("data-action", "horizon-toggle");
chip.textContent = t("builder.event.horizon.label").replace("{n}", String(horizonCount));
chip.addEventListener("click", (e) => {
e.stopPropagation();
on.onHorizon(ruleId, -1);
});
item.appendChild(chip);
} else {
// Inline "+ Optionen" affordance — adds a horizon entry when
// first clicked. Tagged as data-builder-feature so the cleanup
// sweep can rip it out if the calc surface lands a counter.
const chip = document.createElement("button");
chip.type = "button";
chip.className = "builder-event-horizon-chip";
chip.setAttribute("data-action", "horizon-add");
chip.setAttribute("data-builder-feature", "horizon-add");
chip.textContent = "+ " + t("builder.event.horizon.label").replace("+{n} ", "");
chip.addEventListener("click", (e) => {
e.stopPropagation();
on.onHorizon(ruleId, 1);
});
item.appendChild(chip);
}
});
}
function actionButtonsHtml(state: BuilderEvent["state"]): string {
// Re-render the action row per state. Cards in the planned state
// show "File / Skip"; filed/skipped cards show "Reset to planned".
if (state === "planned") {
return `
<button type="button" class="builder-event-action" data-action="file">${escHtml(t("builder.event.action.file"))}</button>
<button type="button" class="builder-event-action" data-action="skip">${escHtml(t("builder.event.action.skip"))}</button>
`;
}
return `<button type="button" class="builder-event-action" data-action="reset">${escHtml(t("builder.event.action.reset"))}</button>`;
}
function escHtml(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}

File diff suppressed because it is too large Load Diff

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,693 @@
// 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;
// m/paliad#149 Phase 2 S1 (design §2.4) — true when the rule's
// primary_party is the side opposite the perspective. Drives the
// Gegenseitig badge + muted style + unchecked default.
is_cross_party: boolean;
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 crossPartyBadge = r.is_cross_party
? `<span class="fristen-overhaul-rule-crossparty" title="${escAttr(t("deadlines.overhaul.crossparty.tooltip"))}">${escHtml(t("deadlines.overhaul.crossparty.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" : ""}${r.is_cross_party ? " is-cross-party" : ""}" 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}
${crossPartyBadge}
</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;
// Cross-party rows are unconditionally excluded from write-back
// (design §2.4). Even if the user manually checks the box, they
// describe what the opponent files — not Akte work for our side.
if (r.is_cross_party) 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;
// Skip cross-party rows even if checked — they describe opposing-
// side filings and don't belong in our side's Akte deadline set
// (design §2.4, write-back exclusion).
if (r.is_cross_party) 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 {
// Cross-party rows are unchecked by default — they describe what the
// OTHER side files. They render to honestly show the workflow, but
// the Akte write-back excludes them unconditionally (design §2.4).
if (r.is_cross_party) return false;
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);
}

File diff suppressed because it is too large Load Diff

View File

@@ -20,8 +20,6 @@ const translations: Record<Lang, Record<string, string>> = {
// Navigation
"nav.home": "Home",
"nav.kostenrechner": "Kostenrechner",
"nav.fristenrechner": "Fristenrechner",
"nav.verfahrensablauf": "Verfahrensablauf",
"nav.downloads": "Downloads",
"nav.links": "Links",
"nav.glossar": "Glossar",
@@ -79,7 +77,7 @@ const translations: Record<Lang, Record<string, string>> = {
"changelog.tag.fix": "Fix",
// Footer
"footer.text": "\u00a9 2026 Paliad \u2014 ein Werkzeug von",
"footer.text": "\u00a9 2026 Paliad \u2014 by",
// Landing page
"index.title": `Paliad \u2014 Patent Litigation f\u00fcr ${FIRM}`,
@@ -200,10 +198,100 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.heading": "Fristenrechner",
"deadlines.subtitle": "Berechnung von Verfahrensfristen f\u00fcr UPC-, deutsche und EPA-Verfahren.",
// Verfahrensablauf (t-paliad-179 Slice 1)
"tools.verfahrensablauf.title": "Verfahrensablauf \u2014 Paliad",
"tools.verfahrensablauf.heading": "Verfahrensablauf",
"tools.verfahrensablauf.subtitle": "Typischen Verfahrensablauf einsehen \u2014 Verfahrensart w\u00e4hlen, Datum optional setzen.",
// Unified procedural-events tool (m/paliad#151)
"procedures.title": "Verfahren & Fristen \u2014 Paliad",
"procedures.heading": "Verfahren & Fristen",
"procedures.subtitle": "Verfahrensablauf, Fristenrechner und gef\u00fchrte Suche in einem Tool.",
"procedures.filter.search.placeholder": "Klageerhebung, Hinweisbeschluss, oral hearing\u2026",
"procedures.filter.axis.forum": "Forum:",
"procedures.filter.axis.proc": "Verfahren:",
"procedures.filter.axis.kind": "Ereignisart:",
"procedures.filter.axis.party": "Partei:",
"procedures.tab.proceeding": "Verfahren w\u00e4hlen",
"procedures.tab.search": "Direkt suchen",
"procedures.tab.wizard": "Gef\u00fchrt",
"procedures.tab.akte": "Aus Akte",
"procedures.panel.akte.placeholder": "Akten-Einstieg folgt in einem sp\u00e4teren Slice.",
"nav.procedures": "Verfahren & Fristen",
// Litigation Builder (m/paliad#153 B1+B2)
"builder.subtitle": "Litigation Builder \u2014 Szenarien bauen, Verfahren stapeln, Fristen behalten.",
"builder.header.scenario": "Szenario:",
"builder.header.akte": "Akte:",
"builder.header.stichtag": "Stichtag:",
"builder.header.search": "Suche:",
"builder.akte.none": "\u2014 ohne \u2014",
"builder.search.placeholder": "Ereignis, Szenario, Akte \u2026",
"builder.action.rename": "Benennen",
"builder.action.rename.prompt": "Name f\u00fcr dieses Szenario:",
"builder.action.share": "Teilen",
"builder.action.promote": "Als Projekt anlegen",
"builder.mode.cold": "\u00dcbersicht",
"builder.mode.event": "Ereignis",
"builder.mode.akte": "Aus Akte",
"builder.panel.title": "Meine Szenarien",
"builder.panel.new": "+ Neues Szenario",
"builder.panel.empty": "Noch keine Szenarien.",
"builder.bucket.active": "Aktiv",
"builder.empty.headline": "Noch kein Szenario ge\u00f6ffnet.",
"builder.empty.hint": "Starte ein neues Szenario, w\u00e4hle aus deiner Liste oder \u00fcbernimm eine Akte (B4).",
"builder.empty.cta": "Neues Szenario starten",
"builder.empty.recent": "Zuletzt bearbeitet",
"builder.picker.placeholder": "\u2014 Szenario w\u00e4hlen \u2014",
"builder.picker.title": "Verfahren hinzuf\u00fcgen",
"builder.picker.close": "Schlie\u00dfen",
"builder.picker.aria": "Verfahren ausw\u00e4hlen",
"builder.picker.axis.forum": "Forum:",
"builder.picker.axis.proc": "Verfahren:",
"builder.picker.empty": "Keine Verfahren verf\u00fcgbar.",
"builder.picker.future_jurisdiction": "Andere Foren folgen sp\u00e4ter.",
"builder.canvas.add_proceeding": "+ Verfahren hinzuf\u00fcgen",
"builder.triplet.loading": "Berechne Fristen \u2026",
"builder.triplet.unknown_proceeding": "Unbekannter Verfahrenstyp.",
"builder.triplet.side.claimant": "Kl\u00e4ger-Sicht",
"builder.triplet.side.defendant": "Beklagten-Sicht",
"builder.triplet.flags.label": "Optionen:",
"builder.triplet.perspective.label": "Perspektive:",
"builder.triplet.perspective.none": "keine",
"builder.triplet.perspective.claimant": "Kl\u00e4ger",
"builder.triplet.perspective.defendant": "Beklagter",
"builder.triplet.detailgrad.label": "Detailgrad:",
"builder.triplet.detailgrad.selected": "Gew\u00e4hlt",
"builder.triplet.detailgrad.all_options": "Alle Optionen",
"builder.triplet.remove": "Entfernen",
"builder.triplet.collapse": "Einklappen",
"builder.triplet.expand": "Ausklappen",
"builder.triplet.no_flags": "(keine Flags f\u00fcr diesen Verfahrenstyp)",
"builder.event.state.planned": "geplant",
"builder.event.state.filed": "eingereicht",
"builder.event.state.skipped": "ausgelassen",
"builder.event.action.file": "Einreichen",
"builder.event.action.skip": "Auslassen",
"builder.event.action.reset": "Zur\u00fcck zu geplant",
"builder.event.actual_date.prompt": "Datum der Einreichung:",
"builder.event.skip_reason.prompt": "Grund (optional):",
"builder.event.horizon.label": "+{n} Optionen \u25be",
"builder.event.horizon.hide": "Optionen ausblenden",
"builder.save.idle": "\u00a0",
"builder.save.saving": "Speichert \u2026",
"builder.save.saved": "Gespeichert \u2713",
"builder.save.error": "Speichern fehlgeschlagen",
"builder.search.hint.start": "Tippe \u2026 z.\u202fB. \u201eKlageerwiderung\u201c, \u201eHinweis\u201c, \u201eHL-2024\u201c",
"builder.search.hint.short": "Mindestens 2 Zeichen.",
"builder.search.hint.loading": "Suche \u2026",
"builder.search.hint.empty": "Keine Treffer.",
"builder.search.hint.error": "Suche fehlgeschlagen. Erneut versuchen.",
"builder.search.hint.akte_b4": "Akten-Modus folgt in B4.",
"builder.search.group.events": "Ereignisse",
"builder.search.group.scenarios": "Szenarien",
"builder.search.group.projects": "Akten",
"builder.search.summary.events.one": "{n} Ereignis",
"builder.search.summary.events.other": "{n} Ereignisse",
"builder.search.summary.scenarios.one": "{n} Szenario",
"builder.search.summary.scenarios.other": "{n} Szenarien",
"builder.search.summary.projects.one": "{n} Akte",
"builder.search.summary.projects.other": "{n} Akten",
"builder.search.anchor.divider": "\u2501\u2501\u2501\u2501 DU BIST HIER \u2501\u2501\u2501\u2501",
"deadlines.step1": "Verfahrensart w\u00e4hlen",
"deadlines.step2": "Ausgangsdatum eingeben",
@@ -1010,6 +1098,89 @@ 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.detail.label": "Anzeige:",
"deadlines.detail.mandatory_only": "Nur Pflicht",
"deadlines.detail.selected": "Gewählt",
"deadlines.detail.all_options": "Alle Optionen",
"deadlines.detail.optional_unselected_hint": "Diese Regel ist optional und gehört nicht zum aktuellen Szenario.",
"deadlines.detail.aufnehmen": "Aufnehmen",
"deadlines.detail.entfernen": "Entfernen",
"deadlines.overhaul.condition.badge": "Nur unter Bedingung",
"deadlines.overhaul.crossparty.badge": "Gegenseitig",
"deadlines.overhaul.crossparty.tooltip": "Diese Frist wird von der Gegenseite eingereicht. Sie erscheint nur zur Information und wird nicht in die Akte übernommen.",
"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",
@@ -1520,6 +1691,18 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.language.de": "DE",
"submissions.draft.language.en": "EN",
"submissions.draft.language.fallback_notice": "Fallback: universelles Skelett (keine sprachspezifische Vorlage).",
// t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list.
"submissions.draft.base.label": "Vorlagenbasis",
"submissions.draft.base.hint": "Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.",
"submissions.draft.sections.title": "Abschnitte",
"submissions.draft.sections.hint": "Inhalt pro Abschnitt — Autosave nach 500 ms. Letztes Layout in Word.",
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
"admin.building_blocks.title": "Bausteine — Paliad",
"admin.building_blocks.heading": "Bausteine",
"admin.building_blocks.subtitle": "Wiederverwendbare Textbausteine für Composer-Abschnitte.",
"admin.building_blocks.loading": "Lädt…",
"admin.building_blocks.action.new": "+ Neuer Baustein",
"admin.building_blocks.editor.empty": "Wählen Sie einen Baustein aus der Liste — oder erstellen Sie einen neuen.",
// t-paliad-240 — global Schriftsätze drafts index page.
"submissions.index.title": "Schriftsätze — Paliad",
"submissions.index.heading": "Schriftsätze",
@@ -2893,10 +3076,11 @@ const translations: Record<Lang, Record<string, string>> = {
// t-paliad-192 Slice 11b — Admin rule-editor UI.
// t-paliad-262 Slice A — "Regel" relabelled as "Verfahrensschritt".
// The admin URL `/admin/rules` and i18n key prefix `admin.rules.*` stay
// (URL change is Slice B.6); the visible labels rename. Canonical
// `admin.procedural_events.*` aliases live after the EN block — they
// pin the contract for when .tsx files rebind in Slice B (B.5).
// t-paliad-305 Slice B.6 (2026-05-26) — canonical URL moved to
// `/admin/procedural-events` (301 redirects from /admin/rules*).
// The i18n keys `admin.rules.*` are kept as the corpus until a
// follow-up slice migrates each reference; canonical
// `admin.procedural_events.*` aliases live after the EN block.
"nav.admin.rules": "Verfahrensschritte verwalten",
"admin.card.rules.title": "Verfahrensschritte verwalten",
"admin.card.rules.desc": "Verfahrensschritte anlegen, bearbeiten, publishen. Audit-Log, Preview, Migration-Export.",
@@ -3107,6 +3291,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)",
@@ -3118,8 +3305,6 @@ const translations: Record<Lang, Record<string, string>> = {
// Navigation
"nav.home": "Home",
"nav.kostenrechner": "Cost Calculator",
"nav.fristenrechner": "Deadline Calculator",
"nav.verfahrensablauf": "Procedure Roadmap",
"nav.downloads": "Downloads",
"nav.links": "Links",
"nav.glossar": "Glossary",
@@ -3174,7 +3359,7 @@ const translations: Record<Lang, Record<string, string>> = {
"changelog.tag.fix": "Fix",
// Footer
"footer.text": "\u00a9 2026 Paliad \u2014 a tool by",
"footer.text": "\u00a9 2026 Paliad \u2014 by",
// Landing page
"index.title": `Paliad \u2014 Patent Litigation for ${FIRM}`,
@@ -3296,9 +3481,100 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.subtitle": "Calculate procedural deadlines for UPC, German, and EPA proceedings.",
// Verfahrensablauf (t-paliad-179 Slice 1)
"tools.verfahrensablauf.title": "Procedure Roadmap \u2014 Paliad",
"tools.verfahrensablauf.heading": "Procedure Roadmap",
"tools.verfahrensablauf.subtitle": "Browse the typical proceeding shape \u2014 pick a proceeding type, optionally set a trigger date.",
// Unified procedural-events tool (m/paliad#151)
"procedures.title": "Procedures & Deadlines \u2014 Paliad",
"procedures.heading": "Procedures & Deadlines",
"procedures.subtitle": "Procedure roadmap, deadline calculator, and guided search in one tool.",
"procedures.filter.search.placeholder": "Statement of claim, hearing notice, m\u00fcndliche Verhandlung\u2026",
"procedures.filter.axis.forum": "Forum:",
"procedures.filter.axis.proc": "Proceeding:",
"procedures.filter.axis.kind": "Event kind:",
"procedures.filter.axis.party": "Party:",
"procedures.tab.proceeding": "Pick proceeding",
"procedures.tab.search": "Direct search",
"procedures.tab.wizard": "Guided",
"procedures.tab.akte": "From matter",
"procedures.panel.akte.placeholder": "Matter entry ships in a later slice.",
"nav.procedures": "Procedures & Deadlines",
// Litigation Builder (m/paliad#153 B1+B2)
"builder.subtitle": "Litigation Builder — build scenarios, stack proceedings, track deadlines.",
"builder.header.scenario": "Scenario:",
"builder.header.akte": "Matter:",
"builder.header.stichtag": "Anchor:",
"builder.header.search": "Search:",
"builder.akte.none": "— none —",
"builder.search.placeholder": "Event, scenario, matter …",
"builder.action.rename": "Name it",
"builder.action.rename.prompt": "Name for this scenario:",
"builder.action.share": "Share",
"builder.action.promote": "Create as project",
"builder.mode.cold": "Overview",
"builder.mode.event": "Event",
"builder.mode.akte": "From matter",
"builder.panel.title": "My scenarios",
"builder.panel.new": "+ New scenario",
"builder.panel.empty": "No scenarios yet.",
"builder.bucket.active": "Active",
"builder.empty.headline": "No scenario open.",
"builder.empty.hint": "Start a new scenario, pick one from your list, or load a matter (B4).",
"builder.empty.cta": "Start a new scenario",
"builder.empty.recent": "Recent",
"builder.picker.placeholder": "— pick a scenario —",
"builder.picker.title": "Add proceeding",
"builder.picker.close": "Close",
"builder.picker.aria": "Pick a proceeding",
"builder.picker.axis.forum": "Forum:",
"builder.picker.axis.proc": "Proceeding:",
"builder.picker.empty": "No proceedings available.",
"builder.picker.future_jurisdiction": "Other forums coming later.",
"builder.canvas.add_proceeding": "+ Add proceeding",
"builder.triplet.loading": "Calculating deadlines …",
"builder.triplet.unknown_proceeding": "Unknown proceeding type.",
"builder.triplet.side.claimant": "Claimant view",
"builder.triplet.side.defendant": "Defendant view",
"builder.triplet.flags.label": "Options:",
"builder.triplet.perspective.label": "Perspective:",
"builder.triplet.perspective.none": "none",
"builder.triplet.perspective.claimant": "Claimant",
"builder.triplet.perspective.defendant": "Defendant",
"builder.triplet.detailgrad.label": "Detail:",
"builder.triplet.detailgrad.selected": "Selected",
"builder.triplet.detailgrad.all_options": "All options",
"builder.triplet.remove": "Remove",
"builder.triplet.collapse": "Collapse",
"builder.triplet.expand": "Expand",
"builder.triplet.no_flags": "(no flags for this proceeding type)",
"builder.event.state.planned": "planned",
"builder.event.state.filed": "filed",
"builder.event.state.skipped": "skipped",
"builder.event.action.file": "File",
"builder.event.action.skip": "Skip",
"builder.event.action.reset": "Reset to planned",
"builder.event.actual_date.prompt": "Date of filing:",
"builder.event.skip_reason.prompt": "Reason (optional):",
"builder.event.horizon.label": "+{n} optional ▾",
"builder.event.horizon.hide": "Hide optional",
"builder.save.idle": " ",
"builder.save.saving": "Saving …",
"builder.save.saved": "Saved ✓",
"builder.save.error": "Save failed",
"builder.search.hint.start": "Type … e.g. \"defence\", \"hearing\", \"HL-2024\"",
"builder.search.hint.short": "At least 2 characters.",
"builder.search.hint.loading": "Searching …",
"builder.search.hint.empty": "No matches.",
"builder.search.hint.error": "Search failed. Try again.",
"builder.search.hint.akte_b4": "Matter mode coming in B4.",
"builder.search.group.events": "Events",
"builder.search.group.scenarios": "Scenarios",
"builder.search.group.projects": "Matters",
"builder.search.summary.events.one": "{n} event",
"builder.search.summary.events.other": "{n} events",
"builder.search.summary.scenarios.one": "{n} scenario",
"builder.search.summary.scenarios.other": "{n} scenarios",
"builder.search.summary.projects.one": "{n} matter",
"builder.search.summary.projects.other": "{n} matters",
"builder.search.anchor.divider": "━━━━ YOU ARE HERE ━━━━",
"deadlines.step1": "Select Proceeding Type",
"deadlines.step2": "Enter Trigger Date",
@@ -4106,6 +4382,89 @@ 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.detail.label": "Detail:",
"deadlines.detail.mandatory_only": "Mandatory only",
"deadlines.detail.selected": "Selected",
"deadlines.detail.all_options": "All options",
"deadlines.detail.optional_unselected_hint": "This rule is optional and not part of the current scenario.",
"deadlines.detail.aufnehmen": "Add",
"deadlines.detail.entfernen": "Remove",
"deadlines.overhaul.condition.badge": "Conditional",
"deadlines.overhaul.crossparty.badge": "Other side",
"deadlines.overhaul.crossparty.tooltip": "This deadline is filed by the opposing party. Shown for information only — not added to the Akte.",
"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",
@@ -4596,6 +4955,18 @@ const translations: Record<Lang, Record<string, string>> = {
"submissions.draft.import.button": "Import from project",
"submissions.draft.parties.title": "Parties",
"submissions.draft.parties.hint": "Pick the parties mentioned in this submission, or add more per side.",
// t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list.
"submissions.draft.base.label": "Template base",
"submissions.draft.base.hint": "Drives fonts, letterhead, and section defaults.",
"submissions.draft.sections.title": "Sections",
"submissions.draft.sections.hint": "Edit per section — autosaves after 500ms. Final layout in Word.",
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
"admin.building_blocks.title": "Building blocks — Paliad",
"admin.building_blocks.heading": "Building blocks",
"admin.building_blocks.subtitle": "Reusable text snippets for Composer sections.",
"admin.building_blocks.loading": "Loading…",
"admin.building_blocks.action.new": "+ New block",
"admin.building_blocks.editor.empty": "Pick a block from the list — or create a new one.",
// t-paliad-240 — global submissions drafts index page.
"submissions.index.title": "Submissions — Paliad",
"submissions.index.heading": "Submissions",
@@ -6163,6 +6534,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

@@ -109,7 +109,7 @@ export function routeNameFor(pathname: string): string {
if (pathname === "/links") return "links";
if (pathname === "/downloads") return "downloads";
if (pathname === "/checklists") return "checklists";
if (pathname.startsWith("/tools/fristenrechner")) return "tools.fristenrechner";
if (pathname.startsWith("/tools/procedures")) return "tools.procedures";
if (pathname.startsWith("/tools/kostenrechner")) return "tools.kostenrechner";
if (pathname.startsWith("/tools/gebuehrentabellen")) return "tools.gebuehrentabellen";
if (pathname === "/events") return "events";

View File

@@ -0,0 +1,15 @@
// /tools/procedures bundle entry — Litigation Builder (m/paliad#153 B1).
//
// Replaces cronus's U0-U4 catalog bootstrap. The page chrome is
// emitted by procedures.tsx; this file boots the i18n + sidebar
// runtime and hands off to builder.ts.
import { initI18n } from "./i18n";
import { initSidebar } from "./sidebar";
import { mountBuilder } from "./builder";
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
void mountBuilder();
});

View File

@@ -0,0 +1,125 @@
// Per-project scenario_flags client — the single source of truth
// (m/paliad#149 Phase 2 P0, mig 154). Wraps GET/PATCH
// /api/projects/{id}/scenario-flags so any project-bound surface can
// read + write the same flag map.
//
// Shape on the wire:
//
// GET → { flags: { "with_ccr": true, "rule:<uuid>": false }, catalog: [...] }
// PATCH body: { "with_ccr": true, "with_amend": null }
// - bool → write the value verbatim
// - null → delete the key (priority-driven default returns)
// - undefined → caller never sends this key; the value is left alone
//
// Cross-surface coherence: every successful PATCH dispatches a
// `scenario-flag-changed` CustomEvent on document so other surfaces
// (Verfahrensablauf strip, Mode B result-view conditional group) can
// re-render without a fresh fetch. Detail carries the merged map so
// listeners can use it directly.
export interface ScenarioFlagCatalogEntry {
flag_key: string;
label_de: string;
label_en: string;
description?: string;
hidden_unless_set: boolean;
}
export interface ScenarioFlagsView {
flags: Record<string, boolean>;
catalog: ScenarioFlagCatalogEntry[];
}
// PatchDelta represents a partial update. Keys present with `null`
// delete the entry; keys present with a bool overwrite; keys not
// present are left untouched.
export type ScenarioFlagsDelta = Record<string, boolean | null>;
export interface ScenarioFlagChangedDetail {
projectId: string;
flags: Record<string, boolean>;
// The keys that were touched by the PATCH that fired this event.
// Useful for surfaces that re-render only when *their* flag moved.
changedKeys: string[];
}
export const SCENARIO_FLAG_CHANGED_EVENT = "scenario-flag-changed";
// fetchScenarioFlags loads the current state and catalog for a project.
// Returns null if the project is invisible to the caller (404 path) or
// the server rejected the request — callers should fall back to local
// defaults in that case rather than surfacing a hard error to the UI.
export async function fetchScenarioFlags(projectId: string): Promise<ScenarioFlagsView | null> {
if (!projectId) return null;
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/scenario-flags`, {
headers: { Accept: "application/json" },
});
if (!resp.ok) {
if (resp.status === 401 || resp.status === 403 || resp.status === 404) {
return null;
}
console.warn(`scenario-flags GET ${resp.status}`);
return null;
}
return (await resp.json()) as ScenarioFlagsView;
} catch (e) {
console.error("scenario-flags GET failed", e);
return null;
}
}
// patchScenarioFlags writes a delta. Returns the merged map on success;
// returns null on failure (caller decides whether to roll back UI).
// Dispatches `scenario-flag-changed` on success so peer surfaces can
// re-sync.
export async function patchScenarioFlags(
projectId: string,
delta: ScenarioFlagsDelta,
): Promise<ScenarioFlagsView | null> {
if (!projectId) return null;
if (Object.keys(delta).length === 0) return null;
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/scenario-flags`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(delta),
});
if (!resp.ok) {
console.warn(`scenario-flags PATCH ${resp.status}`);
return null;
}
const view = (await resp.json()) as ScenarioFlagsView;
dispatchScenarioFlagChanged(projectId, view.flags, Object.keys(delta));
return view;
} catch (e) {
console.error("scenario-flags PATCH failed", e);
return null;
}
}
function dispatchScenarioFlagChanged(
projectId: string,
flags: Record<string, boolean>,
changedKeys: string[],
): void {
const detail: ScenarioFlagChangedDetail = { projectId, flags, changedKeys };
document.dispatchEvent(new CustomEvent(SCENARIO_FLAG_CHANGED_EVENT, { detail }));
}
// onScenarioFlagsChanged subscribes a listener and returns an
// unsubscribe function. Convenient for surfaces wired by lifecycle
// hooks (init / teardown).
export function onScenarioFlagsChanged(
listener: (detail: ScenarioFlagChangedDetail) => void,
): () => void {
const handler = (e: Event) => {
const detail = (e as CustomEvent<ScenarioFlagChangedDetail>).detail;
if (detail) listener(detail);
};
document.addEventListener(SCENARIO_FLAG_CHANGED_EVENT, handler);
return () => document.removeEventListener(SCENARIO_FLAG_CHANGED_EVENT, handler);
}

View File

@@ -28,10 +28,47 @@ interface SubmissionDraftJSON {
last_exported_at?: string | null;
last_exported_sha?: string | null;
last_imported_at?: string | null;
// t-paliad-313 Composer Slice A — base reference + Composer-side
// metadata. base_id is null on pre-Composer drafts (the v1 render
// path stays the fallback). composer_meta carries the seed-time
// section order in later slices.
base_id?: string | null;
composer_meta?: Record<string, unknown>;
created_at: string;
updated_at: string;
}
// t-paliad-313 Composer Slice A — per-draft section row, surfaced
// read-only in the editor body. Slice B adds inline edit + PATCH.
interface SubmissionSectionJSON {
id: string;
section_key: string;
order_index: number;
kind: string;
label_de: string;
label_en: string;
included: boolean;
content_md_de: string;
content_md_en: string;
}
// t-paliad-313 Composer Slice A — base catalog row, surfaced in the
// sidebar picker dropdown.
interface SubmissionBaseRow {
id: string;
slug: string;
firm?: string | null;
proceeding_family?: string | null;
label_de: string;
label_en: string;
description_de?: string | null;
description_en?: string | null;
gitea_path: string;
is_default_for: string[];
is_active: boolean;
section_count: number;
}
interface AvailablePartyJSON {
id: string;
name: string;
@@ -64,6 +101,9 @@ interface SubmissionDraftView {
// language has no per-firm language-matched template.
template_tier?: string;
language_fallback?: boolean;
// t-paliad-313 Composer Slice A — per-draft section stack. Empty
// for pre-Composer drafts where no rows have been seeded.
sections: SubmissionSectionJSON[];
}
interface SubmissionDraftListResponse {
@@ -328,6 +368,11 @@ interface State {
addPartyMode: "manual" | "search";
addPartySearchHits: PartySearchHit[];
addPartyBusy: boolean;
// t-paliad-313 Composer Slice A — base catalog fetched once on boot.
// Picker hidden until populated; empty array (after the fetch
// completes) keeps the picker hidden permanently for this load.
bases: SubmissionBaseRow[];
basesLoaded: boolean;
}
type PartySide = "claimant" | "defendant" | "other";
@@ -354,6 +399,8 @@ const state: State = {
addPartyMode: "manual",
addPartySearchHits: [],
addPartyBusy: false,
bases: [],
basesLoaded: false,
};
// ─────────────────────────────────────────────────────────────────────
@@ -371,6 +418,14 @@ async function boot(): Promise<void> {
}
state.parsed = parsed;
// t-paliad-313 Composer Slice A — kick the base catalog fetch in
// parallel with the view load. The picker hydrates when both land;
// either failing leaves the picker hidden but the editor functional.
loadBases().catch(err => {
console.warn("submission-draft: base catalog fetch failed", err);
state.basesLoaded = true;
});
try {
if (parsed.mode === "global") {
// Global path: we have a draft_id, fetch by id alone. Drafts
@@ -523,11 +578,13 @@ function paint(): void {
paintNoProjectBanner();
paintSwitcher();
paintNameRow();
paintBasePicker();
paintImportRow();
paintPartyPicker();
paintLanguageRow();
paintLanguageFallback();
paintVariables();
paintSectionList();
paintPreview();
}
@@ -1143,6 +1200,869 @@ function paintPreview(): void {
}
}
// ─────────────────────────────────────────────────────────────────────
// t-paliad-313 Composer Slice A — base picker + section list
// ─────────────────────────────────────────────────────────────────────
async function loadBases(): Promise<void> {
const res = await fetch("/api/submission-bases", { credentials: "include" });
if (!res.ok) {
throw new Error("base list HTTP " + res.status);
}
const body = await res.json() as { bases?: SubmissionBaseRow[] };
state.bases = body.bases ?? [];
state.basesLoaded = true;
// If the view has already painted, re-paint the picker so it
// hydrates as soon as the catalog lands. paint() is idempotent.
if (state.view) paintBasePicker();
}
function paintBasePicker(): void {
const row = document.getElementById("submission-draft-base-row") as HTMLDivElement | null;
const sel = document.getElementById("submission-draft-base") as HTMLSelectElement | null;
if (!row || !sel || !state.view) return;
// Hide the picker until the catalog has loaded AND the catalog has
// at least one entry. A failed fetch (basesLoaded=true, bases empty)
// keeps the picker hidden indefinitely so the editor stays usable.
if (!state.basesLoaded || state.bases.length === 0) {
row.style.display = "none";
return;
}
row.style.display = "";
// Rebuild the <option> list each paint so language toggles + base
// catalog updates flow through.
sel.innerHTML = "";
const currentBaseID = state.view.draft.base_id ?? "";
// "Keine Vorlagenbasis" only listed when the draft is currently in
// that state (pre-Composer / cleared). Avoids tempting the lawyer
// to clear after they've already picked one.
if (!currentBaseID) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = isEN() ? "— no base —" : "— keine Vorlagenbasis —";
sel.appendChild(opt);
}
for (const b of state.bases) {
const opt = document.createElement("option");
opt.value = b.id;
opt.textContent = isEN() ? b.label_en : b.label_de;
if (b.id === currentBaseID) opt.selected = true;
sel.appendChild(opt);
}
// Wire change handler once per paint. Removing then re-adding
// keeps the binding consistent across repaints (e.g. after
// language toggle re-renders the labels).
sel.onchange = () => { onBaseChange(sel.value); };
}
async function onBaseChange(newBaseID: string): Promise<void> {
if (!state.view) return;
const payload: Record<string, unknown> = {
// Empty string in the picker maps to null = clear.
base_id: newBaseID === "" ? null : newBaseID,
};
try {
const res = await fetch(
`/api/submission-drafts/${state.view.draft.id}`,
{
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!res.ok) {
console.warn("base swap PATCH failed", res.status);
return;
}
const view = await res.json() as SubmissionDraftView;
state.view = view;
paint();
} catch (err) {
console.warn("base swap PATCH error", err);
}
}
// sectionAutosaveTimers — one debounce timer per section id so two
// sections autosaving simultaneously don't trample each other. Reset
// on each keystroke; 500ms after the last keystroke the patch fires.
const sectionAutosaveTimers: Record<string, number> = {};
const SECTION_AUTOSAVE_MS = 500;
function paintSectionList(): void {
const wrap = document.getElementById("submission-draft-sections-wrap");
const list = document.getElementById("submission-draft-sections-list") as HTMLOListElement | null;
if (!wrap || !list || !state.view) return;
const sections = state.view.sections ?? [];
if (sections.length === 0) {
wrap.style.display = "none";
return;
}
wrap.style.display = "";
// Don't blow away the editor if a section is currently focused —
// would steal cursor + selection mid-type. The patch round-trip
// returns the updated row, but paintSectionList only re-renders
// when the focused section isn't being edited (or the new render
// is being driven by something other than the active editor itself).
const activeID = activeSectionEditorID();
list.innerHTML = "";
const lang = state.view.draft.language || state.view.lang || "de";
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 {
const li = document.createElement("li");
li.className = "submission-draft-section";
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;
head.appendChild(title);
const kind = document.createElement("span");
kind.className = "submission-draft-section-kind";
kind.textContent = sec.kind;
head.appendChild(kind);
if (!sec.included) {
const muted = document.createElement("span");
muted.className = "submission-draft-section-excluded-badge";
muted.textContent = isEN() ? "excluded" : "ausgeblendet";
head.appendChild(muted);
}
// Per-section "Aufnehmen" / "Ausblenden" toggle in the head — flips
// `included` via PATCH and re-paints.
const toggle = document.createElement("button");
toggle.type = "button";
toggle.className = "btn-small btn-secondary submission-draft-section-toggle";
toggle.textContent = sec.included
? (isEN() ? "Hide" : "Ausblenden")
: (isEN() ? "Include" : "Aufnehmen");
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 +
// bullet/numbered list + blockquote + hyperlink. Plus the Slice C
// building-block button. execCommand drives bold/italic/headings/
// lists/blockquote; hyperlink uses createLink with a prompt.
const toolbar = document.createElement("div");
toolbar.className = "submission-draft-section-toolbar";
toolbar.appendChild(makeToolbarButton("B", isEN() ? "Bold" : "Fett", "bold"));
toolbar.appendChild(makeToolbarButton("I", isEN() ? "Italic" : "Kursiv", "italic"));
toolbar.appendChild(makeHeadingButton("H1", isEN() ? "Heading 1" : "Überschrift 1", 1));
toolbar.appendChild(makeHeadingButton("H2", isEN() ? "Heading 2" : "Überschrift 2", 2));
toolbar.appendChild(makeHeadingButton("H3", isEN() ? "Heading 3" : "Überschrift 3", 3));
toolbar.appendChild(makeToolbarButton("•", isEN() ? "Bullet list" : "Aufzählung", "insertUnorderedList"));
toolbar.appendChild(makeToolbarButton("1.", isEN() ? "Numbered list" : "Nummerierte Liste", "insertOrderedList"));
toolbar.appendChild(makeToolbarButton("”", isEN() ? "Blockquote" : "Zitat", "formatBlock", "blockquote"));
toolbar.appendChild(makeLinkButton());
// t-paliad-315 Slice C — building-block insert button. Opens a
// picker modal filtered to this section's section_key. Paste is
// plain-text per Q2 (no lineage stamped).
const bbBtn = document.createElement("button");
bbBtn.type = "button";
bbBtn.className = "btn-small btn-secondary submission-draft-section-bb-btn";
bbBtn.textContent = isEN() ? "+ Block" : "+ Baustein";
bbBtn.title = isEN() ? "Insert a saved building block" : "Baustein einfügen";
bbBtn.addEventListener("click", () => openBlockPicker(sec));
toolbar.appendChild(bbBtn);
li.appendChild(toolbar);
const md = (lang === "en" ? sec.content_md_en : sec.content_md_de) || "";
const editor = document.createElement("div");
editor.className = "submission-draft-section-editor";
editor.contentEditable = "true";
editor.spellcheck = true;
editor.dataset.sectionId = sec.id;
editor.dataset.lang = lang;
editor.dataset.placeholder = isEN()
? "Write section content…"
: "Abschnittstext eingeben…";
// Paint the Markdown as plain text on first render — the editor's
// source of truth is Markdown, the DOM is the view. Lawyer types,
// we serialise back to MD on autosave.
editor.textContent = md;
editor.addEventListener("input", () => onSectionInput(editor));
editor.addEventListener("focus", () => {
li.classList.add("submission-draft-section--editing");
});
editor.addEventListener("blur", () => {
li.classList.remove("submission-draft-section--editing");
// Force-flush any pending autosave so we don't leave unsynced
// edits hanging when the lawyer tabs out.
flushSectionAutosave(sec.id);
});
li.appendChild(editor);
if (isActive) {
// The repaint happened while this section was focused — restore
// focus to it. Cursor placement at the end is a fair default
// (typing mid-content during a repaint is rare; the autosave path
// typically doesn't repaint at all).
queueMicrotask(() => {
const fresh = document.querySelector(`.submission-draft-section-editor[data-section-id="${cssEscape(sec.id)}"]`) as HTMLDivElement | null;
if (fresh) {
fresh.focus();
placeCaretAtEnd(fresh);
}
});
}
return li;
}
function makeToolbarButton(label: string, title: string, format: string, value?: string): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "submission-draft-section-toolbar-btn";
btn.textContent = label;
btn.title = title;
// Mousedown rather than click so the editor doesn't lose focus
// mid-command — execCommand requires the editor to be the active
// selection target.
btn.addEventListener("mousedown", (ev) => {
ev.preventDefault();
document.execCommand(format, false, value);
// Trigger the input handler so autosave fires.
const editor = document.activeElement as HTMLElement | null;
if (editor && editor.classList.contains("submission-draft-section-editor")) {
onSectionInput(editor as HTMLDivElement);
}
});
return btn;
}
// makeHeadingButton emits an `<h1|h2|h3>` wrapping for the active
// block via execCommand("formatBlock", "h1") etc. Toggling the same
// heading back to a paragraph is handled by clicking the same button
// again (the browser's execCommand semantics).
function makeHeadingButton(label: string, title: string, level: 1 | 2 | 3): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "submission-draft-section-toolbar-btn";
btn.textContent = label;
btn.title = title;
btn.addEventListener("mousedown", (ev) => {
ev.preventDefault();
document.execCommand("formatBlock", false, "h" + level);
const editor = document.activeElement as HTMLElement | null;
if (editor && editor.classList.contains("submission-draft-section-editor")) {
onSectionInput(editor as HTMLDivElement);
}
});
return btn;
}
// makeLinkButton prompts for a URL and wraps the current selection
// (or inserts a label-as-URL if nothing selected). The browser's
// createLink built-in wires the <a href="…"> tag into the DOM;
// domToMarkdown reads it back as `[label](url)`.
function makeLinkButton(): HTMLButtonElement {
const btn = document.createElement("button");
btn.type = "button";
btn.className = "submission-draft-section-toolbar-btn";
btn.textContent = "🔗";
btn.title = isEN() ? "Insert link" : "Link einfügen";
btn.addEventListener("mousedown", (ev) => {
ev.preventDefault();
const url = prompt(isEN() ? "URL:" : "URL:");
if (!url) return;
document.execCommand("createLink", false, url);
const editor = document.activeElement as HTMLElement | null;
if (editor && editor.classList.contains("submission-draft-section-editor")) {
onSectionInput(editor as HTMLDivElement);
}
});
return btn;
}
function activeSectionEditorID(): string | null {
const active = document.activeElement as HTMLElement | null;
if (!active || !active.classList.contains("submission-draft-section-editor")) return null;
return active.dataset.sectionId ?? null;
}
function placeCaretAtEnd(el: HTMLElement): void {
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = window.getSelection();
if (!sel) return;
sel.removeAllRanges();
sel.addRange(range);
}
function onSectionInput(editor: HTMLDivElement): void {
const id = editor.dataset.sectionId;
if (!id) return;
if (sectionAutosaveTimers[id]) clearTimeout(sectionAutosaveTimers[id]);
sectionAutosaveTimers[id] = window.setTimeout(() => {
sectionAutosaveTimers[id] = 0;
flushSectionAutosave(id);
}, SECTION_AUTOSAVE_MS);
}
function flushSectionAutosave(sectionID: string): void {
if (sectionAutosaveTimers[sectionID]) {
clearTimeout(sectionAutosaveTimers[sectionID]);
sectionAutosaveTimers[sectionID] = 0;
}
const editor = document.querySelector(`.submission-draft-section-editor[data-section-id="${cssEscape(sectionID)}"]`) as HTMLDivElement | null;
if (!editor || !state.view) return;
const lang = editor.dataset.lang || state.view.draft.language || "de";
const md = domToMarkdown(editor);
void patchSection(sectionID, lang === "en" ? { content_md_en: md } : { content_md_de: md });
}
// domToMarkdown serialises a contentEditable's DOM tree back to
// Markdown. Walks the tree: <b>/<strong> emit `**…**`, <i>/<em> emit
// `*…*`, <br> emits a newline, block-level elements emit a blank line
// between siblings. Slice B handles only B/I + paragraphs/line breaks
// — Slice D's rich toolbar extends this to headings + lists + quote.
function domToMarkdown(root: HTMLElement): string {
return serializeNode(root).trim();
}
function serializeNode(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) {
return node.textContent ?? "";
}
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const el = node as HTMLElement;
const tag = el.tagName.toLowerCase();
// Lists: handle the wrapper before recursing into items so we can
// emit the right per-item Markdown prefix.
if (tag === "ul" || tag === "ol") {
const items: string[] = [];
let counter = 1;
for (const child of Array.from(el.childNodes)) {
if (child.nodeType === Node.ELEMENT_NODE && (child as HTMLElement).tagName.toLowerCase() === "li") {
const liInner = serializeNode(child).replace(/\n+$/g, "");
const prefix = tag === "ol" ? `${counter}. ` : "- ";
items.push(prefix + liInner);
counter++;
}
}
return items.join("\n") + "\n\n";
}
let inner = "";
for (const child of Array.from(el.childNodes)) {
inner += serializeNode(child);
}
switch (tag) {
case "b":
case "strong":
return inner ? `**${inner}**` : "";
case "i":
case "em":
return inner ? `*${inner}*` : "";
case "br":
return "\n";
case "div":
case "p":
// execCommand and contentEditable insert <div> on Enter in some
// browsers, <p> in others. Both are paragraph boundaries.
return inner + "\n\n";
case "h1":
return "# " + inner.replace(/\n+$/g, "") + "\n\n";
case "h2":
return "## " + inner.replace(/\n+$/g, "") + "\n\n";
case "h3":
return "### " + inner.replace(/\n+$/g, "") + "\n\n";
case "blockquote":
// Each line inside the blockquote gets its own "> " prefix per
// Markdown convention.
return inner.split("\n").map(line => line === "" ? "" : "> " + line).join("\n").replace(/\n+$/g, "") + "\n\n";
case "li":
// <li> rendered standalone (no <ul>/<ol> ancestor) — emit
// bullet by default. The ul/ol branch above handles the
// ordered/unordered choice when present.
return "- " + inner.replace(/\n+$/g, "") + "\n";
case "a": {
const href = el.getAttribute("href") ?? "";
if (!href || !inner) return inner;
return `[${inner}](${href})`;
}
default:
return inner;
}
}
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
// ─────────────────────────────────────────────────────────────────────
interface BuildingBlockPickJSON {
id: string;
slug: string;
section_key: string;
proceeding_family?: string | null;
title_de: string;
title_en: string;
description_de?: string | null;
description_en?: string | null;
content_md_de: string;
content_md_en: string;
visibility: string;
}
let blockPickerSearchTimer: number | null = null;
function openBlockPicker(sec: SubmissionSectionJSON): void {
// Remove any prior picker.
document.getElementById("submission-bb-picker")?.remove();
const overlay = document.createElement("div");
overlay.id = "submission-bb-picker";
overlay.className = "submission-bb-picker-overlay";
overlay.addEventListener("click", (ev) => {
if (ev.target === overlay) overlay.remove();
});
const modal = document.createElement("div");
modal.className = "submission-bb-picker";
const head = document.createElement("header");
head.className = "submission-bb-picker-head";
const title = document.createElement("h2");
title.textContent = isEN() ? "Insert building block" : "Baustein einfügen";
head.appendChild(title);
const close = document.createElement("button");
close.type = "button";
close.className = "btn-small btn-secondary";
close.textContent = isEN() ? "Close" : "Schließen";
close.addEventListener("click", () => overlay.remove());
head.appendChild(close);
modal.appendChild(head);
const search = document.createElement("input");
search.type = "search";
search.placeholder = isEN() ? "Search blocks…" : "Bausteine suchen…";
search.className = "entity-form-input submission-bb-picker-search";
modal.appendChild(search);
const sectionInfo = document.createElement("p");
sectionInfo.className = "submission-bb-picker-sectioninfo";
sectionInfo.textContent = (isEN() ? "Section: " : "Abschnitt: ") + sec.section_key;
modal.appendChild(sectionInfo);
const list = document.createElement("div");
list.className = "submission-bb-picker-list";
list.textContent = isEN() ? "Loading…" : "Lädt…";
modal.appendChild(list);
overlay.appendChild(modal);
document.body.appendChild(overlay);
const fetchBlocks = async (q: string) => {
const params = new URLSearchParams();
params.set("section_key", sec.section_key);
if (q) params.set("q", q);
try {
const res = await fetch(`/api/submission-building-blocks?${params.toString()}`, { credentials: "include" });
if (!res.ok) {
list.textContent = `HTTP ${res.status}`;
return;
}
const body = await res.json() as { blocks?: BuildingBlockPickJSON[] };
paintPickerList(list, body.blocks ?? [], sec, overlay);
} catch (err) {
list.textContent = String(err);
}
};
search.addEventListener("input", () => {
if (blockPickerSearchTimer) clearTimeout(blockPickerSearchTimer);
blockPickerSearchTimer = window.setTimeout(() => {
void fetchBlocks(search.value.trim());
}, 200);
});
void fetchBlocks("");
setTimeout(() => search.focus(), 0);
}
function paintPickerList(host: HTMLElement, blocks: BuildingBlockPickJSON[], sec: SubmissionSectionJSON, overlay: HTMLElement): void {
host.innerHTML = "";
if (blocks.length === 0) {
const empty = document.createElement("p");
empty.className = "submission-bb-picker-empty";
empty.textContent = isEN() ? "No blocks match." : "Keine passenden Bausteine.";
host.appendChild(empty);
return;
}
const lang = state.view?.draft.language || "de";
for (const b of blocks) {
const row = document.createElement("button");
row.type = "button";
row.className = "submission-bb-picker-row";
const title = (lang === "en" ? b.title_en : b.title_de) || b.slug;
const desc = (lang === "en" ? b.description_en : b.description_de) || "";
const preview = ((lang === "en" ? b.content_md_en : b.content_md_de) || "").slice(0, 200);
row.innerHTML = `
<div class="submission-bb-picker-row-head">
<strong>${escapeHTML(title)}</strong>
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span>
</div>
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHTML(desc)}</div>` : ""}
<pre class="submission-bb-picker-row-preview">${escapeHTML(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
row.addEventListener("click", () => {
void insertBlockIntoSection(b.id, sec.id, overlay);
});
host.appendChild(row);
}
}
async function insertBlockIntoSection(blockID: string, sectionID: string, overlay: HTMLElement): Promise<void> {
try {
const res = await fetch(
`/api/submission-building-blocks/${blockID}/insert-into/${sectionID}`,
{ method: "POST", credentials: "include" },
);
if (!res.ok) {
console.warn("insert-into PATCH failed", res.status);
return;
}
const updated = await res.json() as SubmissionSectionJSON;
if (state.view && state.view.sections) {
const idx = state.view.sections.findIndex(s => s.id === sectionID);
if (idx >= 0) state.view.sections[idx] = updated;
}
paintSectionList();
overlay.remove();
} catch (err) {
console.warn("insert block error", err);
}
}
function escapeHTML(s: string): string {
return s
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
async function patchSection(sectionID: string, payload: Record<string, unknown>): Promise<void> {
try {
const draftID = state.view?.draft.id;
if (!draftID) return;
const res = await fetch(
`/api/submission-drafts/${draftID}/sections/${sectionID}`,
{
method: "PATCH",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (!res.ok) {
console.warn("section PATCH failed", res.status, sectionID);
return;
}
const updated = await res.json() as SubmissionSectionJSON;
// Splice the updated row into state.view.sections. Don't re-paint
// unless we need to (avoid focus stealing during active typing).
if (state.view && state.view.sections) {
const idx = state.view.sections.findIndex(s => s.id === sectionID);
if (idx >= 0) state.view.sections[idx] = updated;
}
// Only repaint when the change has visible UI knock-on (toggle,
// label, order). content_md_* changes don't need a repaint —
// the editor already shows the lawyer's keystrokes.
if ("included" in payload || "label_de" in payload || "label_en" in payload || "order_index" in payload) {
paintSectionList();
}
} catch (err) {
console.warn("section PATCH error", err);
}
}
// t-paliad-261 (B) — click a substituted variable in the preview to
// jump to the matching sidebar input. Re-wires on every paintPreview
// since the preview HTML is replaced wholesale. The server side wraps

View File

@@ -0,0 +1,96 @@
import { describe, expect, it } from "bun:test";
import { type CalculatedDeadline } from "./views/verfahrensablauf-core";
import { filterByDetailMode, isRuleSelected } from "./verfahrensablauf-detail-mode";
// Helper — minimum-viable CalculatedDeadline for unit testing the filter
// (the renderer's other fields don't matter to the filter).
function mkRule(
ruleId: string,
priority: "mandatory" | "recommended" | "optional",
extras: Partial<CalculatedDeadline> = {},
): CalculatedDeadline {
return {
ruleId,
code: ruleId,
name: ruleId,
nameEN: ruleId,
party: "",
priority,
ruleRef: "",
dueDate: "2026-06-01",
originalDate: "2026-06-01",
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
...extras,
};
}
describe("isRuleSelected", () => {
it("mandatory rules are always selected, even with explicit deselect", () => {
const dl = mkRule("a", "mandatory");
expect(isRuleSelected(dl, null)).toBe(true);
expect(isRuleSelected(dl, { "rule:a": false })).toBe(true);
});
it("recommended rules default to selected; explicit false deselects", () => {
const dl = mkRule("a", "recommended");
expect(isRuleSelected(dl, null)).toBe(true);
expect(isRuleSelected(dl, {})).toBe(true);
expect(isRuleSelected(dl, { "rule:a": false })).toBe(false);
expect(isRuleSelected(dl, { "rule:a": true })).toBe(true);
});
it("optional rules default to unselected; explicit true selects", () => {
const dl = mkRule("a", "optional");
expect(isRuleSelected(dl, null)).toBe(false);
expect(isRuleSelected(dl, {})).toBe(false);
expect(isRuleSelected(dl, { "rule:a": true })).toBe(true);
expect(isRuleSelected(dl, { "rule:a": false })).toBe(false);
});
it("conditional rules are treated as unselected in 'Gewählt' (engine left them unprojected)", () => {
const dl = mkRule("a", "mandatory", { isConditional: true });
expect(isRuleSelected(dl, null)).toBe(false);
});
});
describe("filterByDetailMode", () => {
const deadlines = [
mkRule("anchor", "mandatory", { isRootEvent: true }),
mkRule("m1", "mandatory"),
mkRule("r1", "recommended"),
mkRule("o1", "optional"),
mkRule("o2", "optional"),
];
it("mandatory_only returns mandatory + root only", () => {
const out = filterByDetailMode(deadlines, "mandatory_only", null);
const ids = out.map((d) => d.ruleId);
expect(ids).toEqual(["anchor", "m1"]);
});
it("selected (default flags) returns mandatory + recommended + root", () => {
const out = filterByDetailMode(deadlines, "selected", null);
const ids = out.map((d) => d.ruleId);
expect(ids).toEqual(["anchor", "m1", "r1"]);
});
it("selected with explicit per-rule overrides flips both directions", () => {
const flags = { "rule:r1": false, "rule:o1": true };
const out = filterByDetailMode(deadlines, "selected", flags);
const ids = out.map((d) => d.ruleId);
expect(ids).toEqual(["anchor", "m1", "o1"]);
});
it("all_options returns the full list and tags unselected rules", () => {
const out = filterByDetailMode(deadlines, "all_options", null);
expect(out.length).toBe(5);
const unselected = out.filter(
(d) => (d as CalculatedDeadline & { __detailUnselected?: boolean }).__detailUnselected,
);
// Root + mandatory + recommended are selected; the two optionals
// are unselected → 2 tagged rows.
expect(unselected.map((d) => d.ruleId).sort()).toEqual(["o1", "o2"]);
});
});

View File

@@ -0,0 +1,125 @@
// Detail-level filter for /tools/verfahrensablauf (m/paliad#149 Phase 2 P3).
//
// m's framing (2026-05-27 14:40, design §2.4a + §3.3a):
//
// "It is more that I want a grade of detail in our swimlane display.
// I want to show them but also be able to 'focus' by not displaying
// optional things. We need an option 'show only selected' or
// 'mandatory' ... filter events from the timeline based on whether
// they are selected in this scenario."
//
// Three modes:
// - mandatory_only — render only priority='mandatory' rules
// - selected (default) — mandatory + every rule whose effective
// selection (priority-default OR scenario-flag
// override) is true. Honest summary of "the
// lawyer's scenario".
// - all_options — render everything, with unselected optionals
// rendered dotted-border + muted so the user sees
// what they're NOT considering.
//
// Selection model (per design §2.4a):
// - priority='mandatory' → always selected (cannot be deselected)
// - priority='recommended' → default-selected; rule:<uuid>=false in
// scenario_flags deselects
// - priority='optional' → default-unselected; rule:<uuid>=true in
// scenario_flags selects
// - conditional rules → respect their condition_expr first; if
// the predicate doesn't hold, they're
// effectively unselected regardless of
// their priority default
import { type CalculatedDeadline } from "./views/verfahrensablauf-core";
export type DetailMode = "mandatory_only" | "selected" | "all_options";
const STORAGE_KEY = "verfahrensablauf:view_mode";
const DEFAULT_MODE: DetailMode = "selected";
export function getDetailMode(): DetailMode {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === "mandatory_only" || raw === "selected" || raw === "all_options") {
return raw;
}
} catch {
// localStorage unavailable (private mode, security policy) — fall
// through to default. Render still works; just no persistence.
}
return DEFAULT_MODE;
}
export function setDetailMode(mode: DetailMode): void {
try {
localStorage.setItem(STORAGE_KEY, mode);
} catch {
// best-effort
}
}
// isRuleSelected: combine priority default with the scenario-flag
// override map. Returns the effective selection state.
//
// priority='mandatory' → always true
// priority='recommended' → default true, flipped by rule:<uuid>=false
// priority='optional' → default false, flipped by rule:<uuid>=true
// other (informational) → treated as optional
export function isRuleSelected(
dl: CalculatedDeadline,
scenarioFlags: Record<string, boolean> | null,
): boolean {
// A conditional rule that the engine left unprojected (no concrete
// date because its predicate doesn't hold) is effectively unselected
// in "selected" view mode — even for priority='mandatory' rules,
// because mandatory means "must be filed IF the predicate fires",
// not "always render". Surfacing a non-applicable conditional row in
// "Gewählt" would be a lie. The "all_options" view re-surfaces it via
// the unfiltered render path so the lawyer can see what scenarios
// would unlock it.
if (dl.isConditional) return false;
if (dl.priority === "mandatory") return true;
const key = dl.ruleId ? `rule:${dl.ruleId}` : null;
const override = key && scenarioFlags ? scenarioFlags[key] : undefined;
if (typeof override === "boolean") return override;
return dl.priority === "recommended";
}
// filterByDetailMode applies the three-way filter to a deadlines list.
// Returns a NEW array with the appropriate subset; the caller passes
// the filtered list to the existing renderColumnsBody / renderTimelineBody.
//
// all_options: returns the input as-is, with an `__detailUnselected`
// flag set on optionals/conditionals that aren't part of the active
// scenario — the renderer reads this flag to add the dotted-border
// muted styling.
export function filterByDetailMode(
deadlines: CalculatedDeadline[],
mode: DetailMode,
scenarioFlags: Record<string, boolean> | null,
): CalculatedDeadline[] {
if (mode === "all_options") {
// No filtering, but tag the unselected rows so the renderer can
// dim them. The original CalculatedDeadline doesn't carry this
// axis — we stamp it via a cast so the renderer can pick it up
// without growing the public type. Read-only at the renderer side.
return deadlines.map((dl) => {
const unselected = !isRuleSelected(dl, scenarioFlags) && !dl.isRootEvent;
return unselected
? ({ ...dl, __detailUnselected: true } as CalculatedDeadline & { __detailUnselected: true })
: dl;
});
}
if (mode === "mandatory_only") {
return deadlines.filter(
(dl) => dl.priority === "mandatory" || dl.isRootEvent,
);
}
// "selected": mandatory always, plus rules whose effective selection
// is true. Root events always render (they're the proceeding anchor).
return deadlines.filter(
(dl) => dl.isRootEvent || isRuleSelected(dl, scenarioFlags),
);
}

View File

@@ -8,8 +8,7 @@
// come from ./views/verfahrensablauf-core, which fristenrechner.ts
// shares.
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import { t, tDyn, getLang, onLangChange } from "./i18n";
import {
type DeadlineResponse,
calculateDeadlines,
@@ -25,6 +24,19 @@ import {
reseedChips,
type EventChoice,
} from "./views/event-card-choices";
import {
filterByDetailMode,
getDetailMode,
isRuleSelected,
setDetailMode,
type DetailMode,
} from "./verfahrensablauf-detail-mode";
import {
fetchScenarioFlags,
onScenarioFlagsChanged,
patchScenarioFlags,
SCENARIO_FLAG_CHANGED_EVENT,
} from "./scenario-flags";
import {
APPEAL_TARGETS,
SCENARIO_KEYS,
@@ -48,6 +60,28 @@ import {
let selectedType = "";
let lastResponse: DeadlineResponse | null = null;
// m/paliad#149 Phase 2 P3 — detail-level view-mode + scenario-flag state.
//
// detailMode: which of the three filter buckets is active. Persisted in
// localStorage under verfahrensablauf:view_mode so it survives reload
// and follows the user across projects (m's "I want a grade of detail
// in our swimlane display" framing — it's a UI preference, not a
// scenario fact).
//
// projectId: when the page is opened with ?project=<id>, scenario_flag
// reads/writes go through PATCH /api/projects/{id}/scenario-flags (the
// P0 SSoT). Kontextfrei (no project) stays on localStorage via the
// existing perCardChoices path; per-rule selection deviations land in
// scenarioFlagsLocal keyed by proceeding_type code.
//
// scenarioFlags: live map shadow. Refreshed by hydrateScenarioFlags()
// on project pick + listens to the scenario-flag-changed CustomEvent
// so toggles from other surfaces (Mode B Fristenrechner result-view)
// re-trigger re-render here without a fresh fetch.
let detailMode: DetailMode = getDetailMode();
let projectIdForFlags: string | null = null;
let scenarioFlags: Record<string, boolean> = {};
// Perspective state. URL-driven so the view is shareable + survives
// reload:
// ?side=claimant|defendant — swaps which column owns the user's
@@ -142,6 +176,20 @@ const APPEAL_TARGET_PROCEEDINGS = new Set([
"upc.apl.unified",
]);
// hasOptionalOptIn — true when any `rule:<uuid>=true` override exists in
// the scenarioFlags map (the per-rule "Aufnehmen" deviation written by
// the detail-mode selection-chip in onRuleSelectionToggle). When set we
// flip the engine's IncludeOptional on so the chosen optional rules
// actually reach the response; the engine has no rule:<uuid> awareness
// of its own so without this layer the pick would silently no-op.
// (t-paliad-348)
function hasOptionalOptIn(flags: Record<string, boolean>): boolean {
for (const [k, v] of Object.entries(flags)) {
if (v === true && k.startsWith("rule:")) return true;
}
return false;
}
function hasAppealTarget(proceedingType: string): boolean {
return APPEAL_TARGET_PROCEEDINGS.has(proceedingType);
}
@@ -374,6 +422,16 @@ async function doCalc() {
perCardChoices,
includeHidden: showHidden,
appealTarget,
// t-paliad-348 — match the page-level detail mode to the engine's
// optional-rule suppression. The engine drops optional rules by
// default (IncludeOptional=false); "all_options" mode needs them
// back in the response so filterByDetailMode can dim them. We
// also opt-in whenever any per-rule `rule:<uuid>=true` deviation
// is set on scenarioFlags so an "Aufnehmen"-ed optional in
// "selected" mode still surfaces — the engine doesn't read
// rule:<uuid> overrides, so without this the user's pick would
// silently no-op server-side.
includeOptional: detailMode === "all_options" || hasOptionalOptIn(scenarioFlags),
});
if (seq !== calcSeq) return;
if (!data) return;
@@ -470,8 +528,15 @@ function renderResults(data: DeadlineResponse) {
? `<div class="timeline-context-note" role="note">${escHtml(noteText)}</div>`
: "";
// m/paliad#149 Phase 2 P3 — apply the detail-level filter pre-render.
// The calc payload stays the same; we just narrow what the renderer
// sees. Root events always pass through so the proceeding anchor is
// never hidden by the filter.
const filteredDeadlines = filterByDetailMode(data.deadlines, detailMode, scenarioFlags);
const filteredData: DeadlineResponse = { ...data, deadlines: filteredDeadlines };
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, {
? renderColumnsBody(filteredData, {
editable: true,
showNotes,
showDurations,
@@ -489,7 +554,7 @@ function renderResults(data: DeadlineResponse) {
// m/paliad#136 Bug 1)
appealAware: hasAppealTarget(selectedType),
})
: renderTimelineBody(data, { showParty: true, editable: true, showNotes, showDurations });
: renderTimelineBody(filteredData, { showParty: true, editable: true, showNotes, showDurations });
container.innerHTML = headerHtml + noteHtml + bodyHtml;
if (printBtn) printBtn.style.display = "block";
@@ -786,6 +851,120 @@ function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
document.body.classList.toggle("verfahrensablauf-view-columns", view === "columns");
}
// initDetailModeToggle wires the three-way Anzeige toggle (Nur Pflicht /
// Gewählt / Alle Optionen) introduced for m/paliad#149 Phase 2 P3.
// State persists via localStorage (per-user, per-browser); flipping the
// radio re-renders the last response without a fresh calc — the filter
// is pure client-side narrowing on data the page already has.
function initDetailModeToggle() {
const toggle = document.getElementById("verfahrensablauf-detail-toggle");
if (!toggle) return;
toggle.querySelectorAll<HTMLInputElement>("input[name=detail-mode]").forEach((input) => {
input.checked = input.value === detailMode;
input.addEventListener("change", () => {
if (!input.checked) return;
const v = input.value;
if (v === "mandatory_only" || v === "selected" || v === "all_options") {
detailMode = v;
setDetailMode(detailMode);
if (lastResponse) renderResults(lastResponse);
}
});
});
}
// initScenarioFlagsForProject loads the project's persisted scenario_flags
// (mig 154 SSoT). Called when the page is opened with ?project=<id> and
// also when the project autofill resolves a project. Listens for the
// scenario-flag-changed CustomEvent so any peer surface that PATCHes the
// same project (Mode B Fristenrechner result-view) keeps this page in
// sync without polling.
async function hydrateScenarioFlags(projectID: string) {
projectIdForFlags = projectID;
const view = await fetchScenarioFlags(projectID);
if (!view) return;
scenarioFlags = view.flags;
// The named scenario flags (with_ccr / with_amend / with_cci) drive
// the existing flag checkboxes — re-syncing them here makes the page
// reflect the project's persisted state on first paint.
const setChecked = (id: string, val: boolean | undefined): void => {
const el = document.getElementById(id) as HTMLInputElement | null;
if (!el) return;
el.checked = val === true;
};
setChecked("ccr-flag", scenarioFlags["with_ccr"]);
setChecked("inf-amend-flag", scenarioFlags["with_amend"]);
setChecked("rev-amend-flag", scenarioFlags["with_amend"]);
setChecked("rev-cci-flag", scenarioFlags["with_cci"]);
syncInfAmendEnabled();
if (lastResponse) renderResults(lastResponse);
}
// Subscribe to peer-surface scenario-flag changes once at module load.
// The listener is idempotent — we re-read the map and re-render. Skipped
// when projectIdForFlags hasn't been set yet (kontextfrei mode).
onScenarioFlagsChanged((detail) => {
if (!projectIdForFlags || detail.projectId !== projectIdForFlags) return;
scenarioFlags = detail.flags;
if (lastResponse) renderResults(lastResponse);
});
// persistNamedScenarioFlag writes a named flag (with_ccr / with_amend /
// with_cci) to the project's scenario_flags via PATCH. No-op in
// kontextfrei mode. The page-level checkbox owns the click; this helper
// just mirrors it into the SSoT so peer surfaces see the change.
function persistNamedScenarioFlag(key: string, value: boolean): void {
if (!projectIdForFlags) return;
void patchScenarioFlags(projectIdForFlags, { [key]: value });
scenarioFlags = { ...scenarioFlags, [key]: value };
}
// onRuleSelectionToggle handles a click on a per-rule [Aufnehmen] or
// [Entfernen] chip (m/paliad#149 Phase 2 P3, design §2.4a). Translates
// the action into a scenario-flag delta:
//
// priority='recommended', aufnehmen=true → delete rule:<uuid> (back to default-selected)
// priority='recommended', aufnehmen=false → write rule:<uuid> = false (explicit deselect)
// priority='optional', aufnehmen=true → write rule:<uuid> = true (explicit select)
// priority='optional', aufnehmen=false → delete rule:<uuid> (back to default-unselected)
//
// In other words: the chip stores only DEVIATIONS from the priority
// default; flipping back to the default-state deletes the entry. Akte
// mode PATCHes via scenario-flags.ts; kontextfrei mode is no-op
// today (per-rule selections in kontextfrei mode are a future P3
// extension via localStorage; the chip still hides itself once flipped
// because the page-level scenarioFlags map updates).
function onRuleSelectionToggle(ruleId: string, priority: string, aufnehmen: boolean): void {
const key = `rule:${ruleId}`;
let deltaValue: boolean | null;
if (priority === "recommended") {
deltaValue = aufnehmen ? null : false;
} else if (priority === "optional") {
deltaValue = aufnehmen ? true : null;
} else {
return; // mandatory / unknown — not toggleable
}
// Update the local shadow first so the re-render below reflects the
// new state regardless of network latency.
const next = { ...scenarioFlags };
if (deltaValue === null) {
delete next[key];
} else {
next[key] = deltaValue;
}
scenarioFlags = next;
// Persist to the project's SSoT when in akte mode. Fire-and-forget;
// a network failure leaves the local shadow ahead of the server, which
// the next hydrate or peer scenario-flag-changed event reconciles.
if (projectIdForFlags) {
void patchScenarioFlags(projectIdForFlags, { [key]: deltaValue });
}
if (lastResponse) renderResults(lastResponse);
}
function initViewToggle() {
const toggle = document.getElementById("fristen-view-toggle");
if (!toggle) return;
@@ -855,10 +1034,14 @@ function initPerspectiveControls() {
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
// initVerfahrensablauf wires the entire Verfahrensablauf wizard against
// whatever DOM is currently present (proceeding-btn buttons,
// trigger-date input, flag checkboxes, timeline-container, …).
// Re-callable on demand: m/paliad#151 mounts this against the
// /tools/procedures "Verfahren wählen" tab the first time it activates.
// initI18n() + initSidebar() are NOT included here — both are page-boot
// concerns owned by whichever entrypoint hosts the wiring.
export function initVerfahrensablauf(): void {
document.querySelectorAll<HTMLButtonElement>(".proceeding-btn").forEach((btn) => {
btn.addEventListener("click", () => selectProceeding(btn));
});
@@ -908,17 +1091,24 @@ document.addEventListener("DOMContentLoaded", () => {
// disabled checkbox as checked.
const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
if (infAmend) writeBoolFlag(scenarioStorage, SCENARIO_KEYS.infAmend, infAmend.checked);
// m/paliad#149 Phase 2 P3 — mirror into the project's scenario_flags
// SSoT when in akte mode. PATCH is fire-and-forget; failure just
// means the local UI keeps its optimistic state and the next
// hydrate reconciles.
persistNamedScenarioFlag("with_ccr", ccrFlag.checked);
if (infAmend) persistNamedScenarioFlag("with_amend", infAmend.checked);
scheduleCalc(0);
});
const flagStorageKeys: Record<string, string> = {
"inf-amend-flag": SCENARIO_KEYS.infAmend,
"rev-amend-flag": SCENARIO_KEYS.revAmend,
"rev-cci-flag": SCENARIO_KEYS.revCci,
const flagStorageKeys: Record<string, { storageKey: string; flagKey: string }> = {
"inf-amend-flag": { storageKey: SCENARIO_KEYS.infAmend, flagKey: "with_amend" },
"rev-amend-flag": { storageKey: SCENARIO_KEYS.revAmend, flagKey: "with_amend" },
"rev-cci-flag": { storageKey: SCENARIO_KEYS.revCci, flagKey: "with_cci" },
};
for (const [id, storageKey] of Object.entries(flagStorageKeys)) {
for (const [id, { storageKey, flagKey }] of Object.entries(flagStorageKeys)) {
const cb = document.getElementById(id) as HTMLInputElement | null;
if (cb) cb.addEventListener("change", () => {
writeBoolFlag(scenarioStorage, storageKey, cb.checked);
persistNamedScenarioFlag(flagKey, cb.checked);
scheduleCalc(0);
});
}
@@ -938,6 +1128,21 @@ document.addEventListener("DOMContentLoaded", () => {
}
scheduleCalc(0);
});
// m/paliad#149 Phase 2 P3 — delegated handler for the per-rule
// [Aufnehmen] / [Entfernen] chips. The chip carries data-rule-id +
// data-action; the click flips the scenario-flag entry for that
// rule and re-renders.
timelineContainer.addEventListener("click", (e) => {
const target = e.target as HTMLElement | null;
const btn = target?.closest<HTMLButtonElement>(".timeline-selection-chip");
if (!btn) return;
e.preventDefault();
const ruleId = btn.dataset.ruleId;
const priority = btn.dataset.priority;
const action = btn.dataset.action;
if (!ruleId || !priority || !action) return;
onRuleSelectionToggle(ruleId, priority, action === "aufnehmen");
});
}
// Notes toggle — restores last preference on load + re-renders when
@@ -981,8 +1186,20 @@ document.addEventListener("DOMContentLoaded", () => {
}
initViewToggle();
initDetailModeToggle();
initPerspectiveControls();
// m/paliad#149 Phase 2 P3 — hydrate scenario_flags from the project's
// SSoT (mig 154) when the page is opened with ?project=<id>. Initial
// fetch sets the page-level flag checkboxes; subsequent peer-surface
// changes arrive via the scenario-flag-changed CustomEvent (subscribed
// at module load above). Kontextfrei (no ?project=) skips the fetch —
// localStorage-only behaviour stays as it was.
const projectQuery = new URLSearchParams(window.location.search).get("project");
if (projectQuery && /^[0-9a-fA-F-]{36}$/.test(projectQuery)) {
void hydrateScenarioFlags(projectQuery);
}
// t-paliad-265 — per-event-card choices. Unbound surface; persistence
// is localStorage-only (t-paliad-308) so a shared link doesn't carry
// the recipient's per-card tweaks. The popover module owns the
@@ -1067,4 +1284,5 @@ document.addEventListener("DOMContentLoaded", () => {
const writeURL = urlProceeding !== "" && !urlHit;
selectProceeding(initialBtn, { writeURL });
}
});
}

View File

@@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test";
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import {
type CalculatedDeadline,
type DeadlineResponse,
bucketDeadlinesIntoColumns,
calculateDeadlines,
deadlineCardHtml,
formatDurationLabel,
renderColumnsBody,
@@ -773,3 +774,81 @@ describe("stripLeadingDurationFromNotes — render-side dedup (t-paliad-307)", (
.toBe("Time limit set by the court");
});
});
// Pin the engine-options plumbing surface (t-paliad-348 / yoUPC#178).
// calculateDeadlines must forward `includeOptional` and
// `triggerEventAnchors` straight into the POST body so the Go handler
// (handleFristenrechnerAPI) can pass them into lp.CalcOptions. If a
// future refactor drops the fields, the Builder triplet silently
// reverts to "engine emits optional rules" and the unified
// /tools/procedures page loses its naked-proceeding default.
describe("calculateDeadlines — forwards engine options into request body", () => {
type CapturedRequest = { url: string; body: Record<string, unknown> };
let captured: CapturedRequest | null;
let originalFetch: typeof globalThis.fetch;
beforeEach(() => {
captured = null;
originalFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
captured = { url: String(input), body };
return new Response(JSON.stringify({
proceedingType: "x", proceedingName: "x", triggerDate: "2026-01-01", deadlines: [],
}), { status: 200, headers: { "Content-Type": "application/json" } });
}) as typeof globalThis.fetch;
});
afterEach(() => {
globalThis.fetch = originalFetch;
});
test("default call omits includeOptional and triggerEventAnchors", async () => {
await calculateDeadlines({ proceedingType: "upc.inf.cfi", triggerDate: "2026-05-26" });
expect(captured).not.toBeNull();
expect(captured!.body.includeOptional).toBeUndefined();
expect(captured!.body.triggerEventAnchors).toBeUndefined();
});
test("includeOptional=true sends includeOptional: true", async () => {
await calculateDeadlines({
proceedingType: "upc.inf.cfi",
triggerDate: "2026-05-26",
includeOptional: true,
});
expect(captured!.body.includeOptional).toBe(true);
});
test("includeOptional=false is omitted (matches engine default)", async () => {
await calculateDeadlines({
proceedingType: "upc.inf.cfi",
triggerDate: "2026-05-26",
includeOptional: false,
});
expect(captured!.body.includeOptional).toBeUndefined();
});
test("triggerEventAnchors forwarded as object", async () => {
await calculateDeadlines({
proceedingType: "upc.inf.cfi",
triggerDate: "2026-05-26",
triggerEventAnchors: {
"upc.inf.cfi.oral": "2026-09-01",
"upc.inf.cfi.decision": "2026-12-15",
},
});
expect(captured!.body.triggerEventAnchors).toEqual({
"upc.inf.cfi.oral": "2026-09-01",
"upc.inf.cfi.decision": "2026-12-15",
});
});
test("empty triggerEventAnchors is omitted", async () => {
await calculateDeadlines({
proceedingType: "upc.inf.cfi",
triggerDate: "2026-05-26",
triggerEventAnchors: {},
});
expect(captured!.body.triggerEventAnchors).toBeUndefined();
});
});

View File

@@ -28,6 +28,11 @@ export interface AdjustmentReason {
}
export interface CalculatedDeadline {
// ruleId is the sequencing_rule.id UUID, used by the P3 per-rule
// selection deviations (`rule:<uuid>` keys in projects.scenario_flags).
// Empty on synthetic UI markers like the appeal trigger row that the
// engine prepends — those carry no real rule_id.
ruleId?: string;
code: string;
name: string;
nameEN: string;
@@ -266,6 +271,12 @@ export interface DeadlineResponse {
// when the toggle is OFF — so users know there's something to
// re-surface.
hiddenCount?: number;
// rulesAwaitingAnchor (t-paliad-348 / yoUPC#178): number of rules the
// engine suppressed because their `trigger_event_id` anchor wasn't
// supplied via CalcParams.triggerEventAnchors. Mirrors the Go
// Timeline.RulesAwaitingAnchor counter — a single integer surface for
// "N rules waiting on an anchor" UI affordances.
rulesAwaitingAnchor?: number;
}
export interface CourtRow {
@@ -306,6 +317,20 @@ export interface CalcParams {
// endentscheidung | kostenentscheidung | anordnung |
// schadensbemessung | bucheinsicht.
appealTarget?: string;
// t-paliad-348 / yoUPC#178 — surface the engine's two new CalcOptions
// axes to the HTTP boundary:
//
// includeOptional: when true, the engine returns priority='optional'
// rules in the timeline. Default false matches the engine default
// (mandatory backbone only). The /tools/procedures detailgrad
// toggle ("all_options" mode) drives this to true so the dimmed
// optional cards can be rendered for the lawyer to opt into.
// triggerEventAnchors: per-event-code anchor dates the engine
// consults for rules carrying trigger_event_id. Empty/omitted =
// no anchors → such rules render as IsConditional (the engine
// refuses to fabricate a date off the proceeding's trigger date).
includeOptional?: boolean;
triggerEventAnchors?: Record<string, string>;
}
const PARTY_CLASS: Record<string, string> = {
@@ -613,13 +638,43 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
data-empty="true"></span>`
: "";
return `<div class="timeline-item-header">
// m/paliad#149 Phase 2 P3 — Aufnehmen / Entfernen chip on optional /
// recommended rules (when the detail-mode filter is in "all_options"
// or "selected"). The detail-mode filter tags unselected rules with
// __detailUnselected; the renderer picks that up to render the chip
// in its "Aufnehmen" state. Mandatory rules never get the chip — the
// user can't deselect them.
const detailUnselected = (dl as CalculatedDeadline & { __detailUnselected?: boolean }).__detailUnselected === true;
let selectionChip = "";
if (dl.ruleId && dl.priority !== "mandatory" && !dl.isRootEvent) {
if (detailUnselected) {
selectionChip = `<button type="button" class="timeline-selection-chip timeline-selection-chip--add"
data-rule-id="${escAttr(dl.ruleId)}"
data-priority="${escAttr(dl.priority)}"
data-action="aufnehmen"
title="${escAttr(t("deadlines.detail.optional_unselected_hint"))}">
${escHtml(t("deadlines.detail.aufnehmen"))}
</button>`;
} else if (dl.priority === "recommended" || dl.priority === "optional") {
// The rule IS in the active scenario but can be removed. Renders
// as a discreet [Entfernen] chip on optional / recommended cards.
selectionChip = `<button type="button" class="timeline-selection-chip timeline-selection-chip--remove"
data-rule-id="${escAttr(dl.ruleId)}"
data-priority="${escAttr(dl.priority)}"
data-action="entfernen">
${escHtml(t("deadlines.detail.entfernen"))}
</button>`;
}
}
return `<div class="timeline-item-header${detailUnselected ? " timeline-item-header--unselected" : ""}">
<span class="timeline-name">
${dlName}
${stateIconsHtml}
${chipHtml}
</span>
${dateStr}
${selectionChip}
${choicesHtml}
</div>
${meta}
@@ -1007,7 +1062,15 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
// timeline-item — dotted border + faded styling.
dl.isConditional ? "fr-col-item--conditional" : "",
].filter(Boolean).join(" ");
return `<div class="${itemClasses}">
// data-rule-id on the card root lets the Litigation Builder
// overlay per-card state (planned/filed/skipped) + action
// affordances onto cards rendered through this shared body
// without re-implementing the columns renderer. Empty on
// synthetic rows (appeal trigger marker etc.); the Builder
// skips state lookup when missing.
const ruleIdAttr = dl.ruleId ? ` data-rule-id="${escAttr(dl.ruleId)}"` : "";
const submissionCodeAttr = dl.code ? ` data-submission-code="${escAttr(dl.code)}"` : "";
return `<div class="${itemClasses}"${ruleIdAttr}${submissionCodeAttr}>
${deadlineCardHtml(dl, cardOpts)}
${mirrorTag}
</div>`;
@@ -1075,6 +1138,10 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
: undefined,
includeHidden: params.includeHidden ? true : undefined,
appealTarget: params.appealTarget || undefined,
includeOptional: params.includeOptional ? true : undefined,
triggerEventAnchors: params.triggerEventAnchors && Object.keys(params.triggerEventAnchors).length > 0
? params.triggerEventAnchors
: undefined,
}),
});
if (!resp.ok) {

View File

@@ -5,7 +5,7 @@ export function Footer(): string {
<footer className="footer">
<div className="container">
<p>
<span data-i18n="footer.text">{"© 2026 Paliad — ein Werkzeug von"}</span>{" "}
<span data-i18n="footer.text">{"© 2026 Paliad — by"}</span>{" "}
<a href="https://flexsiebels.de" target="_blank" rel="noopener">flexsiebels.de</a>
</p>
</div>

View File

@@ -17,7 +17,7 @@ export function Header({ showLogout }: HeaderProps): string {
{showLogout && (
<Fragment>
<a href="/tools/kostenrechner" className="nav-link" data-i18n="nav.kostenrechner">Kostenrechner</a>
<a href="/tools/fristenrechner" className="nav-link" data-i18n="nav.fristenrechner">Fristenrechner</a>
<a href="/tools/procedures" className="nav-link" data-i18n="nav.procedures">Verfahren &amp; Fristen</a>
<a href="/logout" className="nav-logout" data-i18n="nav.logout">Abmelden</a>
</Fragment>
)}

View File

@@ -177,8 +177,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
brief: calculators first, then reference (Checklisten /
Gerichte / Glossar), then content (Links / Downloads). */}
{group("nav.group.werkzeuge", "Werkzeuge",
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
navItem("/tools/verfahrensablauf", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
navItem("/tools/procedures", ICON_BOOK_OPEN, "nav.procedures", "Verfahren & Fristen", currentPath) +
navItem("/submissions", ICON_FILE_TEXT, "nav.submissions", "Schriftsätze", currentPath) +
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
@@ -204,7 +203,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
{navItem("/admin/team", ICON_USERS, "nav.admin.team", "Team-Verwaltung", currentPath)}
{navItem("/admin/partner-units", ICON_BUILDING, "nav.admin.partner_units", "Partner Units", currentPath)}
{navItem("/admin/event-types", ICON_TABLE, "nav.admin.event_types", "Event-Typen", currentPath)}
{navItem("/admin/rules", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
{navItem("/admin/procedural-events", ICON_BOOK, "nav.admin.rules", "Regeln verwalten", currentPath)}
{navItem("/admin/audit-log", ICON_AUDIT_LOG, "nav.admin.audit", "Audit-Log", currentPath)}
{navItem("/admin/backups", ICON_DOWNLOAD, "nav.admin.backups", "Backups", currentPath)}
{/* Paliadin Monitor — owner-only sub-entry; revealed by sidebar.ts together with the /paliadin link. */}

View File

@@ -0,0 +1,293 @@
import { h } from "../jsx";
interface ProceedingDef {
code: string;
i18nKey: string;
name: string;
}
function proceedingBtn(p: ProceedingDef): string {
return (
<button type="button" className="proceeding-btn" data-code={p.code}>
<strong data-i18n={p.i18nKey}>{p.name}</strong>
</button>
);
}
// Slice B1 (m/paliad#124 §18.1): the 3 separate Berufung tiles
// (upc.apl.merits / upc.apl.cost / upc.apl.order) collapse into ONE
// unified "Berufung" tile (upc.apl). After picking it, the user
// selects which decision the appeal is directed AT via the
// .appeal-target-row chip group below — the engine then filters
// rules whose applies_to_target contains the picked slug.
const UPC_TYPES: ProceedingDef[] = [
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
{ code: "upc.apl.unified", i18nKey: "deadlines.upc.apl.unified", name: "Berufung" },
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
];
const DE_INF_TYPES: ProceedingDef[] = [
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
];
const DE_NULL_TYPES: ProceedingDef[] = [
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
];
const EPA_TYPES: ProceedingDef[] = [
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
];
const DPMA_TYPES: ProceedingDef[] = [
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
];
// Shared Verfahrensablauf wizard body. Renders the proceeding picker,
// perspective + date inputs, scenario flag rows, detail-mode toggle,
// view toggle, and the timeline-container that client/verfahrensablauf.ts
// (via initVerfahrensablauf()) wires against. Used by both
// /tools/verfahrensablauf (legacy) and /tools/procedures (unified).
export function VerfahrensablaufBody({ todayIso }: { todayIso: string }): string {
return (
<div className="fristen-wizard" id="verfahrensablauf-wizard" data-mode="procedure">
<div className="wizard-step" id="step-1">
<h3 className="wizard-step-label">
<span className="step-number">1</span>
<span data-i18n="deadlines.step1">Verfahrensart w&auml;hlen</span>
</h3>
<div className="proceeding-group" data-forum="upc">
<h4 data-i18n="deadlines.upc">UPC</h4>
<div className="proceeding-btns">
{UPC_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group" data-forum="de">
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
<div className="proceeding-btns">
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
<div className="proceeding-btns">
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
</div>
<div className="proceeding-group" data-forum="epa">
<h4 data-i18n="deadlines.epa">EPA</h4>
<div className="proceeding-btns">
{EPA_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group" data-forum="dpma">
<h4 data-i18n="deadlines.dpma">DPMA</h4>
<div className="proceeding-btns">
{DPMA_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
<strong className="proceeding-summary-name" id="proceeding-summary-name">&mdash;</strong>
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
data-i18n="deadlines.proceeding.reselect">
Anderes Verfahren w&auml;hlen
</button>
</div>
</div>
<div className="wizard-step" id="step-2" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">2</span>
<span data-i18n="deadlines.step2.perspective">Perspektive und Datum</span>
</h3>
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
<div className="verfahrensablauf-perspective-row" id="side-row">
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
<div className="side-radio-cluster" id="side-radio-cluster">
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
<label className="fristen-view-option">
<input type="radio" name="side" value="claimant" />
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="defendant" />
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="" checked />
<span data-i18n="deadlines.side.undefined">Nicht festgelegt</span>
</label>
</div>
<span className="side-hint" id="side-hint"
data-i18n="deadlines.side.hint">
W&auml;hlen Sie eine Seite, um die Spalten zu fokussieren.
</span>
</div>
<div className="side-chip" id="side-chip" style="display:none">
<span className="side-chip-tag" data-i18n="deadlines.side.from_project">Aus Akte:</span>
<strong className="side-chip-value" id="side-chip-value">&mdash;</strong>
<button type="button" className="side-chip-override" id="side-chip-override"
data-i18n="deadlines.side.override">
Andere Seite w&auml;hlen
</button>
</div>
</div>
<div className="verfahrensablauf-perspective-row" id="appeal-target-row" style="display:none">
<span className="date-label" data-i18n="deadlines.appeal_target.label">Worauf richtet sich die Berufung?</span>
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appeal target">
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="endentscheidung" checked />
<span data-i18n="deadlines.appeal_target.endentscheidung">Endentscheidung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="kostenentscheidung" />
<span data-i18n="deadlines.appeal_target.kostenentscheidung">Kostenentscheidung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="anordnung" />
<span data-i18n="deadlines.appeal_target.anordnung">Anordnung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="schadensbemessung" />
<span data-i18n="deadlines.appeal_target.schadensbemessung">Schadensbemessung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="bucheinsicht" />
<span data-i18n="deadlines.appeal_target.bucheinsicht">Bucheinsicht</span>
</label>
</div>
</div>
<div className="verfahrensablauf-perspective-row" id="show-hidden-row" style="display:none">
<label className="fristen-view-option">
<input type="checkbox" id="show-hidden-toggle" />
<span data-i18n="choices.show_hidden.label">Ausgeblendete anzeigen</span>
</label>
<span className="show-hidden-count" id="show-hidden-count" aria-live="polite">&nbsp;</span>
</div>
</div>
<div className="verfahrensablauf-step2-divider" aria-hidden="true"></div>
<div className="date-input-group">
<div className="date-field-row">
<span className="date-label" data-i18n="deadlines.trigger.event">Ausl&ouml;sendes Ereignis:</span>
<span id="trigger-event" className="trigger-event-name">&mdash;</span>
</div>
<div className="date-field-row">
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
<input type="date" id="trigger-date" className="date-input" value={todayIso} />
</div>
<div className="date-field-row" id="court-picker-row" style="display:none">
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
<select id="court-picker" className="date-input"></select>
</div>
<div className="date-field-row" id="ccr-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="ccr-flag" />
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
</label>
</div>
<div className="date-field-row date-field-row--nested" id="inf-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="inf-amend-flag" />
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patent&auml;nderung (R.30)</span>
</label>
</div>
<div className="date-field-row" id="rev-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-amend-flag" />
<span data-i18n="deadlines.flag.rev_amend">Mit Antrag auf Patent&auml;nderung (R.49.2.a)</span>
</label>
</div>
<div className="date-field-row" id="rev-cci-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-cci-flag" />
<span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span>
</label>
</div>
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
Fristen berechnen
</button>
</div>
</div>
<div className="wizard-step" id="step-3" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">3</span>
<span data-i18n="deadlines.step3">Ergebnis</span>
</h3>
<div className="verfahrensablauf-detail-toggle" id="verfahrensablauf-detail-toggle"
role="radiogroup" aria-label="Detail">
<span className="fristen-view-label" data-i18n="deadlines.detail.label">Anzeige:</span>
<label className="fristen-view-option">
<input type="radio" name="detail-mode" value="mandatory_only" />
<span data-i18n="deadlines.detail.mandatory_only">Nur Pflicht</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="detail-mode" value="selected" checked />
<span data-i18n="deadlines.detail.selected">Gewählt</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="detail-mode" value="all_options" />
<span data-i18n="deadlines.detail.all_options">Alle Optionen</span>
</label>
</div>
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
<label className="fristen-view-option">
<input type="radio" name="fristen-view" value="columns" checked />
<span data-i18n="deadlines.view.columns">Spalten</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="fristen-view" value="timeline" />
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
</label>
<label className="fristen-notes-option">
<input type="checkbox" id="fristen-notes-show" />
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
</label>
<label className="fristen-notes-option">
<input type="checkbox" id="verfahrensablauf-durations-show" />
<span data-i18n="deadlines.durations.show">Dauern anzeigen</span>
</label>
</div>
<div id="timeline-container">
</div>
<div className="fristen-result-actions">
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="6 9 6 2 18 2 18 9"></polyline>
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
<rect x="6" y="14" width="12" height="8"></rect>
</svg>
<span data-i18n="deadlines.print">Drucken</span>
</button>
</div>
</div>
</div>
);
}

View File

@@ -1,657 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
interface ProceedingDef {
code: string;
i18nKey: string;
name: string;
}
function proceedingBtn(p: ProceedingDef): string {
return (
<button type="button" className="proceeding-btn" data-code={p.code}>
<strong data-i18n={p.i18nKey}>{p.name}</strong>
</button>
);
}
// Quick-pick chip definition. Each chip targets ONE deadline_concepts
// slug — clicking sets the search query to the concept's name in the
// active language so trigram search lands on the right concept card.
// Single source of truth for both fork-shortcut and B2-search-bar
// chip rows. Dedup invariant: no two chips share a slug. Label flips
// per language via the chip wiring in client/fristenrechner.ts.
interface QuickChip {
slug: string;
name_de: string;
name_en: string;
}
const QUICK_CHIPS: QuickChip[] = [
{ slug: "statement-of-defence", name_de: "Klageerwiderung", name_en: "Statement of Defence" },
{ slug: "notice-of-appeal", name_de: "Berufungsschrift", name_en: "Notice of Appeal" },
{ slug: "opposition", name_de: "Einspruchsfrist", name_en: "Opposition" },
{ slug: "reply-to-defence", name_de: "Replik", name_en: "Reply to Defence" },
{ slug: "nichtzulassungsbeschwerde", name_de: "Nichtzulassungsbeschwerde", name_en: "Non-admission Appeal (NZB)" },
{ slug: "application-for-determination-of-damages",name_de: "Antrag auf Schadensbemessung", name_en: "Application for Determination of Damages" },
{ slug: "wiedereinsetzung", name_de: "Wiedereinsetzung", name_en: "Re-establishment of Rights" },
];
function quickChip(c: QuickChip): string {
return (
<button type="button" className="fristen-search-chip"
data-chip-slug={c.slug}
data-chip-name-de={c.name_de}
data-chip-name-en={c.name_en}
data-q={c.name_de}>
{c.name_de}
</button>
);
}
const UPC_TYPES: ProceedingDef[] = [
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Ma\u00dfnahmen" },
{ code: "upc.apl.merits", i18nKey: "deadlines.upc.apl.merits", name: "Berufung" },
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
{ code: "upc.apl.cost", i18nKey: "deadlines.upc.apl.cost", name: "Berufung Kosten" },
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
];
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
// 2026-05-18 ask. Labels are parallel: <court> (<procedural role>),
// so a user scanning the picker sees the instance-and-role at a glance
// without one tile reading "Berufung OLG" and another "Nichtigkeits-
// verfahren". Sub-group headers convey the type grouping. Combined-
// timeline behaviour (LG→OLG→BGH as one calc) is filed as m/paliad#41.
const DE_INF_TYPES: ProceedingDef[] = [
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
];
const DE_NULL_TYPES: ProceedingDef[] = [
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
];
const EPA_TYPES: ProceedingDef[] = [
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
];
const DPMA_TYPES: ProceedingDef[] = [
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
];
export function renderFristenrechner(): string {
const today = new Date().toISOString().split("T")[0];
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="deadlines.title">Fristenrechner &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/tools/fristenrechner" />
<BottomNav currentPath="/tools/fristenrechner" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="deadlines.heading">Fristenrechner</h1>
<p className="tool-subtitle" data-i18n="deadlines.subtitle">
Berechnung von Verfahrensfristen f&uuml;r UPC-, deutsche und EPA-Verfahren.
</p>
</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 +
four ad-hoc explore-mode chips for users who just want to
look up a rule without saving anywhere. */}
<div className="fristen-step1" id="fristen-step1" role="group" aria-label="Akte picker">
<h2 className="fristen-step-heading" data-i18n="deadlines.step1.heading">
Schritt 1 &mdash; Welche Akte?
</h2>
<div className="fristen-step1-search-row">
<svg className="fristen-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-akte-search"
className="fristen-akte-search" autocomplete="off"
data-i18n-placeholder="deadlines.step1.search.placeholder"
placeholder="Akte suchen&hellip;" />
</div>
<ul className="fristen-akte-list" id="fristen-akte-list" role="listbox" aria-label="Akten"></ul>
<div className="fristen-step1-divider">
<span data-i18n="deadlines.step1.divider.new">oder eine neue Akte</span>
</div>
{/* return-bounce: projects-new.ts honours ?return= and
redirects back to /tools/fristenrechner?project=<new_uuid>
so the new Akte preselects itself in Step 1. */}
<a href="/projects/new?return=/tools/fristenrechner" className="fristen-step1-new" id="fristen-step1-new"
data-i18n="deadlines.step1.new.cta">
+ Neue Akte anlegen
</a>
<div className="fristen-step1-divider">
<span data-i18n="deadlines.step1.divider.adhoc">oder ad-hoc, ohne Akte</span>
</div>
<div className="fristen-adhoc-chips" role="group" aria-label="Ad-hoc proceeding">
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="upc"
data-i18n="deadlines.step1.adhoc.upc">
UPC proceeding
</button>
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="de"
data-i18n="deadlines.step1.adhoc.de">
DE proceeding
</button>
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="epa"
data-i18n="deadlines.step1.adhoc.epa">
EPA proceeding
</button>
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="dpma"
data-i18n="deadlines.step1.adhoc.dpma">
DPMA proceeding
</button>
</div>
</div>
{/* Step 1 collapsed summary, shown after a pick. Mirrors the
proceeding-summary collapse pattern from 097e21c. */}
<div className="fristen-step1-summary" id="fristen-step1-summary" style="display:none" role="group">
<span className="fristen-step1-summary-label" data-i18n="deadlines.step1.selected">Akte:</span>
<strong className="fristen-step1-summary-name" id="fristen-step1-summary-name">&mdash;</strong>
<span className="fristen-step1-summary-meta" id="fristen-step1-summary-meta"></span>
<button type="button" className="fristen-step1-summary-reselect" id="fristen-step1-summary-reselect"
data-i18n="deadlines.step1.reselect">
Andere Akte
</button>
</div>
{/* Step 2 — Do / Happened bifurcation. Hidden until Step 1 is
satisfied. Click on a card routes to the existing Pathway A
(Verfahrensablauf wizard) or Pathway B (cascade) shells —
we keep the routing primitive in showPathway()/showBMode(). */}
<div className="fristen-step2" id="fristen-step2" hidden>
<h2 className="fristen-step-heading" data-i18n="deadlines.step2.heading">
Schritt 2 &mdash; Was m&ouml;chten Sie tun?
</h2>
<div className="fristen-step2-cards">
<button type="button" className="fristen-step2-card" data-action="file" id="fristen-step2-file">
<span className="fristen-step2-card-icon" aria-hidden="true">&#9999;&#65039;</span>
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.file.title">
Etwas einreichen
</span>
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.file.desc">
Outgoing &mdash; eine Frist tritt aus eigener Handlung ein.
</span>
</button>
<button type="button" className="fristen-step2-card" data-action="happened" id="fristen-step2-happened">
<span className="fristen-step2-card-icon" aria-hidden="true">&#128229;</span>
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.happened.title">
Etwas ist passiert
</span>
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.happened.desc">
Incoming &mdash; ein Ereignis hat eine Frist ausgel&ouml;st.
</span>
</button>
{/* t-paliad-179 Slice 1: the third "Verfahrensablauf
einsehen" card retired — abstract-browse intent now
owns its own route at /tools/verfahrensablauf. */}
</div>
<div className="fristen-step2-shortcut">
<div className="fristen-pathway-fork-shortcut-label" data-i18n="deadlines.pathway.shortcut.label">
oder direkt zu einer Frist springen:
</div>
<div className="fristen-search-chips" id="fristen-fork-chips" role="group" aria-label="Schnellzugriff">
{QUICK_CHIPS.map((c) => quickChip(c))}
</div>
</div>
</div>
{/* Pathway B container — search bar relocates here from the page top.
Mode toggle (B1 tree / B2 filter) sits above the panels.
Hidden until ?path=b. */}
<div className="fristen-pathway-shell" id="fristen-pathway-b" data-path="b" hidden>
<button type="button" className="fristen-pathway-back" id="fristen-pathway-b-back">
<span aria-hidden="true">&larr;</span>{" "}
<span data-i18n="deadlines.pathway.back">zur&uuml;ck zur Auswahl</span>
</button>
<h2 className="fristen-pathway-heading">
<span aria-hidden="true">&#128197;</span>{" "}
<span data-i18n="deadlines.pathway.b.title">Frist eintragen aufgrund Ereignis</span>
</h2>
{/* B1 panel — row-stack cascade.
`#fristen-row-stack` hosts the perspective / inbox /
cascade rows (t-paliad-180 Slice 1; t-paliad-197 Slice 2
added project-driven prefills + auto-walk). The
stack-header above carries the inline-search trigger
(t-paliad-198 Slice 3 — clicking expands
`#fristen-row-search-panel` over the row stack instead
of routing to the legacy B2 surface) and the reset link.
`#fristen-b1-results` is unchanged — it renders concept
cards for both cascade-narrowing AND inline-search
results, so users see the same card layout regardless
of how they reached a deadline rule. */}
<div className="fristen-b1-panel" id="fristen-b1-panel" data-mode="tree" hidden>
<div className="fristen-row-stack-header" id="fristen-row-stack-header">
<button type="button" className="fristen-row-search-link" id="fristen-row-search-link"
data-i18n-title="deadlines.row.search.link.title"
aria-expanded="false"
aria-controls="fristen-row-search-panel"
title="Direkt nach einer Frist suchen">
<span aria-hidden="true">&#128269;</span>{" "}
<span data-i18n="deadlines.row.search.link">Direkt suchen</span>
</button>
<button type="button" className="fristen-row-reset-link" id="fristen-row-reset"
data-i18n-title="deadlines.row.reset.title"
title="Pfad zur&uuml;cksetzen — alle Cascade-Antworten verwerfen">
<span aria-hidden="true">&#8634;</span>{" "}
<span data-i18n="deadlines.row.reset">Pfad zur&uuml;cksetzen</span>
</button>
</div>
{/* Inline search overlay (t-paliad-198 Slice 3). Hidden by
default; the search icon-button in the stack header
toggles it open / closed. While open, the row stack is
hidden and the search input drives `#fristen-b1-results`
directly — same surface the cascade leaf populates so
the user sees one consistent concept-card list. */}
<div className="fristen-row-search-panel" id="fristen-row-search-panel" hidden role="search">
<button type="button" className="fristen-row-search-panel-back" id="fristen-row-search-panel-back"
data-i18n-title="deadlines.row.search.panel.back.title"
title="Zur&uuml;ck zum Entscheidungsbaum">
<span aria-hidden="true">&larr;</span>{" "}
<span data-i18n="deadlines.row.search.panel.back">Zur&uuml;ck zum Entscheidungsbaum</span>
</button>
<div className="fristen-row-search-panel-input-wrap">
<svg className="fristen-row-search-panel-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-row-search-panel-input"
className="fristen-row-search-panel-input"
autocomplete="off"
spellcheck="false"
data-i18n-placeholder="deadlines.row.search.panel.placeholder"
placeholder="Frist suchen&hellip;"
aria-label="Frist suchen"
/>
<button type="button" className="fristen-row-search-panel-clear" id="fristen-row-search-panel-clear"
data-i18n-title="deadlines.row.search.panel.clear" title="Eingabe leeren" hidden>
<span aria-hidden="true">&times;</span>
</button>
</div>
</div>
<div className="fristen-row-stack" id="fristen-row-stack" aria-live="polite"></div>
<div className="fristen-b1-results" id="fristen-b1-results" aria-live="polite"></div>
</div>
{/* B2 panel — search bar + chips + concept-card results.
The search input + chips + results host live here so
fristenrechner.ts can drive both Phase D (today) and the
B1↔B2 state-share in Phase D (forum filter). */}
<div className="fristen-b2-panel" id="fristen-b2-panel" data-mode="filter">
<div className="fristen-search">
<label htmlFor="fristen-search-input" className="visually-hidden" data-i18n="deadlines.search.label">Frist suchen</label>
<div className="fristen-search-row">
<svg className="fristen-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-search-input"
className="fristen-search-input"
autocomplete="off"
spellcheck="false"
data-i18n-placeholder="deadlines.search.placeholder"
placeholder="Klageerwiderung, RoP 23, § 82, Wiedereinsetzung&hellip;"
/>
<button type="button" id="fristen-search-clear" className="fristen-search-clear" aria-label="Suche leeren" data-i18n-aria-label="deadlines.search.clear" hidden>
&times;
</button>
</div>
<div className="fristen-search-chips" id="fristen-search-chips" role="group" aria-label="Schnellzugriff">
<span className="fristen-search-chips-label" data-i18n="deadlines.search.chips.label">Schnellzugriff:</span>
{QUICK_CHIPS.map((c) => quickChip(c))}
</div>
{/* Forum filter row — populated by Phase D. */}
<div className="fristen-forum-filter" id="fristen-forum-filter" hidden>
<span className="fristen-forum-filter-label" data-i18n="deadlines.filter.forum.label">Gericht / System:</span>
<div className="fristen-forum-chips" id="fristen-forum-chips"></div>
</div>
<div id="fristen-search-results" className="fristen-search-results" aria-live="polite"></div>
</div>
</div>
</div>
{/* Step 3a — outgoing-intent chooser. Reached when the user
picks "Etwas einreichen" on Step 2. Three options per
m's 2026-05-08 18:09 spec: File (drives the Pathway A
wizard), Draft (future drafting surface; v1
placeholder), Enter (routes to the existing manual-
create form). */}
<div className="fristen-pathway-shell" id="fristen-step3a" data-path="outgoing" hidden>
<button type="button" className="fristen-pathway-back" id="fristen-step3a-back">
<span aria-hidden="true">&larr;</span>{" "}
<span data-i18n="deadlines.step3a.back">zur&uuml;ck zur Auswahl</span>
</button>
<h2 className="fristen-pathway-heading">
<span aria-hidden="true">&#9999;&#65039;</span>{" "}
<span data-i18n="deadlines.step3a.heading">Was m&ouml;chten Sie einreichen?</span>
</h2>
<div className="fristen-step2-cards">
<button type="button" className="fristen-step2-card" id="fristen-step3a-file" data-action="file">
<span className="fristen-step2-card-icon" aria-hidden="true">&#128221;</span>
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.file.title">
Schriftsatz einreichen
</span>
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.file.desc">
Verfahrensablauf laden &mdash; Frist berechnen und zur Akte hinzuf&uuml;gen.
</span>
</button>
<button type="button" className="fristen-step2-card fristen-step2-card--soon" id="fristen-step3a-draft" data-action="draft" disabled
data-i18n-title="deadlines.step3a.soon">
<span className="fristen-step2-card-icon" aria-hidden="true">&#128393;</span>
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.draft.title">
Schriftsatz entwerfen
</span>
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.draft.desc">
Vorbereitung &mdash; sp&auml;ter mit Drafting-Surface verkn&uuml;pft.
</span>
<span className="fristen-step2-card-soon" data-i18n="deadlines.step3a.soon">kommt bald</span>
</button>
<button type="button" className="fristen-step2-card" id="fristen-step3a-enter" data-action="enter">
<span className="fristen-step2-card-icon" aria-hidden="true">&#128190;</span>
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.enter.title">
Frist manuell erfassen
</span>
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.enter.desc">
Direkt eintragen &mdash; bereits bekanntes Datum / bekannter Typ.
</span>
</button>
</div>
</div>
{/* Pathway A container — wraps the existing wizard.
Hidden until ?path=a. */}
<div className="fristen-pathway-shell" id="fristen-pathway-a" data-path="a" hidden>
<button type="button" className="fristen-pathway-back" id="fristen-pathway-a-back">
<span aria-hidden="true">&larr;</span>{" "}
<span data-i18n="deadlines.pathway.back">zur&uuml;ck zur Auswahl</span>
</button>
<h2 className="fristen-pathway-heading">
<span aria-hidden="true">&#128214;</span>{" "}
<span data-i18n="deadlines.pathway.a.title">Verfahrensablauf informieren</span>
</h2>
{/* v3: legacy mode tabs retired (m's spec lock §10 Q1, 2026-05-05).
Pathway A is Verfahrensablauf-only; trigger-event drill-in
surfaces via concept-card pills with ?path=a&trigger=N URL,
which resurfaces mode-event-panel programmatically below. */}
<div className="fristen-wizard mode-panel" id="mode-procedure-panel" data-mode="procedure" role="tabpanel">
<div className="wizard-step" id="step-1">
<h3 className="wizard-step-label">
<span className="step-number">1</span>
<span data-i18n="deadlines.step1">Verfahrensart w&auml;hlen</span>
</h3>
<div className="proceeding-group" data-forum="upc">
<h4 data-i18n="deadlines.upc">UPC</h4>
<div className="proceeding-btns">
{UPC_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group" data-forum="de">
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
<div className="proceeding-btns">
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
<div className="proceeding-btns">
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
</div>
<div className="proceeding-group" data-forum="epa">
<h4 data-i18n="deadlines.epa">EPA</h4>
<div className="proceeding-btns">
{EPA_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group" data-forum="dpma">
<h4 data-i18n="deadlines.dpma">DPMA</h4>
<div className="proceeding-btns">
{DPMA_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
{/* m's 2026-05-08 18:26: collapse the proceeding picker once
a choice is made; this summary line replaces the four
group blocks with a one-line "Selected: X [Reselect]"
affordance. JS toggles `.proceeding-summary` visibility
in lockstep with `.proceeding-group` blocks. */}
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
<strong className="proceeding-summary-name" id="proceeding-summary-name">&mdash;</strong>
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
data-i18n="deadlines.proceeding.reselect">
Anderes Verfahren w&auml;hlen
</button>
</div>
</div>
<div className="wizard-step" id="step-2" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">2</span>
<span data-i18n="deadlines.step2">Ausgangsdatum eingeben</span>
</h3>
<div className="date-input-group">
<div className="date-field-row">
{/* Read-only caption labelling the value <span>. Not a
<label htmlFor> — m/paliad#60: <label for=…> must
point at a labelable form control, never a span. */}
<span className="date-label" data-i18n="deadlines.trigger.event">Ausl&ouml;sendes Ereignis:</span>
<span id="trigger-event" className="trigger-event-name">&mdash;</span>
</div>
<div className="date-field-row">
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
<input type="date" id="trigger-date" className="date-input" value={today} />
</div>
<div className="date-field-row" id="court-picker-row" style="display:none">
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
<select id="court-picker" className="date-input"></select>
</div>
<div className="date-field-row" id="priority-date-row" style="display:none">
<label htmlFor="priority-date" className="date-label" data-i18n="deadlines.priority.date">Priorit&auml;tstag (optional):</label>
<input type="date" id="priority-date" className="date-input" />
</div>
<div className="date-field-row" id="ccr-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="ccr-flag" />
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
</label>
</div>
<div className="date-field-row date-field-row--nested" id="inf-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="inf-amend-flag" />
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patent&auml;nderung (R.30)</span>
</label>
</div>
<div className="date-field-row" id="rev-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-amend-flag" />
<span data-i18n="deadlines.flag.rev_amend">Mit Antrag auf Patent&auml;nderung (R.49.2.a)</span>
</label>
</div>
<div className="date-field-row" id="rev-cci-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-cci-flag" />
<span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span>
</label>
</div>
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
Fristen berechnen
</button>
</div>
</div>
<div className="wizard-step" id="step-3" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">3</span>
<span data-i18n="deadlines.step3">Ergebnis</span>
</h3>
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
<label className="fristen-view-option">
<input type="radio" name="fristen-view" value="columns" checked />
<span data-i18n="deadlines.view.columns">Spalten</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="fristen-view" value="timeline" />
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
</label>
<label className="fristen-notes-option">
<input type="checkbox" id="fristen-notes-show" />
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
</label>
</div>
<div id="timeline-container">
</div>
<div className="fristen-result-actions">
<button type="button" id="fristen-save-cta" className="btn-primary btn-cta-lime" style="display:none" data-i18n="deadlines.save.cta">
Als Frist(en) speichern
</button>
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="6 9 6 2 18 2 18 9"></polyline>
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
<rect x="6" y="14" width="12" height="8"></rect>
</svg>
<span data-i18n="deadlines.print">Drucken</span>
</button>
</div>
</div>
<button type="button" id="reset-btn" className="reset-btn" style="display:none" data-i18n="deadlines.reset">
&larr; Neu berechnen
</button>
</div>
<div className="fristen-wizard mode-panel" id="mode-event-panel" data-mode="event" role="tabpanel" hidden>
<div className="wizard-step" id="event-step-1">
<h3 className="wizard-step-label">
<span className="step-number">1</span>
<span data-i18n="deadlines.event.step1">Trigger-Ereignis w&auml;hlen</span>
</h3>
<p className="wizard-step-hint" data-i18n="deadlines.event.step1.hint">
Welches Ereignis ist eingetreten? (z.B. Klageerhebung, Entscheidung des EPA, Zustellung einer Verf&uuml;gung)
</p>
<div className="event-picker-row">
<label htmlFor="event-search" className="visually-hidden" data-i18n="deadlines.event.search.label">Trigger-Ereignis suchen</label>
<input
type="search"
id="event-search"
className="event-search-input"
autocomplete="off"
data-i18n-placeholder="deadlines.event.search.placeholder"
placeholder="Tippe, um zu suchen&hellip;"
/>
<ul id="event-list" className="event-list" role="listbox" aria-label="Trigger-Ereignisse"></ul>
</div>
</div>
<div className="wizard-step" id="event-step-2" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">2</span>
<span data-i18n="deadlines.event.step2">Datum des Ereignisses</span>
</h3>
<div className="date-input-group">
<div className="date-field-row">
<label className="date-label" data-i18n="deadlines.event.selected">Gew&auml;hltes Ereignis:</label>
<span id="event-selected-name" className="trigger-event-name">&mdash;</span>
</div>
<div className="date-field-row">
<label htmlFor="event-date" className="date-label" data-i18n="deadlines.event.date">Eintrittsdatum:</label>
<input type="date" id="event-date" className="date-input" value={today} />
</div>
<button type="button" id="event-calculate-btn" className="calculate-btn" data-i18n="deadlines.event.calculate">
Folgefristen berechnen
</button>
</div>
</div>
<div className="wizard-step" id="event-step-3" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">3</span>
<span data-i18n="deadlines.event.step3">Folgefristen</span>
</h3>
<div id="event-results-container"></div>
<div className="fristen-result-actions">
<button type="button" id="event-print-btn" className="print-btn" style="display:none">
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="6 9 6 2 18 2 18 9"></polyline>
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
<rect x="6" y="14" width="12" height="8"></rect>
</svg>
<span data-i18n="deadlines.print">Drucken</span>
</button>
</div>
</div>
<button type="button" id="event-reset-btn" className="reset-btn" style="display:none" data-i18n="deadlines.reset">
&larr; Neu berechnen
</button>
</div>
</div>{/* /pathway-a */}
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/fristenrechner.js"></script>
</body>
</html>
);
}

View File

@@ -125,6 +125,12 @@ export type I18nKey =
| "admin.broadcasts.loading"
| "admin.broadcasts.subtitle"
| "admin.broadcasts.title"
| "admin.building_blocks.action.new"
| "admin.building_blocks.editor.empty"
| "admin.building_blocks.heading"
| "admin.building_blocks.loading"
| "admin.building_blocks.subtitle"
| "admin.building_blocks.title"
| "admin.card.approval_policies.desc"
| "admin.card.approval_policies.title"
| "admin.card.audit.desc"
@@ -291,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"
@@ -721,6 +728,83 @@ export type I18nKey =
| "bottomnav.add.title"
| "bottomnav.badge.deadlines"
| "bottomnav.menu"
| "builder.action.promote"
| "builder.action.rename"
| "builder.action.rename.prompt"
| "builder.action.share"
| "builder.akte.none"
| "builder.bucket.active"
| "builder.canvas.add_proceeding"
| "builder.empty.cta"
| "builder.empty.headline"
| "builder.empty.hint"
| "builder.empty.recent"
| "builder.event.action.file"
| "builder.event.action.reset"
| "builder.event.action.skip"
| "builder.event.actual_date.prompt"
| "builder.event.horizon.hide"
| "builder.event.horizon.label"
| "builder.event.skip_reason.prompt"
| "builder.event.state.filed"
| "builder.event.state.planned"
| "builder.event.state.skipped"
| "builder.header.akte"
| "builder.header.scenario"
| "builder.header.search"
| "builder.header.stichtag"
| "builder.mode.akte"
| "builder.mode.cold"
| "builder.mode.event"
| "builder.panel.empty"
| "builder.panel.new"
| "builder.panel.title"
| "builder.picker.aria"
| "builder.picker.axis.forum"
| "builder.picker.axis.proc"
| "builder.picker.close"
| "builder.picker.empty"
| "builder.picker.future_jurisdiction"
| "builder.picker.placeholder"
| "builder.picker.title"
| "builder.save.error"
| "builder.save.idle"
| "builder.save.saved"
| "builder.save.saving"
| "builder.search.anchor.divider"
| "builder.search.group.events"
| "builder.search.group.projects"
| "builder.search.group.scenarios"
| "builder.search.hint.akte_b4"
| "builder.search.hint.empty"
| "builder.search.hint.error"
| "builder.search.hint.loading"
| "builder.search.hint.short"
| "builder.search.hint.start"
| "builder.search.placeholder"
| "builder.search.summary.events.one"
| "builder.search.summary.events.other"
| "builder.search.summary.projects.one"
| "builder.search.summary.projects.other"
| "builder.search.summary.scenarios.one"
| "builder.search.summary.scenarios.other"
| "builder.subtitle"
| "builder.triplet.collapse"
| "builder.triplet.detailgrad.all_options"
| "builder.triplet.detailgrad.label"
| "builder.triplet.detailgrad.selected"
| "builder.triplet.expand"
| "builder.triplet.flags.label"
| "builder.triplet.loading"
| "builder.triplet.no_flags"
| "builder.triplet.perspective.claimant"
| "builder.triplet.perspective.defendant"
| "builder.triplet.perspective.label"
| "builder.triplet.perspective.none"
| "builder.triplet.remove"
| "builder.triplet.side.claimant"
| "builder.triplet.side.defendant"
| "builder.triplet.unknown_proceeding"
| "cal.day.back_to_month"
| "cal.day.fri"
| "cal.day.mon"
@@ -1239,6 +1323,8 @@ export type I18nKey =
| "deadlines.de.inf.olg"
| "deadlines.de.null.bgh"
| "deadlines.de.null.bpatg"
| "deadlines.detail.all_options"
| "deadlines.detail.aufnehmen"
| "deadlines.detail.back"
| "deadlines.detail.cancel"
| "deadlines.detail.complete"
@@ -1252,12 +1338,17 @@ export type I18nKey =
| "deadlines.detail.delete.confirm.title"
| "deadlines.detail.due"
| "deadlines.detail.edit"
| "deadlines.detail.entfernen"
| "deadlines.detail.label"
| "deadlines.detail.loading"
| "deadlines.detail.mandatory_only"
| "deadlines.detail.notes"
| "deadlines.detail.notfound"
| "deadlines.detail.optional_unselected_hint"
| "deadlines.detail.reopen"
| "deadlines.detail.rule"
| "deadlines.detail.save"
| "deadlines.detail.selected"
| "deadlines.detail.source"
| "deadlines.detail.title"
| "deadlines.dpma"
@@ -1370,6 +1461,72 @@ export type I18nKey =
| "deadlines.neu.title"
| "deadlines.notes.show"
| "deadlines.optional.badge"
| "deadlines.overhaul.condition.badge"
| "deadlines.overhaul.crossparty.badge"
| "deadlines.overhaul.crossparty.tooltip"
| "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"
@@ -1991,7 +2148,6 @@ export type I18nKey =
| "nav.downloads"
| "nav.einstellungen"
| "nav.fristen"
| "nav.fristenrechner"
| "nav.gebuehrentabellen"
| "nav.gerichte"
| "nav.glossar"
@@ -2008,13 +2164,13 @@ export type I18nKey =
| "nav.logout"
| "nav.neuigkeiten"
| "nav.paliadin"
| "nav.procedures"
| "nav.projekte"
| "nav.soon.tooltip"
| "nav.submissions"
| "nav.team"
| "nav.termine"
| "nav.user_views.new"
| "nav.verfahrensablauf"
| "notes.cancel"
| "notes.delete"
| "notes.delete.confirm"
@@ -2124,6 +2280,19 @@ export type I18nKey =
| "partner_unit.members_label"
| "partner_unit.none"
| "partner_unit.subtitle"
| "procedures.filter.axis.forum"
| "procedures.filter.axis.kind"
| "procedures.filter.axis.party"
| "procedures.filter.axis.proc"
| "procedures.filter.search.placeholder"
| "procedures.heading"
| "procedures.panel.akte.placeholder"
| "procedures.subtitle"
| "procedures.tab.akte"
| "procedures.tab.proceeding"
| "procedures.tab.search"
| "procedures.tab.wizard"
| "procedures.title"
| "project.instance_level.appeal"
| "project.instance_level.cassation"
| "project.instance_level.first"
@@ -2615,6 +2784,8 @@ export type I18nKey =
| "submissions.draft.action.export"
| "submissions.draft.action.new"
| "submissions.draft.back"
| "submissions.draft.base.hint"
| "submissions.draft.base.label"
| "submissions.draft.import.button"
| "submissions.draft.language"
| "submissions.draft.language.de"
@@ -2627,6 +2798,8 @@ export type I18nKey =
| "submissions.draft.parties.title"
| "submissions.draft.preview.hint"
| "submissions.draft.preview.title"
| "submissions.draft.sections.hint"
| "submissions.draft.sections.title"
| "submissions.draft.switcher.label"
| "submissions.draft.title"
| "submissions.index.action.new"
@@ -2719,9 +2892,6 @@ export type I18nKey =
| "theme.toggle.cycle.light"
| "theme.toggle.dark"
| "theme.toggle.light"
| "tools.verfahrensablauf.heading"
| "tools.verfahrensablauf.subtitle"
| "tools.verfahrensablauf.title"
| "unit_role.attorney"
| "unit_role.lead"
| "unit_role.pa"

View File

@@ -74,7 +74,7 @@ export function renderIndex(): string {
<p data-i18n="index.cost.desc">Sch&auml;tzung der Verfahrenskosten f&uuml;r DE-Gerichte, UPC und EPA-Verfahren. Gerichts- und Anwaltskosten auf einen Blick.</p>
</a>
<a href="/tools/fristenrechner" className="card card-link">
<a href="/tools/procedures" className="card card-link">
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_CLOCK }} />
<h2 data-i18n="index.deadline.title">Fristenrechner</h2>
<p data-i18n="index.deadline.desc">Berechnung von Verfahrensfristen f&uuml;r UPC-, deutsche und EPA-Verfahren mit Feiertags-Anpassung.</p>

178
frontend/src/procedures.tsx Normal file
View File

@@ -0,0 +1,178 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// /tools/procedures — Litigation Builder (m/paliad#153 PRD §3).
//
// Replaces cronus's 4-tab catalog (U0-U4) with a persistence-backed
// builder shell. Server-rendered chrome is minimal — the page-header
// scenario picker, side panel, and canvas are all hydrated by
// `builder.ts` at boot. The builder loads scenarios from
// /api/builder/scenarios (B0 surface, t-paliad-340) and renders the
// per-proceeding triplets with the existing verfahrensablauf-core calc.
//
// B1 — Builder shell + cold-open mode + single triplet end-to-end.
// B2 — Multi-triplet stack + spawn nesting + per-event state machine.
// B3+ — event-triggered + Akte modes, sharing, promotion (head-gated).
export function renderProcedures(): string {
const today = new Date().toISOString().split("T")[0];
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="procedures.title">Verfahren &amp; Fristen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar page-procedures page-builder">
<Sidebar currentPath="/tools/procedures" />
<BottomNav currentPath="/tools/procedures" />
<main>
<section className="tool-page builder-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="procedures.heading">Verfahren &amp; Fristen</h1>
<p className="tool-subtitle" data-i18n="builder.subtitle">
Litigation Builder &mdash; Szenarien bauen, Verfahren stapeln, Fristen behalten.
</p>
</div>
{/* Page header (PRD §3.1): scenario picker · save state · name · share · promote
· Akte picker · Stichtag input. B1 wires the scenario picker
+ name action + Stichtag + save indicator. Akte / share /
promote land at B4 / B5; the affordances render disabled in
B1 so the layout is stable across slices. */}
<section className="builder-pageheader" aria-label="Builder-Steuerung">
<div className="builder-pageheader-row">
<label className="builder-pageheader-field">
<span className="builder-pageheader-label" data-i18n="builder.header.scenario">Szenario:</span>
<select id="builder-scenario-picker" className="builder-scenario-picker" aria-label="Szenario w&auml;hlen"></select>
</label>
<span id="builder-save-status" className="builder-save-status" aria-live="polite" data-state="idle">
<span data-i18n="builder.save.idle">&nbsp;</span>
</span>
<span className="builder-pageheader-spacer"></span>
<button type="button" id="builder-rename-btn"
className="builder-action-btn builder-action-btn--secondary"
disabled
data-i18n="builder.action.rename">Benennen</button>
<button type="button" id="builder-share-btn"
className="builder-action-btn builder-action-btn--secondary"
disabled
title="In B5 verf&uuml;gbar"
data-i18n="builder.action.share">Teilen</button>
<button type="button" id="builder-promote-btn"
className="builder-action-btn builder-action-btn--primary"
disabled
title="In B5 verf&uuml;gbar"
data-i18n="builder.action.promote">Als Projekt anlegen</button>
</div>
<div className="builder-pageheader-row">
<label className="builder-pageheader-field">
<span className="builder-pageheader-label" data-i18n="builder.header.akte">Akte:</span>
<select id="builder-akte-picker" className="builder-akte-picker" disabled aria-label="Akte w&auml;hlen">
<option value="" data-i18n="builder.akte.none">&mdash; ohne &mdash;</option>
</select>
</label>
<label className="builder-pageheader-field">
<span className="builder-pageheader-label" data-i18n="builder.header.stichtag">Stichtag:</span>
<input type="date" id="builder-stichtag-input" className="builder-stichtag-input"
defaultValue={today} aria-label="Stichtag" />
</label>
<label className="builder-pageheader-field builder-pageheader-field--grow">
<span className="builder-pageheader-label" data-i18n="builder.header.search">Suche:</span>
<input type="search" id="builder-search-input" className="builder-search-input"
data-i18n-placeholder="builder.search.placeholder"
placeholder="Ereignis, Szenario, Akte &hellip;"
autocomplete="off" spellcheck="false" />
</label>
</div>
</section>
{/* Entry-mode radio (PRD §0.2, §2). B1 ships cold-open active;
event-triggered + akte ship at B3 / B4 and are disabled
here so the layout stays stable across slices. */}
<nav className="builder-modebar" role="tablist" aria-label="Einstieg">
<button type="button"
className="builder-mode is-active"
role="tab"
aria-selected="true"
data-mode="cold"
id="builder-mode-cold">
<span className="builder-mode-label" data-i18n="builder.mode.cold">&Uuml;bersicht</span>
</button>
<button type="button"
className="builder-mode"
role="tab"
aria-selected="false"
data-mode="event"
id="builder-mode-event">
<span className="builder-mode-label" data-i18n="builder.mode.event">Ereignis</span>
</button>
<button type="button"
className="builder-mode"
role="tab"
aria-selected="false"
data-mode="akte"
id="builder-mode-akte"
disabled
title="In B4 verf&uuml;gbar">
<span className="builder-mode-label" data-i18n="builder.mode.akte">Aus Akte</span>
</button>
</nav>
{/* Two-column body: side panel (left, scenarios list) + canvas (right). */}
<div className="builder-body">
<aside className="builder-sidepanel" aria-label="Meine Szenarien">
<header className="builder-sidepanel-header">
<h2 className="builder-sidepanel-title" data-i18n="builder.panel.title">Meine Szenarien</h2>
<button type="button" id="builder-new-scenario-btn"
className="builder-sidepanel-newbtn"
data-i18n="builder.panel.new">+ Neues Szenario</button>
</header>
<div className="builder-sidepanel-bucket" data-bucket="active">
<h3 className="builder-bucket-label" data-i18n="builder.bucket.active">Aktiv</h3>
<ul className="builder-scenario-list" id="builder-scenario-list-active" aria-label="Aktive Szenarien"></ul>
</div>
{/* Geteilt / Promoted / Archiviert buckets land in B5+. */}
</aside>
<section className="builder-canvas-wrap" aria-label="Builder-Canvas">
<div id="builder-canvas" className="builder-canvas">
{/* Cold-open placeholder — replaced by triplet stack once a
scenario is loaded. */}
<div className="builder-empty" id="builder-empty">
<p className="builder-empty-headline" data-i18n="builder.empty.headline">
Noch kein Szenario ge&ouml;ffnet.
</p>
<p className="builder-empty-hint" data-i18n="builder.empty.hint">
Starte ein neues Szenario, w&auml;hle aus deiner Liste oder &uuml;bernimm eine Akte (B4).
</p>
<button type="button" id="builder-cta-new" className="builder-cta-new"
data-i18n="builder.empty.cta">
Neues Szenario starten
</button>
</div>
</div>
</section>
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/procedures.js"></script>
</body>
</html>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -109,6 +109,27 @@ export function renderSubmissionDraft(): string {
</button>
</div>
{/* t-paliad-313 (m/paliad#141) Composer Slice A —
base picker. Hydrated by client/submission-draft.ts
once /api/submission-bases returns. Disabled
for pre-Composer drafts (base_id NULL); switching
autosaves the draft. */}
<div
className="submission-draft-base-row"
id="submission-draft-base-row"
style="display:none">
<label htmlFor="submission-draft-base" data-i18n="submissions.draft.base.label">
Vorlagenbasis
</label>
<select id="submission-draft-base" />
<p
className="submission-draft-base-hint"
id="submission-draft-base-hint"
data-i18n="submissions.draft.base.hint">
Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.
</p>
</div>
{/* t-paliad-276 — output language toggle (DE/EN).
Hydrated by client/submission-draft.ts; switching
autosaves the draft and re-renders the preview. */}
@@ -202,6 +223,29 @@ export function renderSubmissionDraft(): string {
<div className="submission-draft-variables" id="submission-draft-variables" />
</aside>
{/* t-paliad-313 (m/paliad#141) Composer Slice A —
read-only section list. Painted from
view.sections. Empty/hidden for pre-Composer
drafts where no rows have been seeded. Slice B
turns these into in-place editable prose blocks. */}
<section
className="submission-draft-sections-wrap"
id="submission-draft-sections-wrap"
style="display:none">
<header className="submission-draft-sections-header">
<h2 data-i18n="submissions.draft.sections.title">Abschnitte</h2>
<span
className="submission-draft-sections-hint"
data-i18n="submissions.draft.sections.hint">
Inhalt pro Abschnitt &mdash; Autosave nach 500 ms. Letztes Layout in Word.
</span>
</header>
<ol
className="submission-draft-sections-list"
id="submission-draft-sections-list"
/>
</section>
{/* Preview pane — read-only HTML render of the merged
document body. Re-renders on autosave round-trip. */}
<section className="submission-draft-preview-wrap">

View File

@@ -1,378 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { PaliadinWidget } from "./components/PaliadinWidget";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// Slice 1 (t-paliad-179) — the dedicated abstract-browse surface for
// procedural shape. Same backend (POST /api/tools/fristenrechner) +
// same renderer module (./client/views/verfahrensablauf-core) as
// /tools/fristenrechner; this page strips the Step 1 Akte picker /
// Step 2 cards / Pathway A wizard / Pathway B cascade / save modal,
// leaving just: proceeding-type tile picker + trigger date + court
// picker + result panel. Variant chips, lane view and compare arrive in
// Slices 2-4.
interface ProceedingDef {
code: string;
i18nKey: string;
name: string;
}
function proceedingBtn(p: ProceedingDef): string {
return (
<button type="button" className="proceeding-btn" data-code={p.code}>
<strong data-i18n={p.i18nKey}>{p.name}</strong>
</button>
);
}
// Slice B1 (m/paliad#124 §18.1): the 3 separate Berufung tiles
// (upc.apl.merits / upc.apl.cost / upc.apl.order) collapse into ONE
// unified "Berufung" tile (upc.apl). After picking it, the user
// selects which decision the appeal is directed AT via the
// .appeal-target-row chip group below — the engine then filters
// rules whose applies_to_target contains the picked slug.
const UPC_TYPES: ProceedingDef[] = [
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
{ code: "upc.apl.unified", i18nKey: "deadlines.upc.apl.unified", name: "Berufung" },
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
];
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
// 2026-05-18 ask. Labels are parallel: <court> (<procedural role>),
// so a user scanning the picker sees the instance-and-role at a glance
// without one tile reading "Berufung OLG" and another "Nichtigkeits-
// verfahren". Sub-group headers convey the type grouping. Combined-
// timeline behaviour (LG→OLG→BGH as one calc) is filed as m/paliad#41.
const DE_INF_TYPES: ProceedingDef[] = [
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
];
const DE_NULL_TYPES: ProceedingDef[] = [
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
];
const EPA_TYPES: ProceedingDef[] = [
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
];
const DPMA_TYPES: ProceedingDef[] = [
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
];
export function renderVerfahrensablauf(): string {
const today = new Date().toISOString().split("T")[0];
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="tools.verfahrensablauf.title">Verfahrensablauf &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar page-verfahrensablauf">
<Sidebar currentPath="/tools/verfahrensablauf" />
<BottomNav currentPath="/tools/verfahrensablauf" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<h1 data-i18n="tools.verfahrensablauf.heading">Verfahrensablauf</h1>
<p className="tool-subtitle" data-i18n="tools.verfahrensablauf.subtitle">
Typischen Verfahrensablauf einsehen &mdash; Verfahrensart w&auml;hlen, Datum optional setzen.
</p>
</div>
{/* Verfahrensart picker (single-tile mode — same DOM ids as
/tools/fristenrechner so the shared renderer module and
court-picker primitives bind without parameterisation). */}
<div className="fristen-wizard" id="verfahrensablauf-wizard" data-mode="procedure">
<div className="wizard-step" id="step-1">
<h3 className="wizard-step-label">
<span className="step-number">1</span>
<span data-i18n="deadlines.step1">Verfahrensart w&auml;hlen</span>
</h3>
<div className="proceeding-group" data-forum="upc">
<h4 data-i18n="deadlines.upc">UPC</h4>
<div className="proceeding-btns">
{UPC_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group" data-forum="de">
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
<div className="proceeding-btns">
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-subgroup">
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
<div className="proceeding-btns">
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
</div>
<div className="proceeding-group" data-forum="epa">
<h4 data-i18n="deadlines.epa">EPA</h4>
<div className="proceeding-btns">
{EPA_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-group" data-forum="dpma">
<h4 data-i18n="deadlines.dpma">DPMA</h4>
<div className="proceeding-btns">
{DPMA_TYPES.map((p) => proceedingBtn(p))}
</div>
</div>
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
<strong className="proceeding-summary-name" id="proceeding-summary-name">&mdash;</strong>
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
data-i18n="deadlines.proceeding.reselect">
Anderes Verfahren w&auml;hlen
</button>
</div>
</div>
<div className="wizard-step" id="step-2" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">2</span>
<span data-i18n="deadlines.step2.perspective">Perspektive und Datum</span>
</h3>
{/* Perspective strip (t-paliad-250 / m/paliad#81, reordered
in t-paliad-279 / m/paliad#111). Side defines whose
perspective the columns project; appellant collapses
party=both rows for role-swap proceedings (Appeal etc.).
Moved above .date-input-group because party-side is the
most-defining input after proceeding-type — without
side, the column labels can't pick "your filings". Both
selectors are URL-driven (?side= + ?appellant=) so the
perspective survives reload and is shareable.
When the page is opened with ?project=<id> and that
project's our_side is set, side-row renders as a
read-only chip with an "Andere Seite wählen" override
link — see client/verfahrensablauf.ts. */}
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
<div className="verfahrensablauf-perspective-row" id="side-row">
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
<div className="side-radio-cluster" id="side-radio-cluster">
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
<label className="fristen-view-option">
<input type="radio" name="side" value="claimant" />
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="defendant" />
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="" checked />
<span data-i18n="deadlines.side.undefined">Nicht festgelegt</span>
</label>
</div>
{/* Prompt shown while the user hasn't picked a side
(m/paliad#120). Hidden by client when side is
claimant or defendant. Both columns still
render every rule in this state — picking a
side just focuses the user's column. */}
<span className="side-hint" id="side-hint"
data-i18n="deadlines.side.hint">
W&auml;hlen Sie eine Seite, um die Spalten zu fokussieren.
</span>
</div>
{/* Auto-fill chip — populated by the client when a
?project=<id> URL resolves a project with our_side
set. Hidden by default; the radio cluster above is
hidden whenever this chip is shown. */}
<div className="side-chip" id="side-chip" style="display:none">
<span className="side-chip-tag" data-i18n="deadlines.side.from_project">Aus Akte:</span>
<strong className="side-chip-value" id="side-chip-value">&mdash;</strong>
<button type="button" className="side-chip-override" id="side-chip-override"
data-i18n="deadlines.side.override">
Andere Seite w&auml;hlen
</button>
</div>
</div>
{/* Appeal-target chip row (Slice B1 / m/paliad#124 §18.1).
Shown only when the unified upc.apl Berufung tile is
selected; lets the user narrow the timeline to the
rules whose applies_to_target contains the picked
decision kind. URL state ?target=<slug>. */}
<div className="verfahrensablauf-perspective-row" id="appeal-target-row" style="display:none">
<span className="date-label" data-i18n="deadlines.appeal_target.label">Worauf richtet sich die Berufung?</span>
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appeal target">
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="endentscheidung" checked />
<span data-i18n="deadlines.appeal_target.endentscheidung">Endentscheidung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="kostenentscheidung" />
<span data-i18n="deadlines.appeal_target.kostenentscheidung">Kostenentscheidung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="anordnung" />
<span data-i18n="deadlines.appeal_target.anordnung">Anordnung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="schadensbemessung" />
<span data-i18n="deadlines.appeal_target.schadensbemessung">Schadensbemessung</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appeal-target" value="bucheinsicht" />
<span data-i18n="deadlines.appeal_target.bucheinsicht">Bucheinsicht</span>
</label>
</div>
</div>
{/* Show-hidden toggle (t-paliad-290 / m/paliad#122).
Re-surfaces optional cards the user has previously
marked "Überspringen" via the per-card popover.
The row hides itself when the projection has no
hidden cards (handled in client/verfahrensablauf.ts).
Default OFF; URL state ?show_hidden=1. */}
<div className="verfahrensablauf-perspective-row" id="show-hidden-row" style="display:none">
<label className="fristen-view-option">
<input type="checkbox" id="show-hidden-toggle" />
<span data-i18n="choices.show_hidden.label">Ausgeblendete anzeigen</span>
</label>
<span className="show-hidden-count" id="show-hidden-count" aria-live="polite">&nbsp;</span>
</div>
</div>
{/* Visual divider — keeps the perspective block (most-
defining inputs after proceeding-type) optically
separate from the date / court / flag knobs below. */}
<div className="verfahrensablauf-step2-divider" aria-hidden="true"></div>
<div className="date-input-group">
<div className="date-field-row">
{/* Read-only caption labelling the value <span>. Not a
<label htmlFor> — m/paliad#60: <label for=…> must
point at a labelable form control, never a span. */}
<span className="date-label" data-i18n="deadlines.trigger.event">Ausl&ouml;sendes Ereignis:</span>
<span id="trigger-event" className="trigger-event-name">&mdash;</span>
</div>
<div className="date-field-row">
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
<input type="date" id="trigger-date" className="date-input" value={today} />
</div>
<div className="date-field-row" id="court-picker-row" style="display:none">
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
<select id="court-picker" className="date-input"></select>
</div>
{/* Proceeding-specific flag rows — mirror /tools/fristenrechner
so an abstract-browse user can model the same variants
(CCR, Patentänderung, Verletzungswiderklage,
Vorab-Einrede). Show/hide driven by selectedType in
the client. */}
<div className="date-field-row" id="ccr-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="ccr-flag" />
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
</label>
</div>
<div className="date-field-row date-field-row--nested" id="inf-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="inf-amend-flag" />
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patent&auml;nderung (R.30)</span>
</label>
</div>
<div className="date-field-row" id="rev-amend-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-amend-flag" />
<span data-i18n="deadlines.flag.rev_amend">Mit Antrag auf Patent&auml;nderung (R.49.2.a)</span>
</label>
</div>
<div className="date-field-row" id="rev-cci-flag-row" style="display:none">
<label className="date-label">
<input type="checkbox" id="rev-cci-flag" />
<span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span>
</label>
</div>
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
Fristen berechnen
</button>
</div>
</div>
<div className="wizard-step" id="step-3" style="display:none">
<h3 className="wizard-step-label">
<span className="step-number">3</span>
<span data-i18n="deadlines.step3">Ergebnis</span>
</h3>
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
<label className="fristen-view-option">
<input type="radio" name="fristen-view" value="columns" checked />
<span data-i18n="deadlines.view.columns">Spalten</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="fristen-view" value="timeline" />
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
</label>
<label className="fristen-notes-option">
<input type="checkbox" id="fristen-notes-show" />
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
</label>
{/* Durations toggle (m/paliad#133, t-paliad-302).
Default off — hover-tooltips on date spans are
the always-on path. */}
<label className="fristen-notes-option">
<input type="checkbox" id="verfahrensablauf-durations-show" />
<span data-i18n="deadlines.durations.show">Dauern anzeigen</span>
</label>
</div>
<div id="timeline-container">
</div>
<div className="fristen-result-actions">
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="6 9 6 2 18 2 18 9"></polyline>
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
<rect x="6" y="14" width="12" height="8"></rect>
</svg>
<span data-i18n="deadlines.print">Drucken</span>
</button>
</div>
</div>
</div>
</div>
</section>
</main>
<Footer />
<PaliadinWidget />
<script src="/assets/verfahrensablauf.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,7 @@
-- 139_deadline_rules_unified_view (down) — Slice B.3, t-paliad-305
--
-- Drops the view. The underlying paliad.sequencing_rules /
-- procedural_events / legal_sources tables are untouched (they own the
-- data — the view is just a projection).
DROP VIEW IF EXISTS paliad.deadline_rules_unified;

View File

@@ -0,0 +1,122 @@
-- 139_deadline_rules_unified_view — Slice B.3 read cutover (t-paliad-305 / m/paliad#93)
--
-- Creates paliad.deadline_rules_unified — a Postgres VIEW that
-- re-projects paliad.sequencing_rules + paliad.procedural_events +
-- paliad.legal_sources back into the legacy paliad.deadline_rules
-- column shape.
--
-- Why a view instead of rewriting every SELECT in Go:
--
-- - 19 read sites across 11 service files reference
-- paliad.deadline_rules. Rewriting each by hand multiplies the
-- opportunity for off-by-one bugs in the JOIN.
-- - The view has the same column names + types as the legacy table,
-- so the change in Go is a 1-token substitution per query
-- (FROM paliad.deadline_rules → FROM paliad.deadline_rules_unified)
-- with no struct or scanner changes.
-- - When B.4 drops paliad.deadline_rules, this view stays — it
-- becomes the canonical legacy-shape reader for any code that
-- hasn't been migrated to direct sr/pe/ls reads.
--
-- Column mapping (per design §4.2):
-- - id, proceeding_type_id, parent_id, primary_party, duration_*,
-- timing, sequence_order, is_spawn/court_set/bilateral, priority,
-- rule_code, rule_codes, deadline_notes(_en), condition_expr,
-- choices_offered, applies_to_target, trigger_event_id,
-- spawn_proceeding_type_id, anchor_alt, alt_duration_*,
-- alt_rule_code, combine_op, lifecycle_state, draft_of,
-- published_at, is_active, created_at, updated_at, spawn_label
-- → from paliad.sequencing_rules
-- - submission_code → procedural_events.code
-- - name, name_en, description→ procedural_events
-- - event_type → procedural_events.event_kind (renamed)
-- - concept_id → procedural_events
-- - legal_source → legal_sources.citation (via legal_source_id FK)
--
-- The view is READ-ONLY by default. Writes still go to the underlying
-- tables — RuleEditorService is refactored in the same slice to write
-- directly to sr/pe/ls. paliad.deadline_rules is FROZEN from B.3 onward
-- (no new writes); the dual-write helper from B.2 is decommissioned.
-- The CHECK constraint on sequencing_rules.primary_party doesn't exist
-- yet (mig 135 only constrained deadline_rules.primary_party). The view
-- inherits whatever value sr.primary_party carries; mig 136's backfill
-- set sr.primary_party = dr.primary_party so the canonical four-value
-- vocab is already in place. A later slice can add the same CHECK to
-- sequencing_rules itself.
CREATE OR REPLACE VIEW paliad.deadline_rules_unified AS
SELECT
sr.id,
sr.proceeding_type_id,
sr.parent_id,
pe.code AS submission_code,
pe.name,
pe.name_en,
pe.description,
sr.primary_party,
pe.event_kind AS event_type,
sr.duration_value,
sr.duration_unit,
sr.timing,
sr.alt_duration_value,
sr.alt_duration_unit,
sr.alt_rule_code,
sr.anchor_alt,
sr.combine_op,
sr.rule_code,
sr.deadline_notes,
sr.deadline_notes_en,
sr.sequence_order,
sr.is_spawn,
sr.spawn_label,
sr.spawn_proceeding_type_id,
sr.is_bilateral,
sr.is_court_set,
sr.priority,
sr.condition_expr,
pe.concept_id,
ls.citation AS legal_source,
sr.trigger_event_id,
sr.rule_codes,
sr.choices_offered,
sr.applies_to_target,
sr.lifecycle_state,
sr.draft_of,
sr.published_at,
sr.is_active,
sr.created_at,
sr.updated_at
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;
COMMENT ON VIEW paliad.deadline_rules_unified IS
'Slice B.3 (mig 139, t-paliad-305): legacy-shape projection over '
'sequencing_rules + procedural_events + legal_sources. Read-only — '
'writes go directly to the three underlying tables via '
'RuleEditorService. Survives B.4 destructive drop of '
'paliad.deadline_rules; the view will then be the only '
'legacy-shape reader.';
-- Post-apply integrity check: confirm the view's row count matches the
-- live sequencing_rules row count. A mismatch would indicate either a
-- mid-deploy race (rare) or a JOIN issue (the LEFT JOIN to legal_sources
-- never drops rows, the INNER JOIN to procedural_events drops sr rows
-- whose procedural_event_id is NULL — but that column is NOT NULL on
-- the table so it can't happen). Belt-and-braces.
DO $$
DECLARE
v_view_count int;
v_sr_count int;
BEGIN
SELECT COUNT(*) INTO v_view_count FROM paliad.deadline_rules_unified;
SELECT COUNT(*) INTO v_sr_count FROM paliad.sequencing_rules;
IF v_view_count <> v_sr_count THEN
RAISE EXCEPTION '[mig 139] FAILED POST: view row count % does not match sequencing_rules row count %. '
'Possible cause: a sequencing_rules row references a procedural_event_id that does not exist (NOT NULL FK should prevent this).',
v_view_count, v_sr_count;
END IF;
RAISE NOTICE '[mig 139] view OK — deadline_rules_unified rows = % (= sequencing_rules)',
v_view_count;
END $$;

View File

@@ -0,0 +1,47 @@
-- 140_drop_deadline_rules (down) — Slice B.4, t-paliad-305
--
-- Best-effort recovery from the deadline_rules_pre_140 snapshot. The
-- original triggers (mig 079 audit), indexes, CHECK constraints (mig
-- 135 primary_party), and FK constraints on the new tables are NOT
-- recreated here — restoring the working state requires replaying
-- migrations 078/079/091/095/098/122/128/134/135 against the restored
-- table.
--
-- Use this only for catastrophic recovery. The normal revert path
-- for B.4 is to re-deploy the previous container image (which still
-- writes via the dual-write helper to a paliad.deadline_rules that no
-- longer exists) — that would crash on first write, so true revert
-- requires this down + a code revert + a snapshot restore.
-- Drop the INSTEAD OF triggers + functions
DROP TRIGGER IF EXISTS deadline_rules_unified_insert ON paliad.deadline_rules_unified;
DROP TRIGGER IF EXISTS deadline_rules_unified_update ON paliad.deadline_rules_unified;
DROP FUNCTION IF EXISTS paliad.deadline_rules_unified_insert_trigger();
DROP FUNCTION IF EXISTS paliad.deadline_rules_unified_update_trigger();
-- Recreate paliad.deadline_rules from snapshot.
CREATE TABLE paliad.deadline_rules AS TABLE paliad.deadline_rules_pre_140;
-- Re-add the PK constraint (CREATE TABLE AS doesn't carry constraints).
ALTER TABLE paliad.deadline_rules ADD PRIMARY KEY (id);
-- Re-point the FKs back to deadline_rules.
ALTER TABLE paliad.appointments
DROP CONSTRAINT IF EXISTS appointments_deadline_rule_id_fkey;
ALTER TABLE paliad.appointments
ADD CONSTRAINT appointments_deadline_rule_id_fkey
FOREIGN KEY (deadline_rule_id) REFERENCES paliad.deadline_rules(id);
ALTER TABLE paliad.deadline_rule_backfill_orphans
DROP CONSTRAINT IF EXISTS deadline_rule_backfill_orphans_resolved_rule_id_fkey;
ALTER TABLE paliad.deadline_rule_backfill_orphans
ADD CONSTRAINT deadline_rule_backfill_orphans_resolved_rule_id_fkey
FOREIGN KEY (resolved_rule_id) REFERENCES paliad.deadline_rules(id);
-- Re-add deadlines.rule_id from the snapshot's data (via sequencing_rule_id
-- which inherited deadline_rules.id during mig 136).
ALTER TABLE paliad.deadlines ADD COLUMN rule_id uuid;
UPDATE paliad.deadlines SET rule_id = sequencing_rule_id WHERE sequencing_rule_id IS NOT NULL;
ALTER TABLE paliad.deadlines
ADD CONSTRAINT fristen_rule_id_fkey
FOREIGN KEY (rule_id) REFERENCES paliad.deadline_rules(id);

View File

@@ -0,0 +1,448 @@
-- 140_drop_deadline_rules — Slice B.4 destructive drop (t-paliad-305 / m/paliad#93)
--
-- HARD STOPS:
-- * Audit-first: snapshot paliad.deadline_rules → paliad.deadline_rules_pre_140
-- in the SAME TRANSACTION as the DROP, per m's snapshot policy
-- (precedent migs 091/093/095/098). The whole .up.sql runs inside a
-- single transaction because the migration runner wraps it; if any
-- statement fails, the snapshot CREATE TABLE rolls back with the
-- destructive DROP.
-- * No data loss: paliad.deadline_rules has been a write-side shadow
-- since B.3 (B.2 dual-write keeps sequencing_rules + procedural_events
-- + legal_sources current). Drift verified clean before this slice
-- (deadline_rules=231, sequencing_rules=231, 0 mismatches across
-- counts/FKs/lifecycle/is_active).
--
-- What this migration does:
-- 1. Snapshot deadline_rules → deadline_rules_pre_140 (preserves audit
-- trail of the table's final state for forensic + revert paths).
-- 2. Final reconciliation: catch any deadlines whose
-- sequencing_rule_id/procedural_event_id columns drifted from the
-- legacy rule_id (no live drift today — defensive).
-- 3. Drop the audit trigger on deadline_rules (it can't fire on a
-- gone table; the trigger function itself stays for the historical
-- paliad.deadline_rule_audit reads).
-- 4. Re-point FKs that currently target deadline_rules.id over to
-- sequencing_rules.id. The id values are identical (sequencing_rules
-- inherited deadline_rules.id during mig 136 backfill), so no data
-- migration is needed — just the constraint swap. Affects:
-- - paliad.appointments.deadline_rule_id
-- - paliad.deadline_rule_backfill_orphans.resolved_rule_id
-- 5. Drop paliad.deadlines.rule_id column. Per design §5.4 step 16:
-- "DROP COLUMN paliad.deadlines.rule_id (keep rule_code +
-- custom_rule_text as the human-readable denormalized columns —
-- they're the safety net for orphaned deadlines per t-paliad-258)."
-- The new sequencing_rule_id + procedural_event_id columns from
-- mig 136 are the FK back-links from B.4 forward.
-- 6. DROP TABLE paliad.deadline_rules.
-- 7. INSTEAD OF triggers on paliad.deadline_rules_unified that route
-- INSERTs/UPDATEs to the underlying sr+pe+ls tables. Lets the
-- RuleEditorService keep its existing SQL shape (one INSERT, one
-- UPDATE per write method) with only a table-name swap. The
-- triggers project the legacy column shape back to the three new
-- tables exactly as the dual-write helper did in B.2.
--
-- Down: best-effort restore from the snapshot. The original triggers,
-- indexes, and FKs are NOT recreated — operator must replay historical
-- migrations 078/079/091/095/098/122 to bring the table back to a
-- working shape. The down path is for catastrophic recovery, not casual
-- revert.
-- ---------------------------------------------------------------
-- 1. Snapshot — must precede the destructive ops (same TX).
-- ---------------------------------------------------------------
CREATE TABLE paliad.deadline_rules_pre_140 AS TABLE paliad.deadline_rules;
COMMENT ON TABLE paliad.deadline_rules_pre_140 IS
'Snapshot of paliad.deadline_rules taken in mig 140 (Slice B.4, '
't-paliad-305) before the destructive DROP. Mirrors precedent '
'pre_091/093/095/098. Read-only forensic + revert source.';
-- ---------------------------------------------------------------
-- 2. Final reconciliation — should be a no-op (drift was 0 going
-- into this slice). Belt-and-braces against a write that snuck
-- in between drift-check and this migration.
-- ---------------------------------------------------------------
UPDATE paliad.deadlines d
SET sequencing_rule_id = d.rule_id,
procedural_event_id = sr.procedural_event_id
FROM paliad.sequencing_rules sr
WHERE sr.id = d.rule_id
AND d.rule_id IS NOT NULL
AND (d.sequencing_rule_id IS DISTINCT FROM d.rule_id
OR d.procedural_event_id IS DISTINCT FROM sr.procedural_event_id);
-- ---------------------------------------------------------------
-- 3. Drop the deadline_rules audit trigger. The trigger function
-- (paliad.deadline_rule_audit_trigger) stays defined for any
-- historical references; mig 079 created it.
-- ---------------------------------------------------------------
DROP TRIGGER IF EXISTS deadline_rules_audit_aiud ON paliad.deadline_rules;
-- ---------------------------------------------------------------
-- 4. Re-point FKs from deadline_rules → sequencing_rules.
-- ---------------------------------------------------------------
ALTER TABLE paliad.appointments
DROP CONSTRAINT IF EXISTS appointments_deadline_rule_id_fkey;
ALTER TABLE paliad.appointments
ADD CONSTRAINT appointments_deadline_rule_id_fkey
FOREIGN KEY (deadline_rule_id) REFERENCES paliad.sequencing_rules(id);
ALTER TABLE paliad.deadline_rule_backfill_orphans
DROP CONSTRAINT IF EXISTS deadline_rule_backfill_orphans_resolved_rule_id_fkey;
ALTER TABLE paliad.deadline_rule_backfill_orphans
ADD CONSTRAINT deadline_rule_backfill_orphans_resolved_rule_id_fkey
FOREIGN KEY (resolved_rule_id) REFERENCES paliad.sequencing_rules(id);
-- Drop the deadlines→deadline_rules FK before we drop the column.
ALTER TABLE paliad.deadlines
DROP CONSTRAINT IF EXISTS fristen_rule_id_fkey;
-- ---------------------------------------------------------------
-- 5. Drop paliad.deadlines.rule_id (column + remaining indexes).
-- ---------------------------------------------------------------
ALTER TABLE paliad.deadlines
DROP COLUMN IF EXISTS rule_id;
-- ---------------------------------------------------------------
-- 6a. Drop the deadline_search materialized view, which has a
-- direct dependency on paliad.deadline_rules (mig 077). We
-- recreate it after the DROP, re-pointed at deadline_rules_unified
-- so reads keep working. All 11 indexes are recreated alongside.
-- ---------------------------------------------------------------
DROP MATERIALIZED VIEW IF EXISTS paliad.deadline_search;
-- ---------------------------------------------------------------
-- 6. DROP TABLE paliad.deadline_rules. Now that:
-- - dependent FKs are re-pointed to sequencing_rules,
-- - the audit trigger is dropped,
-- - deadlines.rule_id is gone,
-- - the deadline_search matview is gone,
-- nothing references the table anymore. The self-FKs
-- (deadline_rules.parent_id, .draft_of) drop with the table.
-- ---------------------------------------------------------------
DROP TABLE paliad.deadline_rules;
-- ---------------------------------------------------------------
-- 7. INSTEAD OF triggers on the view — routes writes to sr+pe+ls.
-- ---------------------------------------------------------------
CREATE OR REPLACE FUNCTION paliad.deadline_rules_unified_insert_trigger()
RETURNS TRIGGER LANGUAGE plpgsql AS $fn$
DECLARE
v_legal_source_id uuid;
v_pe_id uuid;
v_code text;
BEGIN
-- legal_sources upsert (no-op if NEW.legal_source is NULL)
IF NEW.legal_source IS NOT NULL THEN
INSERT INTO paliad.legal_sources (citation, jurisdiction)
VALUES (NEW.legal_source,
COALESCE(NULLIF(split_part(NEW.legal_source, '.', 1), ''), 'other'))
ON CONFLICT (citation) DO NOTHING;
SELECT id INTO v_legal_source_id
FROM paliad.legal_sources
WHERE citation = NEW.legal_source;
END IF;
-- Mint synthetic code when submission_code is NULL — same recipe
-- as mig 136 + B.2 dual-write helper. Stays byte-identical.
v_code := COALESCE(NEW.submission_code,
'null.' || substring(replace(NEW.id::text, '-', ''), 1, 8));
-- procedural_events upsert. ON CONFLICT (code) deliberately leaves
-- lifecycle_state / published_at / is_active alone — those track
-- the procedural-event concept's own lifecycle, not the inserting
-- sequencing-rule's lifecycle (e.g. a CloneAsDraft of a published
-- rule creates a draft sr that shares the published PE; the PE
-- should stay 'published'). Identity columns DO update so an
-- admin editing a draft's name still flips the lawyer-visible
-- label (1:1 today; revisit when 1:N becomes a real pattern).
INSERT INTO paliad.procedural_events
(code, name, name_en, description, event_kind, primary_party_default,
legal_source_id, concept_id, lifecycle_state, published_at, is_active)
VALUES
(v_code, NEW.name, NEW.name_en, NEW.description, NEW.event_type,
NEW.primary_party, v_legal_source_id, NEW.concept_id,
COALESCE(NEW.lifecycle_state, 'draft'), NEW.published_at,
COALESCE(NEW.is_active, true))
ON CONFLICT (code) DO UPDATE SET
name = EXCLUDED.name,
name_en = EXCLUDED.name_en,
description = EXCLUDED.description,
event_kind = EXCLUDED.event_kind,
primary_party_default = EXCLUDED.primary_party_default,
legal_source_id = EXCLUDED.legal_source_id,
concept_id = EXCLUDED.concept_id,
-- lifecycle_state / published_at / is_active deliberately omitted
updated_at = now()
RETURNING id INTO v_pe_id;
-- sequencing_rules insert. id is the caller-supplied NEW.id so
-- existing FK back-links (deadlines.sequencing_rule_id) resolve.
INSERT INTO paliad.sequencing_rules
(id, procedural_event_id, proceeding_type_id, parent_id, trigger_event_id,
duration_value, duration_unit, timing,
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
combine_op, condition_expr, primary_party, sequence_order,
is_spawn, spawn_label, spawn_proceeding_type_id,
is_bilateral, is_court_set, priority,
rule_code, rule_codes, deadline_notes, deadline_notes_en,
choices_offered, applies_to_target,
lifecycle_state, draft_of, published_at, is_active,
created_at, updated_at)
VALUES
(NEW.id, v_pe_id, NEW.proceeding_type_id, NEW.parent_id, NEW.trigger_event_id,
COALESCE(NEW.duration_value, 0), COALESCE(NEW.duration_unit, 'months'),
COALESCE(NEW.timing, 'after'),
NEW.alt_duration_value, NEW.alt_duration_unit, NEW.alt_rule_code, NEW.anchor_alt,
NEW.combine_op, NEW.condition_expr, NEW.primary_party,
COALESCE(NEW.sequence_order, 0),
COALESCE(NEW.is_spawn, false), NEW.spawn_label, NEW.spawn_proceeding_type_id,
COALESCE(NEW.is_bilateral, false), COALESCE(NEW.is_court_set, false),
COALESCE(NEW.priority, 'mandatory'),
NEW.rule_code, NEW.rule_codes, NEW.deadline_notes, NEW.deadline_notes_en,
NEW.choices_offered, NEW.applies_to_target,
COALESCE(NEW.lifecycle_state, 'draft'), NEW.draft_of,
NEW.published_at, COALESCE(NEW.is_active, true),
COALESCE(NEW.created_at, now()), COALESCE(NEW.updated_at, now()));
RETURN NEW;
END $fn$;
CREATE TRIGGER deadline_rules_unified_insert
INSTEAD OF INSERT ON paliad.deadline_rules_unified
FOR EACH ROW EXECUTE FUNCTION paliad.deadline_rules_unified_insert_trigger();
CREATE OR REPLACE FUNCTION paliad.deadline_rules_unified_update_trigger()
RETURNS TRIGGER LANGUAGE plpgsql AS $fn$
DECLARE
v_legal_source_id uuid;
v_code text;
BEGIN
-- legal_sources upsert (only if NEW.legal_source is non-NULL).
-- A change FROM non-NULL TO NULL clears legal_source_id on the
-- procedural_event below — same shape as mig 136 / B.2 behaviour.
IF NEW.legal_source IS NOT NULL THEN
INSERT INTO paliad.legal_sources (citation, jurisdiction)
VALUES (NEW.legal_source,
COALESCE(NULLIF(split_part(NEW.legal_source, '.', 1), ''), 'other'))
ON CONFLICT (citation) DO NOTHING;
SELECT id INTO v_legal_source_id
FROM paliad.legal_sources
WHERE citation = NEW.legal_source;
END IF;
v_code := COALESCE(NEW.submission_code,
'null.' || substring(replace(NEW.id::text, '-', ''), 1, 8));
-- Update procedural_events keyed by the existing PE link on
-- sequencing_rules. lifecycle_state / published_at / is_active on
-- PE are NOT mirrored from the per-sequencing-rule UPDATE — see
-- the INSERT trigger comment for the rationale (a draft sr that
-- shares its PE with a published peer must not flip the PE to
-- draft). Identity columns DO mirror so editing name/code from
-- the admin UI continues to reach the lawyer-visible label.
UPDATE paliad.procedural_events
SET code = v_code,
name = NEW.name,
name_en = NEW.name_en,
description = NEW.description,
event_kind = NEW.event_type,
primary_party_default = NEW.primary_party,
legal_source_id = v_legal_source_id,
concept_id = NEW.concept_id,
updated_at = now()
WHERE id = (SELECT procedural_event_id
FROM paliad.sequencing_rules
WHERE id = NEW.id);
-- Update sequencing_rules (1:1 by id).
UPDATE paliad.sequencing_rules
SET proceeding_type_id = NEW.proceeding_type_id,
parent_id = NEW.parent_id,
trigger_event_id = NEW.trigger_event_id,
duration_value = NEW.duration_value,
duration_unit = NEW.duration_unit,
timing = NEW.timing,
alt_duration_value = NEW.alt_duration_value,
alt_duration_unit = NEW.alt_duration_unit,
alt_rule_code = NEW.alt_rule_code,
anchor_alt = NEW.anchor_alt,
combine_op = NEW.combine_op,
condition_expr = NEW.condition_expr,
primary_party = NEW.primary_party,
sequence_order = NEW.sequence_order,
is_spawn = NEW.is_spawn,
spawn_label = NEW.spawn_label,
spawn_proceeding_type_id = NEW.spawn_proceeding_type_id,
is_bilateral = NEW.is_bilateral,
is_court_set = NEW.is_court_set,
priority = NEW.priority,
rule_code = NEW.rule_code,
rule_codes = NEW.rule_codes,
deadline_notes = NEW.deadline_notes,
deadline_notes_en = NEW.deadline_notes_en,
choices_offered = NEW.choices_offered,
applies_to_target = NEW.applies_to_target,
lifecycle_state = NEW.lifecycle_state,
draft_of = NEW.draft_of,
published_at = NEW.published_at,
is_active = NEW.is_active,
updated_at = now()
WHERE id = NEW.id;
RETURN NEW;
END $fn$;
CREATE TRIGGER deadline_rules_unified_update
INSTEAD OF UPDATE ON paliad.deadline_rules_unified
FOR EACH ROW EXECUTE FUNCTION paliad.deadline_rules_unified_update_trigger();
-- ---------------------------------------------------------------
-- 8. POST assertions.
-- ---------------------------------------------------------------
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
-- 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_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
FROM information_schema.tables
WHERE table_schema = 'paliad' AND table_name = 'deadline_rules';
IF v_dr_table_exists > 0 THEN
RAISE EXCEPTION '[mig 140] FAILED POST: paliad.deadline_rules table still exists after DROP';
END IF;
SELECT COUNT(*) INTO v_rule_id_col
FROM information_schema.columns
WHERE table_schema = 'paliad' AND table_name = 'deadlines' AND column_name = 'rule_id';
IF v_rule_id_col > 0 THEN
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, sequencing_rules=% rows, view (filtered)=% rows, INSTEAD OF triggers active',
v_snapshot_count, v_sr_count, v_view_count;
END $$;
-- ---------------------------------------------------------------
-- 8. Recreate paliad.deadline_search materialized view against
-- deadline_rules_unified (same column shape — sr.id is the new
-- dr.id, etc.). Definition mirrors mig 077; only the FROM table
-- name changes. All 11 indexes restored.
-- ---------------------------------------------------------------
CREATE MATERIALIZED VIEW paliad.deadline_search AS
SELECT 'rule'::text AS kind,
('r:'::text || (dr.id)::text) AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
dr.id AS rule_id,
NULL::bigint AS trigger_event_id,
pt.code AS proceeding_code,
pt.name AS proceeding_name_de,
pt.name_en AS proceeding_name_en,
pt.jurisdiction,
pt.display_order AS proceeding_display_order,
dr.submission_code AS rule_local_code,
dr.name AS rule_name_de,
dr.name_en AS rule_name_en,
dr.legal_source,
dr.rule_code,
dr.duration_value,
dr.duration_unit,
dr.timing,
COALESCE(dr.primary_party, dc.party) AS effective_party
FROM paliad.deadline_rules_unified dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
JOIN paliad.deadline_concepts dc ON dc.id = dr.concept_id
WHERE dr.is_active AND pt.is_active AND pt.category = 'fristenrechner'::text
UNION ALL
SELECT 'trigger'::text AS kind,
('t:'::text || (te.id)::text) AS row_key,
dc.id AS concept_id,
dc.slug AS concept_slug,
dc.name_de AS concept_name_de,
dc.name_en AS concept_name_en,
dc.description AS concept_description,
dc.aliases AS concept_aliases,
dc.party AS concept_party,
dc.category AS concept_category,
dc.sort_order AS concept_sort_order,
NULL::uuid AS rule_id,
te.id AS trigger_event_id,
NULL::text AS proceeding_code,
NULL::text AS proceeding_name_de,
NULL::text AS proceeding_name_en,
'cross-cutting'::text AS jurisdiction,
9999 AS proceeding_display_order,
te.code AS rule_local_code,
te.name_de AS rule_name_de,
te.name AS rule_name_en,
dr_trig.legal_source,
NULL::text AS rule_code,
NULL::integer AS duration_value,
NULL::text AS duration_unit,
NULL::text AS timing,
dc.party AS effective_party
FROM paliad.trigger_events te
JOIN paliad.deadline_concepts dc ON dc.slug = te.concept_id
LEFT JOIN paliad.deadline_rules_unified dr_trig
ON dr_trig.trigger_event_id = te.id
AND dr_trig.proceeding_type_id IS NULL
AND dr_trig.is_active
AND dr_trig.lifecycle_state = 'published'::text
WHERE te.is_active
WITH NO DATA;
CREATE UNIQUE INDEX deadline_search_row_key ON paliad.deadline_search (row_key);
CREATE INDEX deadline_search_concept_id ON paliad.deadline_search (concept_id);
CREATE INDEX deadline_search_proc_code ON paliad.deadline_search (proceeding_code);
CREATE INDEX deadline_search_legal_source ON paliad.deadline_search (legal_source);
CREATE INDEX deadline_search_effective_party ON paliad.deadline_search (effective_party);
CREATE INDEX deadline_search_legal_source_trgm ON paliad.deadline_search USING gin (legal_source gin_trgm_ops);
CREATE INDEX deadline_search_concept_de_trgm ON paliad.deadline_search USING gin (concept_name_de gin_trgm_ops);
CREATE INDEX deadline_search_concept_en_trgm ON paliad.deadline_search USING gin (concept_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_de_trgm ON paliad.deadline_search USING gin (rule_name_de gin_trgm_ops);
CREATE INDEX deadline_search_rule_en_trgm ON paliad.deadline_search USING gin (rule_name_en gin_trgm_ops);
CREATE INDEX deadline_search_rule_code_trgm ON paliad.deadline_search USING gin (rule_code gin_trgm_ops);
REFRESH MATERIALIZED VIEW paliad.deadline_search;

View File

@@ -0,0 +1,3 @@
-- t-paliad-313: revert submission_bases catalog.
DROP TABLE IF EXISTS paliad.submission_bases;

View File

@@ -0,0 +1,173 @@
-- t-paliad-313 (m/paliad#141): Composer Slice A — submission base catalog.
--
-- paliad.submission_bases is a thin pointer table — each row maps a
-- short, stable slug ("hlc-letterhead", "neutral", …) onto a Gitea path
-- that holds the actual .docx body, plus a JSON section-spec describing
-- the base's default section set, stylemap, and per-section seed
-- Markdown. The .docx in Gitea stays the source of truth for the
-- chrome, fonts, paragraph styles, and (in later slices) the
-- {{#section:KEY}} anchors. The DB row carries the listable metadata
-- the picker needs.
--
-- Visibility: every authenticated user SELECTs (the catalog is shared
-- firm-wide). Mutations are admin-only and enforced in Go at the
-- handler layer — RLS only gates reads.
--
-- Slice A seeds two rows:
-- 1. hlc-letterhead — points at the existing HLC firm skeleton
-- (_firm-skeleton.docx with HL Patents Style typography).
-- 2. neutral — points at the universal _skeleton.docx.
-- Specialist bases (lg-duesseldorf, upc-formal) land in Slice E with
-- their own .docx authoring task.
CREATE TABLE IF NOT EXISTS paliad.submission_bases (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL UNIQUE,
firm text,
proceeding_family text,
label_de text NOT NULL,
label_en text NOT NULL,
description_de text,
description_en text,
gitea_path text NOT NULL,
section_spec jsonb NOT NULL,
is_default_for text[] NOT NULL DEFAULT '{}'::text[],
is_active bool NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS submission_bases_firm_family_idx
ON paliad.submission_bases (firm, proceeding_family) WHERE is_active;
ALTER TABLE paliad.submission_bases ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS submission_bases_select ON paliad.submission_bases;
CREATE POLICY submission_bases_select
ON paliad.submission_bases FOR SELECT TO authenticated
USING (true);
-- INSERT / UPDATE / DELETE intentionally absent — admin-only mutations
-- happen via the handler layer with explicit role checks. No RLS path
-- for mutations means RLS denies them by default.
DROP TRIGGER IF EXISTS submission_bases_set_updated_at ON paliad.submission_bases;
CREATE TRIGGER submission_bases_set_updated_at
BEFORE UPDATE ON paliad.submission_bases
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
COMMENT ON TABLE paliad.submission_bases IS
't-paliad-313: Composer base catalog. One row per base template (HLC letterhead, neutral, …) pointing at a .docx in Gitea + a JSON section spec.';
-- Seed: HLC letterhead + neutral skeleton. The section_spec carries the
-- 10 default sections (letterhead, caption, introduction, requests,
-- facts, legal_argument, evidence, exhibits, closing, signature) with
-- their kinds, default order, and bilingual labels. seed_md_de /
-- seed_md_en are populated for the bag-driven sections (letterhead,
-- caption, signature); the remaining sections seed empty.
--
-- exhibits.included=false by default (lawyer opts in when an attachment
-- list applies). Every other section ships included=true.
INSERT INTO paliad.submission_bases
(slug, firm, proceeding_family, label_de, label_en, description_de, description_en, gitea_path, section_spec, is_default_for)
VALUES
('hlc-letterhead', 'HLC', NULL,
'HLC-Briefkopf', 'HLC letterhead',
'Mit HL Patents Style — Firmen-Header, Schriftarten, Absatzformaten.',
'With HL Patents Style — firm header, fonts, paragraph styles.',
'6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx',
jsonb_build_object(
'version', 1,
'stylemap', jsonb_build_object(
'paragraph', 'HLpat-Body-B0',
'heading_1', 'HLpat-Heading-H1',
'heading_2', 'HLpat-Heading-H2',
'heading_3', 'HLpat-Heading-H3',
'list_bullet', 'HLpat-Body-B0',
'list_numbered', 'HLpat-Body-B0',
'blockquote', 'HLpat-Body-B1'
),
'defaults', jsonb_build_array(
jsonb_build_object('section_key','letterhead', 'kind','prose', 'order_index', 1, 'label_de','Briefkopf', 'label_en','Letterhead',
'included',true,
'seed_md_de', E'Schriftsatz von {{firm.name}}\n\n{{user.display_name}}, {{user.office}}',
'seed_md_en', E'Submission by {{firm.name}}\n\n{{user.display_name}}, {{user.office}}'),
jsonb_build_object('section_key','caption', 'kind','prose', 'order_index', 2, 'label_de','Rubrum', 'label_en','Caption',
'included',true,
'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\nvertreten durch {{parties.claimant.0.representative}}\n\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\nvertreten durch {{parties.defendant.0.representative}}\n\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n\n— Defendant —\n\nCase number: {{project.case_number}}\n{{project.court}}'),
jsonb_build_object('section_key','introduction', 'kind','prose', 'order_index', 3, 'label_de','Einleitung', 'label_en','Introduction',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','requests', 'kind','requests', 'order_index', 4, 'label_de','Anträge', 'label_en','Requests',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','facts', 'kind','prose', 'order_index', 5, 'label_de','Sachverhalt', 'label_en','Facts',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','legal_argument', 'kind','prose', 'order_index', 6, 'label_de','Rechtliche Würdigung', 'label_en','Legal argument',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','evidence', 'kind','evidence', 'order_index', 7, 'label_de','Beweisangebote', 'label_en','Evidence offering',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','exhibits', 'kind','prose', 'order_index', 8, 'label_de','Anlagen', 'label_en','Exhibits',
'included',false, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','closing', 'kind','prose', 'order_index', 9, 'label_de','Schlussformel', 'label_en','Closing',
'included',true,
'seed_md_de', E'Mit freundlichen Grüßen',
'seed_md_en', E'Yours sincerely,'),
jsonb_build_object('section_key','signature', 'kind','prose', 'order_index',10, 'label_de','Unterschrift', 'label_en','Signature',
'included',true,
'seed_md_de', E'{{user.display_name}}\n{{user.office}}',
'seed_md_en', E'{{user.display_name}}\n{{user.office}}')
)
),
'{}'::text[]
),
('neutral', NULL, NULL,
'Neutraler Schriftsatz', 'Neutral skeleton',
'Universelle Vorlage ohne firmenspezifisches Branding.',
'Universal template with no firm-specific branding.',
'6 - material/Templates/Word/Paliad/HLC/_skeleton.docx',
jsonb_build_object(
'version', 1,
'stylemap', jsonb_build_object(
'paragraph', 'Normal',
'heading_1', 'Heading 1',
'heading_2', 'Heading 2',
'heading_3', 'Heading 3',
'list_bullet', 'Normal',
'list_numbered', 'Normal',
'blockquote', 'Quote'
),
'defaults', jsonb_build_array(
jsonb_build_object('section_key','letterhead', 'kind','prose', 'order_index', 1, 'label_de','Briefkopf', 'label_en','Letterhead',
'included',true,
'seed_md_de', E'Schriftsatz von {{firm.name}}\n\n{{user.display_name}}',
'seed_md_en', E'Submission by {{firm.name}}\n\n{{user.display_name}}'),
jsonb_build_object('section_key','caption', 'kind','prose', 'order_index', 2, 'label_de','Rubrum', 'label_en','Caption',
'included',true,
'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}',
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\n— Defendant —\n\nCase number: {{project.case_number}}'),
jsonb_build_object('section_key','introduction', 'kind','prose', 'order_index', 3, 'label_de','Einleitung', 'label_en','Introduction',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','requests', 'kind','requests', 'order_index', 4, 'label_de','Anträge', 'label_en','Requests',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','facts', 'kind','prose', 'order_index', 5, 'label_de','Sachverhalt', 'label_en','Facts',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','legal_argument', 'kind','prose', 'order_index', 6, 'label_de','Rechtliche Würdigung', 'label_en','Legal argument',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','evidence', 'kind','evidence', 'order_index', 7, 'label_de','Beweisangebote', 'label_en','Evidence offering',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','exhibits', 'kind','prose', 'order_index', 8, 'label_de','Anlagen', 'label_en','Exhibits',
'included',false, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','closing', 'kind','prose', 'order_index', 9, 'label_de','Schlussformel', 'label_en','Closing',
'included',true,
'seed_md_de', E'Mit freundlichen Grüßen',
'seed_md_en', E'Yours sincerely,'),
jsonb_build_object('section_key','signature', 'kind','prose', 'order_index',10, 'label_de','Unterschrift', 'label_en','Signature',
'included',true,
'seed_md_de', E'{{user.display_name}}',
'seed_md_en', E'{{user.display_name}}')
)
),
'{}'::text[]
)
ON CONFLICT (slug) DO NOTHING;

View File

@@ -0,0 +1,5 @@
-- t-paliad-313: revert Composer columns on submission_drafts.
ALTER TABLE paliad.submission_drafts
DROP COLUMN IF EXISTS composer_meta,
DROP COLUMN IF EXISTS base_id;

View File

@@ -0,0 +1,31 @@
-- t-paliad-313 (m/paliad#141): Composer Slice A — point submission_drafts at a base.
--
-- Two purely-additive columns on paliad.submission_drafts:
--
-- base_id uuid — FK to paliad.submission_bases. NULL on existing
-- drafts (Slice A explicitly does NOT auto-upgrade pre-Composer
-- rows — that's Slice C). NEW drafts created post-Composer get
-- base_id seeded by SubmissionDraftService.Create from the firm
-- default for the proceeding family. ON DELETE SET NULL keeps a
-- draft renderable via the v1 fallback chain even if its base is
-- removed; the lawyer picks a new base via the sidebar.
--
-- composer_meta jsonb — Composer-specific metadata. For Slice A this
-- carries the seed-time section order so the editor paints without
-- a join. Future slices may add hidden_sections, active_locale,
-- etc.
--
-- No data backfill, no auto-upgrade — pre-Composer drafts keep base_id
-- NULL and render via the existing v1 path. The Go side has the
-- corresponding gate (base_id IS NULL OR no submission_sections rows →
-- v1 path).
ALTER TABLE paliad.submission_drafts
ADD COLUMN IF NOT EXISTS base_id uuid REFERENCES paliad.submission_bases(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS composer_meta jsonb NOT NULL DEFAULT '{}'::jsonb;
COMMENT ON COLUMN paliad.submission_drafts.base_id IS
't-paliad-313: Composer base reference. NULL = pre-Composer draft, renders via v1 fallback chain. ON DELETE SET NULL.';
COMMENT ON COLUMN paliad.submission_drafts.composer_meta IS
't-paliad-313: Composer-side metadata (section_order, hidden_sections, …). jsonb, default {}.';

View File

@@ -0,0 +1,3 @@
-- t-paliad-313: revert submission_sections table.
DROP TABLE IF EXISTS paliad.submission_sections;

View File

@@ -0,0 +1,116 @@
-- t-paliad-313 (m/paliad#141): Composer Slice A — per-draft section rows.
--
-- paliad.submission_sections holds one row per (draft, section_key) for
-- Composer-mode drafts. Slice A seeds rows on draft create from the
-- base's section_spec.defaults; the editor renders them read-only. Slice
-- B turns them editable, Slice F adds reorder/hide/add-custom.
--
-- kind values per the design (Q10 ratification — no *_auto kind):
-- 'prose' — free Markdown content (default).
-- 'requests' — Anträge-style content (editor may add auto-numbering
-- later; Slice A treats identical to 'prose').
-- 'evidence' — Beweisangebote (editor may prefix lines with
-- 'Beweis: '; Slice A treats identical to 'prose').
--
-- Visibility flows through draft_id → submission_drafts → can_see_project
-- + owner-scoped. RLS policies mirror the four-policy shape on
-- submission_drafts so seeding from the Go service stays inside the
-- same RLS envelope.
--
-- content_md_de + content_md_en both NOT NULL DEFAULT '' so neither
-- side blocks the bilingual-by-construction render path. Empty content
-- renders as the missing-content marker per the editor's contract.
--
-- Per the brief (head's instruction msg #2392) Slice A does NOT auto-
-- upgrade the 11 pre-Composer drafts — those remain base_id=NULL with
-- no section rows. The v1 fallback render path stays compiled in to
-- keep them working.
CREATE TABLE IF NOT EXISTS paliad.submission_sections (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
draft_id uuid NOT NULL REFERENCES paliad.submission_drafts(id) ON DELETE CASCADE,
section_key text NOT NULL,
order_index int NOT NULL,
kind text NOT NULL,
label_de text NOT NULL,
label_en text NOT NULL,
included bool NOT NULL DEFAULT true,
content_md_de text NOT NULL DEFAULT '',
content_md_en text NOT NULL DEFAULT '',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT submission_sections_kind_check
CHECK (kind IN ('prose', 'requests', 'evidence')),
CONSTRAINT submission_sections_unique_per_draft
UNIQUE (draft_id, section_key)
);
CREATE INDEX IF NOT EXISTS submission_sections_draft_idx
ON paliad.submission_sections (draft_id, order_index);
ALTER TABLE paliad.submission_sections ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS submission_sections_select ON paliad.submission_sections;
CREATE POLICY submission_sections_select
ON paliad.submission_sections FOR SELECT TO authenticated
USING (
EXISTS (
SELECT 1 FROM paliad.submission_drafts d
WHERE d.id = paliad.submission_sections.draft_id
AND d.user_id = auth.uid()
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
)
);
DROP POLICY IF EXISTS submission_sections_insert ON paliad.submission_sections;
CREATE POLICY submission_sections_insert
ON paliad.submission_sections FOR INSERT TO authenticated
WITH CHECK (
EXISTS (
SELECT 1 FROM paliad.submission_drafts d
WHERE d.id = paliad.submission_sections.draft_id
AND d.user_id = auth.uid()
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
)
);
DROP POLICY IF EXISTS submission_sections_update ON paliad.submission_sections;
CREATE POLICY submission_sections_update
ON paliad.submission_sections FOR UPDATE TO authenticated
USING (
EXISTS (
SELECT 1 FROM paliad.submission_drafts d
WHERE d.id = paliad.submission_sections.draft_id
AND d.user_id = auth.uid()
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
)
)
WITH CHECK (
EXISTS (
SELECT 1 FROM paliad.submission_drafts d
WHERE d.id = paliad.submission_sections.draft_id
AND d.user_id = auth.uid()
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
)
);
DROP POLICY IF EXISTS submission_sections_delete ON paliad.submission_sections;
CREATE POLICY submission_sections_delete
ON paliad.submission_sections FOR DELETE TO authenticated
USING (
EXISTS (
SELECT 1 FROM paliad.submission_drafts d
WHERE d.id = paliad.submission_sections.draft_id
AND d.user_id = auth.uid()
AND (d.project_id IS NULL OR paliad.can_see_project(d.project_id))
)
);
DROP TRIGGER IF EXISTS submission_sections_set_updated_at ON paliad.submission_sections;
CREATE TRIGGER submission_sections_set_updated_at
BEFORE UPDATE ON paliad.submission_sections
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
COMMENT ON TABLE paliad.submission_sections IS
't-paliad-313: per-draft Composer section rows. Slice A: seeded on draft create from base.section_spec.defaults, rendered read-only. Slice B: editable. RLS mirrors submission_drafts (owner-scoped + can_see_project).';

View File

@@ -0,0 +1,4 @@
-- t-paliad-315: revert building blocks library.
DROP TABLE IF EXISTS paliad.submission_building_block_admin_versions;
DROP TABLE IF EXISTS paliad.submission_building_blocks;

View File

@@ -0,0 +1,118 @@
-- t-paliad-315 (m/paliad#141): Composer Slice C — building blocks library.
--
-- Per the design at docs/design-submission-generator-v2-2026-05-26.md §4.4
-- and the Q2 / Q9 ratifications:
--
-- Q2 (m, 2026-05-26): building blocks are plain text paste sources.
-- No building_block_id reference is stored on submission_sections —
-- insertion is a one-way copy of content_md_<lang> into the section.
-- This table records the library; submission_sections doesn't know
-- where its content came from.
--
-- Q9 (m, 2026-05-26): four visibility tiers — private / team / firm
-- / global. Picker filtering and RLS SELECT predicate both honour
-- the tier. Tier upgrades (private → team/firm/global) go through
-- admin moderation in later slices; Slice C starts with admin-only
-- mutations (no user-initiated rows yet).
--
-- The _admin_versions companion table mirrors the email-templates
-- retention=20 audit history. It is INTERNAL to the admin editor —
-- not referenced from submission_sections, not exposed to the lawyer.
-- It exists so accidental delete + accidental overwrite are
-- recoverable.
CREATE TABLE IF NOT EXISTS paliad.submission_building_blocks (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
slug text NOT NULL,
firm text, -- e.g. 'HLC', NULL = cross-firm
section_key text NOT NULL, -- which section kind this block fits
proceeding_family text, -- 'de.inf.lg', NULL = any family
title_de text NOT NULL,
title_en text NOT NULL,
description_de text,
description_en text,
content_md_de text NOT NULL DEFAULT '',
content_md_en text NOT NULL DEFAULT '',
author_id uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
visibility text NOT NULL, -- 'private' | 'team' | 'firm' | 'global'
is_published bool NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted_at timestamptz,
CONSTRAINT submission_building_blocks_visibility_check
CHECK (visibility IN ('private', 'team', 'firm', 'global')),
CONSTRAINT submission_building_blocks_unique_slug_per_firm
UNIQUE (slug, firm)
);
CREATE INDEX IF NOT EXISTS submission_building_blocks_section_visibility_idx
ON paliad.submission_building_blocks (section_key, visibility, firm, proceeding_family)
WHERE deleted_at IS NULL AND is_published;
CREATE INDEX IF NOT EXISTS submission_building_blocks_author_idx
ON paliad.submission_building_blocks (author_id)
WHERE deleted_at IS NULL;
ALTER TABLE paliad.submission_building_blocks ENABLE ROW LEVEL SECURITY;
-- SELECT policy: coarse-grained RLS that admits every non-deleted
-- block to any authenticated user. The Go-side BuildingBlockService
-- applies the fine-grained tier predicate (private / team / firm /
-- global) using branding.Name + team-membership joins. This split
-- keeps the SQL simple and lets the tier semantics evolve in code
-- without RLS migrations.
--
-- The exception below is 'private': only the author sees their own
-- private rows. That's the hard line where a tier upgrade is
-- substantive enough to warrant DB-level enforcement.
DROP POLICY IF EXISTS submission_building_blocks_select ON paliad.submission_building_blocks;
CREATE POLICY submission_building_blocks_select
ON paliad.submission_building_blocks FOR SELECT TO authenticated
USING (
deleted_at IS NULL
AND (
visibility <> 'private'
OR author_id = auth.uid()
)
);
-- INSERT / UPDATE / DELETE intentionally absent — admin mutations
-- happen at the Go handler layer with explicit adminGate. RLS without
-- mutation policies denies them by default.
DROP TRIGGER IF EXISTS submission_building_blocks_set_updated_at ON paliad.submission_building_blocks;
CREATE TRIGGER submission_building_blocks_set_updated_at
BEFORE UPDATE ON paliad.submission_building_blocks
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
COMMENT ON TABLE paliad.submission_building_blocks IS
't-paliad-315: Composer building-block library. Plain text paste sources for section content (no lineage tracked on sections per Q2 ratification). 4-tier visibility per Q9.';
-- _admin_versions: append-only history per block. Admin-side only;
-- not referenced from submission_sections. Retention 20 per block,
-- GCed in the same transaction as the Save (mirrors email-templates).
CREATE TABLE IF NOT EXISTS paliad.submission_building_block_admin_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
building_block_id uuid NOT NULL REFERENCES paliad.submission_building_blocks(id) ON DELETE CASCADE,
content_md_de text NOT NULL,
content_md_en text NOT NULL,
title_de text NOT NULL,
title_en text NOT NULL,
edited_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
note text,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS submission_building_block_admin_versions_block_idx
ON paliad.submission_building_block_admin_versions (building_block_id, created_at DESC);
ALTER TABLE paliad.submission_building_block_admin_versions ENABLE ROW LEVEL SECURITY;
-- Admin-only audit; the handler layer gates this via adminGate and
-- writes via SECURITY DEFINER paths or admin-role SQL. No RLS SELECT
-- policy exists, so non-admin users get an empty result set.
COMMENT ON TABLE paliad.submission_building_block_admin_versions IS
't-paliad-315: append-only history per building block. Admin-side only; retention 20 rows per block, GCed at Save time.';

View File

@@ -0,0 +1,3 @@
-- t-paliad-317: revert specialist base seed rows.
DELETE FROM paliad.submission_bases WHERE slug IN ('lg-duesseldorf', 'upc-formal');

View File

@@ -0,0 +1,128 @@
-- t-paliad-317 (m/paliad#141): Composer Slice E — specialist bases.
--
-- Two firm-agnostic bases for proceeding-family-specific styling:
--
-- lg-duesseldorf — DE LG (de.inf.lg) conservative German legal style.
-- Times New Roman 11pt; black headings.
-- upc-formal — UPC court of first instance (upc.inf.cfi) formal
-- style. Calibri 11pt body; UPC-blue (1F3864) headings;
-- Cambria italic for blockquotes.
--
-- The .docx body for each is a minimal Composer-mode skeleton with
-- the 10 default section anchors and an empty rels envelope. The
-- styles.xml declares the {prefix}-Body / -Heading1/2/3 / -ListBullet
-- / -ListNumber / -Quote paragraph styles + a "Hyperlink" character
-- style (matches the MD walker's emitted r:id="rIdComposerN" link
-- runs from Slice D).
--
-- Generator: scripts/gen-submission-base/main.go (each preset hard-
-- codes the typography). The .docx files are uploaded to Gitea at
-- 6 - material/Templates/Word/Paliad/Composer/{slug}.docx as mAi.
--
-- The mig is additive only: ON CONFLICT (slug) DO NOTHING keeps a
-- re-run safe and existing rows untouched.
INSERT INTO paliad.submission_bases
(slug, firm, proceeding_family, label_de, label_en,
description_de, description_en,
gitea_path, section_spec, is_default_for)
VALUES
('lg-duesseldorf', NULL, 'de.inf.lg',
'LG-Düsseldorf-Stil', 'LG-Düsseldorf style',
'Konservativer DE-LG-Stil: Times New Roman 11pt, schlichte Überschriften.',
'Conservative DE LG style: Times New Roman 11pt, plain headings.',
'6 - material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx',
jsonb_build_object(
'version', 1,
'stylemap', jsonb_build_object(
'paragraph', 'LG-Body',
'heading_1', 'LG-Heading1',
'heading_2', 'LG-Heading2',
'heading_3', 'LG-Heading3',
'list_bullet', 'LG-ListBullet',
'list_numbered', 'LG-ListNumber',
'blockquote', 'LG-Quote'
),
'defaults', jsonb_build_array(
jsonb_build_object('section_key','letterhead', 'kind','prose', 'order_index', 1, 'label_de','Briefkopf', 'label_en','Letterhead',
'included',true,
'seed_md_de', E'Schriftsatz von {{firm.name}}\n\n{{user.display_name}}',
'seed_md_en', E'Submission by {{firm.name}}\n\n{{user.display_name}}'),
jsonb_build_object('section_key','caption', 'kind','prose', 'order_index', 2, 'label_de','Rubrum', 'label_en','Caption',
'included',true,
'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\n— Defendant —\n\nCase number: {{project.case_number}}\n{{project.court}}'),
jsonb_build_object('section_key','introduction', 'kind','prose', 'order_index', 3, 'label_de','Einleitung', 'label_en','Introduction',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','requests', 'kind','requests', 'order_index', 4, 'label_de','Anträge', 'label_en','Requests',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','facts', 'kind','prose', 'order_index', 5, 'label_de','Sachverhalt', 'label_en','Facts',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','legal_argument', 'kind','prose', 'order_index', 6, 'label_de','Rechtliche Würdigung', 'label_en','Legal argument',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','evidence', 'kind','evidence', 'order_index', 7, 'label_de','Beweisangebote', 'label_en','Evidence offering',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','exhibits', 'kind','prose', 'order_index', 8, 'label_de','Anlagen', 'label_en','Exhibits',
'included',false, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','closing', 'kind','prose', 'order_index', 9, 'label_de','Schlussformel', 'label_en','Closing',
'included',true,
'seed_md_de', E'Mit freundlichen Grüßen',
'seed_md_en', E'Yours sincerely,'),
jsonb_build_object('section_key','signature', 'kind','prose', 'order_index',10, 'label_de','Unterschrift', 'label_en','Signature',
'included',true,
'seed_md_de', E'{{user.display_name}}',
'seed_md_en', E'{{user.display_name}}')
)
),
'{}'::text[]
),
('upc-formal', NULL, 'upc.inf.cfi',
'UPC-Verfahren', 'UPC formal',
'UPC-Verfahrensstil: Calibri 11pt, UPC-blaue Überschriften, Cambria-Zitate.',
'UPC court style: Calibri 11pt, UPC-blue headings, Cambria quotes.',
'6 - material/Templates/Word/Paliad/Composer/upc-formal.docx',
jsonb_build_object(
'version', 1,
'stylemap', jsonb_build_object(
'paragraph', 'UPC-Body',
'heading_1', 'UPC-Heading1',
'heading_2', 'UPC-Heading2',
'heading_3', 'UPC-Heading3',
'list_bullet', 'UPC-ListBullet',
'list_numbered', 'UPC-ListNumber',
'blockquote', 'UPC-Quote'
),
'defaults', jsonb_build_array(
jsonb_build_object('section_key','letterhead', 'kind','prose', 'order_index', 1, 'label_de','Briefkopf', 'label_en','Letterhead',
'included',true,
'seed_md_de', E'Schriftsatz von {{firm.name}}\n\n{{user.display_name}}',
'seed_md_en', E'Submission by {{firm.name}}\n\n{{user.display_name}}'),
jsonb_build_object('section_key','caption', 'kind','prose', 'order_index', 2, 'label_de','Rubrum', 'label_en','Caption',
'included',true,
'seed_md_de', E'# In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n— Defendant —\n\nUPC-Aktenzeichen: {{project.case_number}}\nStreitpatent: {{project.patent_number_upc}}',
'seed_md_en', E'# In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n— Defendant —\n\nUPC case number: {{project.case_number}}\nPatent in suit: {{project.patent_number_upc}}'),
jsonb_build_object('section_key','introduction', 'kind','prose', 'order_index', 3, 'label_de','Einleitung', 'label_en','Introduction',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','requests', 'kind','requests', 'order_index', 4, 'label_de','Anträge', 'label_en','Requests',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','facts', 'kind','prose', 'order_index', 5, 'label_de','Sachverhalt', 'label_en','Facts',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','legal_argument', 'kind','prose', 'order_index', 6, 'label_de','Rechtliche Würdigung', 'label_en','Legal argument',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','evidence', 'kind','evidence', 'order_index', 7, 'label_de','Beweisangebote', 'label_en','Evidence offering',
'included',true, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','exhibits', 'kind','prose', 'order_index', 8, 'label_de','Anlagen', 'label_en','Exhibits',
'included',false, 'seed_md_de', '', 'seed_md_en', ''),
jsonb_build_object('section_key','closing', 'kind','prose', 'order_index', 9, 'label_de','Schlussformel', 'label_en','Closing',
'included',true,
'seed_md_de', E'Mit freundlichen Grüßen',
'seed_md_en', E'Yours sincerely,'),
jsonb_build_object('section_key','signature', 'kind','prose', 'order_index',10, 'label_de','Unterschrift', 'label_en','Signature',
'included',true,
'seed_md_de', E'{{user.display_name}}',
'seed_md_en', E'{{user.display_name}}')
)
),
'{}'::text[]
)
ON CONFLICT (slug) DO NOTHING;

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

@@ -0,0 +1,21 @@
-- 154_scenario_flags_ssot.down — t-paliad-331 / m/paliad#149 Phase 2 P0
--
-- Best-effort rollback of mig 154. Drops the catalog table and the
-- jsonb SSoT column. Any scenario state that downstream slices have
-- already written is lost — this is by design: down migs are operator
-- recovery, not a feature toggle.
BEGIN;
SELECT set_config(
'paliad.audit_reason',
'mig 154 down: revert scenario_flags SSoT',
true
);
DROP TABLE IF EXISTS paliad.scenario_flag_catalog;
ALTER TABLE paliad.projects
DROP COLUMN IF EXISTS scenario_flags;
COMMIT;

View File

@@ -0,0 +1,139 @@
-- 154_scenario_flags_ssot — t-paliad-331 / m/paliad#149 Phase 2 P0
--
-- Single source of truth for per-project scenario state. Per the
-- design (docs/design-deadline-system-revision-2026-05-27.md §2.3
-- and §2.4a), every scenario decision a user makes on a project
-- lives in one jsonb column on paliad.projects:
--
-- { "with_ccr": true, "with_amend": false,
-- "rule:<uuid_of_optional_X>": true,
-- "rule:<uuid_of_recommended_Y>": false }
--
-- Entries are either:
-- * named scenario flags (whitelist via paliad.scenario_flag_catalog), or
-- * per-rule selection deviations of shape "rule:<uuid>".
--
-- The application validates writes against the catalog and the
-- project's active sequencing-rules set; this migration only adds the
-- storage. The three known flags (with_ccr / with_amend / with_cci)
-- are seeded into the catalog so the API layer has something to
-- validate against on day one — extra flags are admin-added later
-- (see §4.2.1 R.109 worked example: with_interpreter_denied /
-- with_translation_granted both land via the editor when m walks the
-- backfill, no fresh migration needed).
--
-- Purely additive: ADD COLUMN with safe DEFAULT, CREATE TABLE, seed
-- inserts. Three existing scenario storage surfaces (project_event_
-- choices, scenarios.spec, DOM-only) are all empty per athena's audit
-- (zero rows in either persistent surface), so there is nothing to
-- migrate.
--
-- No audit trigger fires on paliad.projects today; set_config is
-- defensive so any future audit trigger inherits the reason.
BEGIN;
SELECT set_config(
'paliad.audit_reason',
'mig 154: scenario_flags SSoT (t-paliad-331 / m/paliad#149 Phase 2 P0)',
true
);
-- ----------------------------------------------------------------
-- 1. paliad.projects.scenario_flags — the jsonb SSoT.
-- ----------------------------------------------------------------
ALTER TABLE paliad.projects
ADD COLUMN scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb
CHECK (jsonb_typeof(scenario_flags) = 'object');
COMMENT ON COLUMN paliad.projects.scenario_flags IS
'Per-project scenario state — single source of truth (m/paliad#149 '
'Phase 2 P0, design §2.3 + §2.4a). Flat jsonb object whose keys are '
'either named scenario flags (whitelist via paliad.scenario_flag_catalog) '
'or per-rule selection deviations of shape "rule:<uuid>". Values are '
'always JSON booleans; missing keys take the priority-driven default '
'(mandatory always selected; recommended default-selected; optional '
'default-unselected). Validated at write time by the '
'ScenarioFlagsService.Patch handler; this column''s CHECK only '
'enforces that the top-level shape is an object.';
-- ----------------------------------------------------------------
-- 2. paliad.scenario_flag_catalog — the named-flag whitelist.
-- Per design §4.1: a small admin-editable vocabulary that powers
-- both the write-time validator and the UI's scenario-flag strip.
-- Per-rule entries ("rule:<uuid>") are NOT enumerated here — they
-- match a pattern and are validated by resolving the UUID against
-- the project's active sequencing-rules set.
-- ----------------------------------------------------------------
CREATE TABLE paliad.scenario_flag_catalog (
flag_key text PRIMARY KEY
CHECK (flag_key ~ '^[a-z][a-z0-9_]*$'
AND flag_key NOT LIKE 'rule:%'
AND char_length(flag_key) BETWEEN 1 AND 64),
label_de text NOT NULL CHECK (char_length(label_de) > 0),
label_en text NOT NULL CHECK (char_length(label_en) > 0),
description text NULL,
-- hidden_unless_set: when true, the flag is only surfaced in the
-- UI's scenario strip once a rule's condition_expr references it
-- (or once it's explicitly set on a project). Per design §4.2.1,
-- with_interpreter_denied + with_translation_granted are good
-- candidates for this once they're seeded — the flag exists for
-- write validation but doesn't clutter the default UI.
hidden_unless_set boolean NOT NULL DEFAULT false,
added_at timestamptz NOT NULL DEFAULT now()
);
COMMENT ON TABLE paliad.scenario_flag_catalog IS
'Named-flag vocabulary for paliad.projects.scenario_flags '
'(m/paliad#149 Phase 2 P0, design §4.1). Read by the write-time '
'validator in ScenarioFlagsService.Patch and by the Verfahrensablauf '
'scenario-strip UI. Per-rule selection entries ("rule:<uuid>") are '
'NOT enumerated here — they match a pattern and are validated by '
'UUID lookup against the project''s active sequencing-rules set.';
COMMENT ON COLUMN paliad.scenario_flag_catalog.hidden_unless_set IS
'When true, the flag does not appear in the default UI scenario '
'strip — it is surfaced only when a rule''s condition_expr '
'references it or when the project already has it set. Lets us '
'register rare flags (e.g. with_interpreter_denied) without '
'cluttering the default strip.';
-- ----------------------------------------------------------------
-- 3. Seed the three known flags. These are the flags referenced by
-- the 18 condition_expr rows in paliad.sequencing_rules today
-- (4 composite condition_expr rows are and/or-of these three).
-- ----------------------------------------------------------------
INSERT INTO paliad.scenario_flag_catalog (flag_key, label_de, label_en, description, hidden_unless_set)
VALUES
('with_ccr', 'Mit Widerklage auf Nichtigkeit',
'With counterclaim for revocation (CCR)',
'Active when the defendant has filed a CCR. Gates R.025 + the R.029 reply/rejoinder chain on upc.inf.cfi and the R.030 amendment branch nested under it.',
false),
('with_amend', 'Mit Antrag auf Patentänderung (R.30)',
'With application to amend the patent (R.30)',
'Active when the patentee has filed an R.30 application. Gates the R.032 def-to-amend / reply / rejoinder chain on the amendment branch.',
false),
('with_cci', 'Mit Widerklage auf Verletzung',
'With counterclaim for infringement (CCI)',
'Active when the defendant on a revocation action has filed an infringement counterclaim. Gates the analogous chain on upc.rev.cfi (the inverse of with_ccr).',
false);
-- ----------------------------------------------------------------
-- 4. Sanity check + informational notice.
-- ----------------------------------------------------------------
DO $$
DECLARE
n int;
BEGIN
SELECT COUNT(*) INTO n FROM paliad.scenario_flag_catalog;
IF n <> 3 THEN
RAISE EXCEPTION '[mig 154] expected 3 seeded flags, found %', n;
END IF;
RAISE NOTICE '[mig 154] scenario_flags SSoT ready — % flag(s) in catalog', n;
END $$;
COMMIT;

View File

@@ -0,0 +1,43 @@
-- 155_upc_apl_resplit.down — t-paliad-331 / m/paliad#149 Phase 2 P1
--
-- Best-effort rollback. Restores from the same-TX snapshots written by
-- mig 155. Drops the snapshots once restoration is verified.
BEGIN;
SELECT set_config(
'paliad.audit_reason',
'mig 155 down: revert upc.apl re-split (restore unified id=160)',
true
);
-- ----------------------------------------------------------------
-- 1. Restore proceeding_types.is_active from snapshot.
-- ----------------------------------------------------------------
UPDATE paliad.proceeding_types pt
SET is_active = pre.is_active
FROM paliad.proceeding_types_pre_155 pre
WHERE pt.id = pre.id
AND pt.is_active IS DISTINCT FROM pre.is_active;
-- ----------------------------------------------------------------
-- 2. Restore rule bindings from snapshot.
-- ----------------------------------------------------------------
UPDATE paliad.sequencing_rules sr
SET proceeding_type_id = pre.proceeding_type_id,
spawn_proceeding_type_id = pre.spawn_proceeding_type_id
FROM paliad.sequencing_rules_pre_155 pre
WHERE sr.id = pre.id
AND (sr.proceeding_type_id IS DISTINCT FROM pre.proceeding_type_id
OR sr.spawn_proceeding_type_id IS DISTINCT FROM pre.spawn_proceeding_type_id);
-- ----------------------------------------------------------------
-- 3. Drop the snapshots.
-- ----------------------------------------------------------------
DROP TABLE IF EXISTS paliad.sequencing_rules_pre_155;
DROP TABLE IF EXISTS paliad.proceeding_types_pre_155;
COMMIT;

View File

@@ -0,0 +1,191 @@
-- 155_upc_apl_resplit — t-paliad-331 / m/paliad#149 Phase 2 P1
--
-- Reverts the upc.apl unification that mig 096 introduced. m's Q5
-- (2026-05-27, verbatim):
--
-- "Reverse the unification as suggested in 3. They are different
-- proceedings, I only wanted the approach to be unified in the
-- 'determinator' — but they are actually different proceedings!"
--
-- The current state (audited 2026-05-27, mig 155 pre-flight):
--
-- id=160 upc.apl.unified is_active=true (carries all 16 rules)
-- id=11 upc.apl.merits is_active=false
-- id=19 upc.apl.cost is_active=false
-- id=20 upc.apl.order is_active=false
--
-- The 16 rules under id=160 split cleanly by event_code prefix:
-- 7 rows match 'upc.apl.merits.%' → target id=11
-- 2 rows match 'upc.apl.cost.%' → target id=19
-- 7 rows match 'upc.apl.order.%' → target id=20
--
-- Every parent_id chain among those 16 rows stays inside its bucket
-- (audited: 10/10 parent edges are bucket-local), so retargeting by
-- event_code prefix preserves the tree shape — no extra parent_id
-- surgery needed.
--
-- Spawn FKs: 4 rules currently target id=11 (was inactive — this is
-- the R3 finding athena flagged, re-interpreted by m's Q5 as correct
-- intent rather than broken state):
--
-- upc.inf.cfi.appeal_spawn → 11 (merits) — keep
-- upc.rev.cfi.appeal_spawn → 11 (merits) — keep
-- upc.dmgs.cfi.appeal_spawn → 11 (merits) — keep
-- upc.pi.cfi.appeal_spawn → 11 (merits) — RETARGET to 20 (order),
-- since PI appeals
-- land on the orders
-- track per design §3.1.
--
-- Active scenarios / projects pointing at id=160: zero (verified
-- pre-flight: 0 projects, 0 scenarios reference 'upc.apl'). No data
-- migration on the project side; no production traffic is mid-flight
-- on id=160.
--
-- Mig 153's `projects_proceeding_type_kind_check` trigger gates
-- inserts/updates against kind='proceeding'. id=11/19/20 already
-- carry kind='proceeding' (verified pre-flight), so the trigger
-- won't fire on the re-activations.
BEGIN;
SELECT set_config(
'paliad.audit_reason',
'mig 155: upc.apl re-split — reactivate merits/cost/order, retire unified (t-paliad-331 / m/paliad#149 P1)',
true
);
-- ----------------------------------------------------------------
-- 1. Snapshot for audit + rollback.
-- ----------------------------------------------------------------
CREATE TABLE paliad.proceeding_types_pre_155 AS
SELECT * FROM paliad.proceeding_types WHERE id IN (11, 19, 20, 160);
CREATE TABLE paliad.sequencing_rules_pre_155 AS
SELECT * FROM paliad.sequencing_rules
WHERE proceeding_type_id = 160
OR (is_spawn AND spawn_proceeding_type_id IN (11, 19, 20, 160));
COMMENT ON TABLE paliad.proceeding_types_pre_155 IS
'Snapshot of the 4 appeal-related proceeding_types rows taken in '
'the same TX as mig 155 (upc.apl re-split). Audit + rollback safety.';
COMMENT ON TABLE paliad.sequencing_rules_pre_155 IS
'Snapshot of the 16 rules under id=160 + the 4 spawn rules targeting '
'the appeal cluster, taken in the same TX as mig 155. Audit + rollback.';
-- ----------------------------------------------------------------
-- 2. Re-activate the three discrete appeal PTs; retire the unified row.
-- ----------------------------------------------------------------
UPDATE paliad.proceeding_types SET is_active = true WHERE id IN (11, 19, 20);
UPDATE paliad.proceeding_types SET is_active = false WHERE id = 160;
DO $$
DECLARE
n_active int;
n_inactive int;
BEGIN
SELECT COUNT(*) INTO n_active FROM paliad.proceeding_types
WHERE id IN (11, 19, 20) AND is_active = true;
SELECT COUNT(*) INTO n_inactive FROM paliad.proceeding_types
WHERE id = 160 AND is_active = false;
IF n_active <> 3 OR n_inactive <> 1 THEN
RAISE EXCEPTION '[mig 155] activation check failed — active(11,19,20)=% / inactive(160)=%', n_active, n_inactive;
END IF;
END $$;
-- ----------------------------------------------------------------
-- 3. Retarget the 16 rules on id=160 to merits/cost/order by event_code
-- prefix. parent_id stays intact (all parent edges are bucket-local
-- per pre-flight audit).
-- ----------------------------------------------------------------
UPDATE paliad.sequencing_rules sr
SET proceeding_type_id = 11
FROM paliad.procedural_events pe
WHERE pe.id = sr.procedural_event_id
AND sr.proceeding_type_id = 160
AND pe.code LIKE 'upc.apl.merits.%';
UPDATE paliad.sequencing_rules sr
SET proceeding_type_id = 19
FROM paliad.procedural_events pe
WHERE pe.id = sr.procedural_event_id
AND sr.proceeding_type_id = 160
AND pe.code LIKE 'upc.apl.cost.%';
UPDATE paliad.sequencing_rules sr
SET proceeding_type_id = 20
FROM paliad.procedural_events pe
WHERE pe.id = sr.procedural_event_id
AND sr.proceeding_type_id = 160
AND pe.code LIKE 'upc.apl.order.%';
DO $$
DECLARE
remaining int;
merits int; cost int; ord int;
BEGIN
SELECT COUNT(*) INTO remaining
FROM paliad.sequencing_rules WHERE proceeding_type_id = 160;
IF remaining <> 0 THEN
RAISE EXCEPTION '[mig 155] rebind failed — % rules still on id=160 (expected 0)', remaining;
END IF;
SELECT COUNT(*) INTO merits
FROM paliad.sequencing_rules WHERE proceeding_type_id = 11;
SELECT COUNT(*) INTO cost
FROM paliad.sequencing_rules WHERE proceeding_type_id = 19;
SELECT COUNT(*) INTO ord
FROM paliad.sequencing_rules WHERE proceeding_type_id = 20;
IF merits <> 7 OR cost <> 2 OR ord <> 7 THEN
RAISE EXCEPTION
'[mig 155] post-rebind counts wrong — merits=% (want 7) / cost=% (want 2) / order=% (want 7)',
merits, cost, ord;
END IF;
RAISE NOTICE '[mig 155] rebind OK — merits=% cost=% order=%', merits, cost, ord;
END $$;
-- ----------------------------------------------------------------
-- 4. Retarget the upc.pi.cfi.appeal_spawn rule to id=20 (orders track).
-- PI appeals don't go to the merits track — they're orders.
-- The inf/rev/dmgs spawns keep target=11 (now active, was inactive
-- by accident of the unification).
-- ----------------------------------------------------------------
UPDATE paliad.sequencing_rules
SET spawn_proceeding_type_id = 20
WHERE is_spawn = true
AND procedural_event_id = (
SELECT id FROM paliad.procedural_events WHERE code = 'upc.pi.cfi.appeal_spawn'
)
AND spawn_proceeding_type_id = 11;
DO $$
DECLARE
pi_target int;
others int;
BEGIN
SELECT spawn_proceeding_type_id INTO pi_target
FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
WHERE pe.code = 'upc.pi.cfi.appeal_spawn' AND sr.is_spawn = true
LIMIT 1;
IF pi_target IS DISTINCT FROM 20 THEN
RAISE EXCEPTION '[mig 155] pi.cfi spawn retarget failed — got %, want 20', pi_target;
END IF;
SELECT COUNT(*) INTO others
FROM paliad.sequencing_rules sr
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
WHERE sr.is_spawn = true
AND sr.spawn_proceeding_type_id = 11
AND pe.code IN ('upc.inf.cfi.appeal_spawn',
'upc.rev.cfi.appeal_spawn',
'upc.dmgs.cfi.appeal_spawn');
IF others <> 3 THEN
RAISE EXCEPTION '[mig 155] inf/rev/dmgs spawn target check failed — % rows point at 11 (want 3)', others;
END IF;
RAISE NOTICE '[mig 155] spawn graph OK — pi → 20 (order); inf/rev/dmgs → 11 (merits)';
END $$;
COMMIT;

View File

@@ -0,0 +1,21 @@
-- 156_trigger_event_id_partial_deprecation.down — t-paliad-331 / m/paliad#149
BEGIN;
SELECT set_config(
'paliad.audit_reason',
'mig 156 down: restore trigger_event_id on the 2 hybrid rules',
true
);
-- Restore the trigger_event_id values from the same-TX snapshot.
UPDATE paliad.sequencing_rules sr
SET trigger_event_id = pre.trigger_event_id
FROM paliad.sequencing_rules_pre_156 pre
WHERE sr.id = pre.id
AND sr.trigger_event_id IS NULL
AND pre.trigger_event_id IS NOT NULL;
DROP TABLE IF EXISTS paliad.sequencing_rules_pre_156;
COMMIT;

View File

@@ -0,0 +1,80 @@
-- 156_trigger_event_id_partial_deprecation — t-paliad-331 / m/paliad#149 Phase 2 P4 (partial)
--
-- Partial deprecation step toward retiring paliad.trigger_events.
-- The full table-drop (and the route + service + 5 read-site removals
-- the design's §3.4 + §4.3 lay out) is gated on the editorial backfill
-- of the 73 orphan globals — sequencing_rules rows that carry
-- trigger_event_id NOT NULL AND proceeding_type_id IS NULL today. m
-- drives that walk via /admin/procedural-events at his cadence (no
-- coder time blocked); this mig prepares the way without breaking the
-- legacy route the orphans still depend on.
--
-- What this mig does (live-DB audited 2026-05-27 pre-flight):
--
-- 1. NULL out the 2 hybrid rules that carry BOTH parent_id AND
-- trigger_event_id. Per design §2.1 / m's Q1: parent_id is the
-- canonical predecessor link; trigger_event_id on those 2 rows is
-- redundant. The parent_id chain keeps the live edge — no data
-- loss, no route disruption (the route only reads trigger_event_id
-- for the 73 orphan globals, which have no parent_id).
--
-- 2. NOT-DROP the column or the table. Both stay live so the
-- /api/tools/event-deadlines route continues to serve the 73
-- orphan globals until editorial reparenting lands.
--
-- The full P4 (mig that DROPs paliad.trigger_events + the
-- `sequencing_rules.trigger_event_id` column + the legacy route +
-- EventDeadlineService + ExportService::1680 + cmd/gen-upc-snapshot/
-- main.go:185-202) lands AFTER the 73 orphans are reparented. Until
-- then, the legacy surface remains.
BEGIN;
SELECT set_config(
'paliad.audit_reason',
'mig 156: trigger_event_id partial deprecation — NULL out 2 hybrid rules (t-paliad-331 / m/paliad#149 Phase 2 P4 partial)',
true
);
-- ----------------------------------------------------------------
-- 1. Snapshot the 2 hybrid rows for audit + rollback.
-- ----------------------------------------------------------------
CREATE TABLE paliad.sequencing_rules_pre_156 AS
SELECT * FROM paliad.sequencing_rules
WHERE trigger_event_id IS NOT NULL
AND parent_id IS NOT NULL
AND is_active = true;
COMMENT ON TABLE paliad.sequencing_rules_pre_156 IS
'Snapshot of the 2 hybrid rules (trigger_event_id NOT NULL AND '
'parent_id NOT NULL) taken in the same TX as mig 156, before their '
'trigger_event_id is NULL''ed. Rollback aid until P4 final lands.';
-- ----------------------------------------------------------------
-- 2. NULL out trigger_event_id on hybrid rules — parent_id is the
-- canonical predecessor link per design §2.1.
-- ----------------------------------------------------------------
UPDATE paliad.sequencing_rules
SET trigger_event_id = NULL
WHERE trigger_event_id IS NOT NULL
AND parent_id IS NOT NULL
AND is_active = true;
DO $$
DECLARE
remaining_hybrids int;
BEGIN
SELECT COUNT(*) INTO remaining_hybrids
FROM paliad.sequencing_rules
WHERE trigger_event_id IS NOT NULL
AND parent_id IS NOT NULL
AND is_active = true;
IF remaining_hybrids <> 0 THEN
RAISE EXCEPTION '[mig 156] expected 0 active hybrid rules, found %', remaining_hybrids;
END IF;
RAISE NOTICE '[mig 156] hybrid-rule cleanup OK — 0 active rules carry both parent_id and trigger_event_id';
END $$;
COMMIT;

View File

@@ -0,0 +1,94 @@
-- 157_scenario_builder_foundation — down
--
-- Rolls back mig 157 in reverse order. Down files are reference material
-- (not auto-applied); operator recovery path is:
--
-- psql ... < 157_scenario_builder_foundation.down.sql
-- DELETE FROM paliad.applied_migrations WHERE version = 157;
--
-- This restores the legacy paliad.scenarios shape from mig 145 — the
-- builder columns and the three sibling tables are dropped wholesale.
-- Any builder data in the dropped tables is lost (the tables CASCADE to
-- their children, and DROP TABLE doesn't keep a backup).
BEGIN;
SELECT set_config(
'paliad.audit_reason',
'mig 157 rollback: tear down Scenario builder foundation (t-paliad-340)',
true
);
-- 8. updated_at triggers
DROP TRIGGER IF EXISTS scenario_events_touch_updated_at_trg ON paliad.scenario_events;
DROP TRIGGER IF EXISTS scenario_proceedings_touch_updated_at_trg ON paliad.scenario_proceedings;
-- 7. RLS — drop new policies + restore legacy four
DROP POLICY IF EXISTS scenario_shares_mutate ON paliad.scenario_shares;
DROP POLICY IF EXISTS scenario_shares_select ON paliad.scenario_shares;
DROP POLICY IF EXISTS scenario_events_mutate ON paliad.scenario_events;
DROP POLICY IF EXISTS scenario_events_select ON paliad.scenario_events;
DROP POLICY IF EXISTS scenario_proceedings_mutate ON paliad.scenario_proceedings;
DROP POLICY IF EXISTS scenario_proceedings_select ON paliad.scenario_proceedings;
DROP POLICY IF EXISTS scenarios_owner_mutate ON paliad.scenarios;
DROP POLICY IF EXISTS scenarios_select ON paliad.scenarios;
-- Restore the four mig-145 policies verbatim.
CREATE POLICY scenarios_project_select ON paliad.scenarios
FOR SELECT
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id));
CREATE POLICY scenarios_project_mutate ON paliad.scenarios
FOR ALL
USING (project_id IS NOT NULL AND paliad.can_see_project(project_id))
WITH CHECK (project_id IS NOT NULL AND paliad.can_see_project(project_id));
CREATE POLICY scenarios_abstract_select ON paliad.scenarios
FOR SELECT
USING (project_id IS NULL AND created_by = auth.uid());
CREATE POLICY scenarios_abstract_mutate ON paliad.scenarios
FOR ALL
USING (project_id IS NULL AND created_by = auth.uid())
WITH CHECK (project_id IS NULL AND created_by = auth.uid());
-- 6. helper function
DROP FUNCTION IF EXISTS paliad.can_see_scenario(uuid);
-- 5. paliad.projects.origin_scenario_id
DROP INDEX IF EXISTS paliad.projects_origin_scenario_idx;
ALTER TABLE paliad.projects DROP COLUMN IF EXISTS origin_scenario_id;
-- 4. paliad.scenario_shares
DROP TABLE IF EXISTS paliad.scenario_shares;
-- 3. paliad.scenario_events
DROP TABLE IF EXISTS paliad.scenario_events;
-- 2. paliad.scenario_proceedings
DROP TABLE IF EXISTS paliad.scenario_proceedings;
-- 1. paliad.scenarios — restore mig-145 shape
DROP INDEX IF EXISTS paliad.scenarios_updated_idx;
DROP INDEX IF EXISTS paliad.scenarios_owner_status_idx;
-- Restore the unique constraint mig 145 had.
ALTER TABLE paliad.scenarios
ADD CONSTRAINT scenarios_unique_per_scope
UNIQUE NULLS NOT DISTINCT (project_id, created_by, name);
-- spec was NOT NULL in mig 145. Restore that — but only after backfilling
-- any NULL specs the builder might have created (none in legacy paths;
-- only builder rows have NULL spec, and those are dropped together with
-- the builder schema if a real rollback is needed).
UPDATE paliad.scenarios SET spec = '{}'::jsonb WHERE spec IS NULL;
ALTER TABLE paliad.scenarios ALTER COLUMN spec SET NOT NULL;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS notes;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS stichtag;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS promoted_project_id;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS origin_project_id;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS status;
ALTER TABLE paliad.scenarios DROP COLUMN IF EXISTS owner_id;
COMMIT;

View File

@@ -0,0 +1,500 @@
-- 157_scenario_builder_foundation — t-paliad-340 / m/paliad#153 B0
--
-- Schema foundation for the Litigation Builder (PRD
-- docs/plans/prd-procedures-litigation-planner-2026-05-27.md §5.1 + §5.2).
-- Phase B0 of the 7-slice train described in PRD §7.1. DB-only — no UI
-- depends on these tables yet; B1 wires the builder shell on top.
--
-- What this migration adds:
--
-- 1. Six new columns on paliad.scenarios for the builder shape:
-- owner_id, status, origin_project_id, promoted_project_id,
-- stichtag, notes.
-- Two relaxations on existing columns:
-- - spec NOT NULL → NULL (the builder normalises spec contents
-- into scenario_proceedings / scenario_events; new rows skip
-- spec entirely. Legacy callers from mig 145 still provide it
-- explicitly, so they keep inserting valid rows.)
-- - DROP CONSTRAINT scenarios_unique_per_scope (the builder
-- allows multiple "Unbenanntes Szenario" + multiple scratch
-- scenarios per user — uniqueness on (project_id, created_by,
-- name) blocks that. The legacy service treated the constraint
-- as UX collision avoidance, not correctness.)
--
-- 2. Three new tables for the normalised builder shape:
-- - paliad.scenario_proceedings (one row per proceeding in a
-- scenario; multi-proceeding constellations + spawned children)
-- - paliad.scenario_events (one row per event card on the
-- canvas; planned / filed / skipped state + actual_date + notes
-- + per-card optional horizon)
-- - paliad.scenario_shares (read-only team shares; owner is
-- the sole editor)
--
-- 3. One new column on paliad.projects:
-- - origin_scenario_id — audit trail for promote-to-project
-- (B5; the column lands now so the FK is in place when the
-- wizard arrives).
--
-- 4. New helper function paliad.can_see_scenario(_scenario_id) that
-- mirrors paliad.can_see_project's STABLE SECURITY DEFINER shape.
-- Visibility logic:
-- - global_admin sees everything,
-- - owner_id = auth.uid() (builder-owned scenarios),
-- - scenario_shares.shared_with_user_id = auth.uid()
-- (read-only shared scenarios),
-- - legacy project-scoped scenarios (owner_id IS NULL AND
-- project_id IS NOT NULL) follow can_see_project(project_id),
-- - legacy abstract scenarios (owner_id IS NULL AND project_id
-- IS NULL) follow created_by = auth.uid().
--
-- 5. Replacement RLS policies on paliad.scenarios that fold builder
-- visibility together with the legacy shape. The legacy
-- project_* / abstract_* policies are dropped (they covered only
-- legacy paths) and rewritten as a single pair of policies that
-- treats owner_id, scenario_shares, and the legacy paths uniformly.
--
-- Builder-only RLS for the three new tables: read = scenario
-- visibility; write = scenario owner (or legacy editor) only.
--
-- PRD §5.1 deviations called out for the reader:
--
-- - PRD specs `proceeding_type_id uuid REFERENCES paliad.proceeding_types(id)`.
-- The live column is `integer` (see paliad.proceeding_types.id);
-- scenario_proceedings.proceeding_type_id is integer here to match
-- the real FK target. PRD authors did not check the column type;
-- this migration uses the truth on disk.
--
-- - PRD references `auth.users(id)` for owner_id and share columns;
-- the established paliad convention (see paliad.projects.created_by,
-- paliad.scenarios.created_by) uses `paliad.users(id)`. Same UUIDs
-- either way (paliad.users.id == auth.users.id), but the FK targets
-- paliad.users to stay consistent with project tables.
--
-- Audit-first: all DDL ran clean against a BEGIN/ROLLBACK probe on the
-- live DB before this file was committed. paliad.scenarios has 0 rows
-- (verified pre-mig), so the column additions and constraint relaxations
-- have no data impact.
BEGIN;
SELECT set_config(
'paliad.audit_reason',
'mig 157: Scenario builder foundation (t-paliad-340 / m/paliad#153 B0)',
true
);
-- ----------------------------------------------------------------
-- 1. paliad.scenarios — additive columns + constraint relaxations
-- ----------------------------------------------------------------
ALTER TABLE paliad.scenarios
ADD COLUMN owner_id uuid NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
ADD COLUMN status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active','archived','promoted')),
ADD COLUMN origin_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
ADD COLUMN promoted_project_id uuid NULL REFERENCES paliad.projects(id) ON DELETE SET NULL,
ADD COLUMN stichtag date NULL,
ADD COLUMN notes text NULL;
ALTER TABLE paliad.scenarios ALTER COLUMN spec DROP NOT NULL;
ALTER TABLE paliad.scenarios DROP CONSTRAINT IF EXISTS scenarios_unique_per_scope;
CREATE INDEX scenarios_owner_status_idx
ON paliad.scenarios(owner_id, status)
WHERE owner_id IS NOT NULL;
CREATE INDEX scenarios_updated_idx
ON paliad.scenarios(owner_id, updated_at DESC)
WHERE owner_id IS NOT NULL;
COMMENT ON COLUMN paliad.scenarios.owner_id IS
'Litigation Builder owner (PRD §5.1). NULL = legacy composition-spec '
'scenario from m/paliad#124 Slice D (mig 145). Builder rows MUST have '
'owner_id set; the application enforces it via ScenarioBuilderService.';
COMMENT ON COLUMN paliad.scenarios.status IS
'Lifecycle: active (default; user-editable) / archived (soft-deleted, '
'still visible in side panel) / promoted (converted to project via '
'B5 wizard; read-only). Legacy mig-145 rows default to active.';
COMMENT ON COLUMN paliad.scenarios.origin_project_id IS
'Set when the scenario was exported from an existing project '
'("Im Builder öffnen" — Akte mode, PRD §2.3).';
COMMENT ON COLUMN paliad.scenarios.promoted_project_id IS
'Set after the scenario was promoted to a real project via the 3-step '
'wizard (PRD §5.4). Together with paliad.projects.origin_scenario_id, '
'forms the bidirectional audit link.';
COMMENT ON COLUMN paliad.scenarios.stichtag IS
'Scenario-level default Stichtag; per-proceeding overrides in '
'paliad.scenario_proceedings.stichtag take precedence.';
-- ----------------------------------------------------------------
-- 2. paliad.scenario_proceedings — one proceeding per scenario row
-- ----------------------------------------------------------------
CREATE TABLE paliad.scenario_proceedings (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_id uuid NOT NULL
REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
proceeding_type_id integer NOT NULL
REFERENCES paliad.proceeding_types(id),
primary_party text NULL
CHECK (primary_party IN ('claimant','defendant')),
scenario_flags jsonb NOT NULL DEFAULT '{}'::jsonb
CHECK (jsonb_typeof(scenario_flags) = 'object'),
parent_scenario_proceeding_id uuid NULL
REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
spawn_anchor_event_id uuid NULL
REFERENCES paliad.sequencing_rules(id),
ordinal int NOT NULL DEFAULT 0,
stichtag date NULL,
detailgrad text NOT NULL DEFAULT 'selected'
CHECK (detailgrad IN ('selected','all_options')),
appeal_target text NULL,
collapsed boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX scenario_proceedings_scenario_idx
ON paliad.scenario_proceedings(scenario_id, ordinal);
CREATE INDEX scenario_proceedings_parent_idx
ON paliad.scenario_proceedings(parent_scenario_proceeding_id)
WHERE parent_scenario_proceeding_id IS NOT NULL;
COMMENT ON TABLE paliad.scenario_proceedings IS
'One proceeding inside a Litigation Builder scenario. Multiple rows '
'per scenario for multi-proceeding constellations. '
'parent_scenario_proceeding_id self-refs for spawned children '
'(e.g. upc.ccr.cfi spawned by with_ccr on upc.inf.cfi). '
'PRD §5.1, m/paliad#153 B0.';
COMMENT ON COLUMN paliad.scenario_proceedings.primary_party IS
'Per-proceeding perspective ("our side"). NULL = no perspective '
'picked yet (both party columns render with natural labels). '
'Per-proceeding so multi-jurisdiction constellations can flip side '
'independently (PRD §3.3).';
COMMENT ON COLUMN paliad.scenario_proceedings.scenario_flags IS
'Per-proceeding flags (e.g. {"with_ccr": true, "with_amend": false}). '
'Mirrors paliad.projects.scenario_flags shape but lives per-proceeding-'
'per-scenario. Validated by the application against '
'paliad.scenario_flag_catalog at write time.';
COMMENT ON COLUMN paliad.scenario_proceedings.spawn_anchor_event_id IS
'Which sequencing_rule of the parent proceeding caused this spawn. '
'NULL for root proceedings. Used by the UI to place the spawned child '
'triplet directly below the parent at the spawn node (PRD §3.6).';
COMMENT ON COLUMN paliad.scenario_proceedings.ordinal IS
'Stack order on canvas (top to bottom). Siblings under the same '
'parent (or top-level) are ordered by ordinal asc, then created_at.';
COMMENT ON COLUMN paliad.scenario_proceedings.detailgrad IS
'Per-proceeding optional-detail toggle: selected (only explicitly '
'chosen optionals + mandatories) or all_options (every optional '
'sequencing_rule surfaces). Matches today''s Verfahrensablauf pattern.';
-- ----------------------------------------------------------------
-- 3. paliad.scenario_events — one event card on the canvas
-- ----------------------------------------------------------------
CREATE TABLE paliad.scenario_events (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_proceeding_id uuid NOT NULL
REFERENCES paliad.scenario_proceedings(id) ON DELETE CASCADE,
sequencing_rule_id uuid NULL
REFERENCES paliad.sequencing_rules(id),
procedural_event_id uuid NULL
REFERENCES paliad.procedural_events(id),
custom_label text NULL,
state text NOT NULL DEFAULT 'planned'
CHECK (state IN ('planned','filed','skipped')),
actual_date date NULL,
skip_reason text NULL,
notes text NULL,
horizon_optional int NOT NULL DEFAULT 0
CHECK (horizon_optional >= 0),
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT scenario_events_one_anchor CHECK (
(sequencing_rule_id IS NOT NULL)::int +
(procedural_event_id IS NOT NULL)::int +
(custom_label IS NOT NULL)::int >= 1
)
);
CREATE INDEX scenario_events_proceeding_idx
ON paliad.scenario_events(scenario_proceeding_id);
-- A single proceeding can't carry two cards for the same sequencing rule
-- (each rule maps to one card). Free-form / procedural_event-only cards
-- skip this uniqueness — multiple custom cards per proceeding are OK.
CREATE UNIQUE INDEX scenario_events_rule_uniq_idx
ON paliad.scenario_events(scenario_proceeding_id, sequencing_rule_id)
WHERE sequencing_rule_id IS NOT NULL;
COMMENT ON TABLE paliad.scenario_events IS
'One event card on the Litigation Builder canvas. Captures state '
'(planned/filed/skipped), actual_date, notes, skip_reason, and the '
'per-card optional-horizon setting. At least one of '
'(sequencing_rule_id, procedural_event_id, custom_label) must be '
'set — sequencing-rule-backed cards are the common case; free-form '
'cards exist for events the catalog doesn''t cover yet. '
'PRD §3.4 / §5.1.';
COMMENT ON COLUMN paliad.scenario_events.state IS
'3-state machine: planned (default, future event with computed date) '
'/ filed (past event, actual_date set) / skipped (user chose not to '
'file; optional skip_reason). No "overdue" enum — that''s derived '
'(date < today AND state=planned), not stored. PRD Q10 / §3.4.';
COMMENT ON COLUMN paliad.scenario_events.actual_date IS
'Set when state=filed (real-world filing date) OR when state=planned '
'and the user overrode the computed date (court-set events, manual '
'tweaks). NULL when the computed date is canonical.';
COMMENT ON COLUMN paliad.scenario_events.horizon_optional IS
'Per-card "show N more optional follow-ups" affordance. Default 0 '
'(hidden). PRD Q4 / §3.4.';
-- ----------------------------------------------------------------
-- 4. paliad.scenario_shares — read-only team shares
-- ----------------------------------------------------------------
CREATE TABLE paliad.scenario_shares (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
scenario_id uuid NOT NULL
REFERENCES paliad.scenarios(id) ON DELETE CASCADE,
shared_with_user_id uuid NOT NULL
REFERENCES paliad.users(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid NOT NULL REFERENCES paliad.users(id),
UNIQUE (scenario_id, shared_with_user_id)
);
CREATE INDEX scenario_shares_user_idx
ON paliad.scenario_shares(shared_with_user_id);
COMMENT ON TABLE paliad.scenario_shares IS
'Read-only team shares for Litigation Builder scenarios. Owner '
'(paliad.scenarios.owner_id) is the sole editor; rows here grant '
'view-only access to other paliad users. PRD Q12 / §5.1.';
-- ----------------------------------------------------------------
-- 5. paliad.projects.origin_scenario_id — promote-to-project trail
-- ----------------------------------------------------------------
ALTER TABLE paliad.projects
ADD COLUMN origin_scenario_id uuid NULL
REFERENCES paliad.scenarios(id) ON DELETE SET NULL;
CREATE INDEX projects_origin_scenario_idx
ON paliad.projects(origin_scenario_id)
WHERE origin_scenario_id IS NOT NULL;
COMMENT ON COLUMN paliad.projects.origin_scenario_id IS
'FK to the scenario this project was promoted from (B5 wizard). '
'NULL = project was created directly, not via Builder. Together with '
'paliad.scenarios.promoted_project_id, forms the bidirectional audit '
'link. PRD §5.2.';
-- ----------------------------------------------------------------
-- 6. paliad.can_see_scenario — visibility helper
-- ----------------------------------------------------------------
CREATE OR REPLACE FUNCTION paliad.can_see_scenario(_scenario_id uuid)
RETURNS boolean
LANGUAGE sql STABLE SECURITY DEFINER
SET search_path TO 'paliad', 'public'
AS $func$
SELECT EXISTS (
SELECT 1 FROM paliad.users u
WHERE u.id = auth.uid() AND u.global_role = 'global_admin'
)
OR EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = _scenario_id AND s.owner_id = auth.uid()
)
OR EXISTS (
SELECT 1 FROM paliad.scenario_shares sh
WHERE sh.scenario_id = _scenario_id
AND sh.shared_with_user_id = auth.uid()
)
-- Legacy project-scoped scenarios (mig 145) — visible via project
-- team membership.
OR EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = _scenario_id
AND s.owner_id IS NULL
AND s.project_id IS NOT NULL
AND paliad.can_see_project(s.project_id)
)
-- Legacy abstract scenarios (mig 145) — owner-only via created_by.
OR EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = _scenario_id
AND s.owner_id IS NULL
AND s.project_id IS NULL
AND s.created_by = auth.uid()
);
$func$;
COMMENT ON FUNCTION paliad.can_see_scenario(uuid) IS
'Returns true if the caller (auth.uid()) can see the given scenario. '
'Mirrors paliad.can_see_project. Covers builder-owned scenarios '
'(owner_id), read-only shares (scenario_shares), and the two legacy '
'paths from mig 145 (project-scoped via can_see_project, abstract '
'via created_by). Used by RLS on all four scenario_* tables.';
-- ----------------------------------------------------------------
-- 7. RLS — replace legacy scenarios policies + new tables
-- ----------------------------------------------------------------
-- Replace mig-145's four policies with a single pair that handles
-- builder + legacy shapes together.
DROP POLICY IF EXISTS scenarios_project_select ON paliad.scenarios;
DROP POLICY IF EXISTS scenarios_project_mutate ON paliad.scenarios;
DROP POLICY IF EXISTS scenarios_abstract_select ON paliad.scenarios;
DROP POLICY IF EXISTS scenarios_abstract_mutate ON paliad.scenarios;
CREATE POLICY scenarios_select ON paliad.scenarios
FOR SELECT USING (paliad.can_see_scenario(id));
-- Write rule: builder owner, legacy project team member (if no owner),
-- or legacy abstract creator (if no owner + no project). Shares are
-- read-only — they don't grant mutate.
CREATE POLICY scenarios_owner_mutate ON paliad.scenarios
FOR ALL
USING (
owner_id = auth.uid()
OR (owner_id IS NULL AND project_id IS NOT NULL AND paliad.can_see_project(project_id))
OR (owner_id IS NULL AND project_id IS NULL AND created_by = auth.uid())
)
WITH CHECK (
owner_id = auth.uid()
OR (owner_id IS NULL AND project_id IS NOT NULL AND paliad.can_see_project(project_id))
OR (owner_id IS NULL AND project_id IS NULL AND created_by = auth.uid())
);
-- scenario_proceedings — visibility piggybacks on the parent scenario.
ALTER TABLE paliad.scenario_proceedings ENABLE ROW LEVEL SECURITY;
CREATE POLICY scenario_proceedings_select ON paliad.scenario_proceedings
FOR SELECT USING (paliad.can_see_scenario(scenario_id));
CREATE POLICY scenario_proceedings_mutate ON paliad.scenario_proceedings
FOR ALL
USING (EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
))
WITH CHECK (EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
));
-- scenario_events — visibility piggybacks on the parent scenario via
-- the proceeding row.
ALTER TABLE paliad.scenario_events ENABLE ROW LEVEL SECURITY;
CREATE POLICY scenario_events_select ON paliad.scenario_events
FOR SELECT
USING (EXISTS (
SELECT 1 FROM paliad.scenario_proceedings sp
WHERE sp.id = scenario_proceeding_id
AND paliad.can_see_scenario(sp.scenario_id)
));
CREATE POLICY scenario_events_mutate ON paliad.scenario_events
FOR ALL
USING (EXISTS (
SELECT 1 FROM paliad.scenario_proceedings sp
JOIN paliad.scenarios s ON s.id = sp.scenario_id
WHERE sp.id = scenario_proceeding_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
))
WITH CHECK (EXISTS (
SELECT 1 FROM paliad.scenario_proceedings sp
JOIN paliad.scenarios s ON s.id = sp.scenario_id
WHERE sp.id = scenario_proceeding_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
));
-- scenario_shares — recipient can see their share rows; the scenario
-- owner (or legacy editor) can manage them.
ALTER TABLE paliad.scenario_shares ENABLE ROW LEVEL SECURITY;
CREATE POLICY scenario_shares_select ON paliad.scenario_shares
FOR SELECT
USING (
shared_with_user_id = auth.uid()
OR EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
)
);
CREATE POLICY scenario_shares_mutate ON paliad.scenario_shares
FOR ALL
USING (EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
))
WITH CHECK (EXISTS (
SELECT 1 FROM paliad.scenarios s
WHERE s.id = scenario_id
AND (s.owner_id = auth.uid()
OR (s.owner_id IS NULL AND s.project_id IS NOT NULL AND paliad.can_see_project(s.project_id))
OR (s.owner_id IS NULL AND s.project_id IS NULL AND s.created_by = auth.uid()))
));
-- ----------------------------------------------------------------
-- 8. updated_at triggers on the new tables (reuse the function mig 145
-- already created for paliad.scenarios).
-- ----------------------------------------------------------------
CREATE TRIGGER scenario_proceedings_touch_updated_at_trg
BEFORE UPDATE ON paliad.scenario_proceedings
FOR EACH ROW
EXECUTE FUNCTION paliad.scenarios_touch_updated_at();
CREATE TRIGGER scenario_events_touch_updated_at_trg
BEFORE UPDATE ON paliad.scenario_events
FOR EACH ROW
EXECUTE FUNCTION paliad.scenarios_touch_updated_at();
-- ----------------------------------------------------------------
-- 9. Informational NOTICE.
-- ----------------------------------------------------------------
DO $$
BEGIN
RAISE NOTICE '[mig 157] paliad.scenarios extended with builder columns (0 legacy rows affected)';
RAISE NOTICE '[mig 157] paliad.scenario_proceedings created';
RAISE NOTICE '[mig 157] paliad.scenario_events created';
RAISE NOTICE '[mig 157] paliad.scenario_shares created';
RAISE NOTICE '[mig 157] paliad.projects.origin_scenario_id added';
RAISE NOTICE '[mig 157] paliad.can_see_scenario(uuid) created';
END $$;
COMMIT;

View File

@@ -8,6 +8,7 @@ import (
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/models"
"mgit.msbls.de/m/paliad/internal/services"
)
@@ -25,6 +26,77 @@ import (
// is mapped to 409 Conflict so the editor UI can show a clear "must
// clone first" hint.
// Slice B.5 (t-paliad-305) JSON envelope renames:
//
// - submission_code → code (procedural-event identifier)
// - event_type → event_kind (procedural-event taxonomy)
//
// Wire compatibility: every response emits BOTH the legacy and the
// canonical keys for one slice (see Deprecation HTTP header on the
// response). Input bodies accept either name on the request; the
// canonical key wins when both are present.
//
// adminRuleResponse wraps models.DeadlineRule (= litigationplanner.Rule)
// to add the canonical `code` + `event_kind` fields alongside the
// 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"`
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. 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{}
}
return adminRuleResponse{
DeadlineRule: r,
Code: r.SubmissionCode,
EventKind: r.EventType,
}
}
// wrapRuleListResponse maps a slice of service results into the
// 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
}
// adminRuleDeprecationHeaders writes the IETF "Deprecation" + "Sunset"
// HTTP headers signaling that the legacy `submission_code` /
// `event_type` JSON keys are being retired in favour of `code` /
// `event_kind`. RFC 8594 (Sunset) + draft-ietf-httpapi-deprecation-header.
// Clients should migrate within one slice cycle.
func adminRuleDeprecationHeaders(w http.ResponseWriter) {
w.Header().Set("Deprecation", `true; key="submission_code,event_type"`)
w.Header().Set("Link", `<https://mgit.msbls.de/m/paliad/issues/93>; rel="deprecation"`)
}
// GET /admin/api/rules — paginated list with filters.
func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.ruleEditor == nil {
@@ -73,7 +145,16 @@ func handleAdminListRules(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, rows)
// 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, ptCodes))
}
// GET /admin/api/rules/{id}
@@ -91,7 +172,8 @@ func handleAdminGetRule(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
}
// POST /admin/api/rules — create draft.
@@ -108,12 +190,15 @@ func handleAdminCreateRule(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
// Slice B.5 (t-paliad-305): accept both legacy + canonical JSON keys.
body.CreateRuleInput.CoalesceCanonicalKeys()
row, err := dbSvc.ruleEditor.Create(r.Context(), body.CreateRuleInput, body.Reason)
if err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusCreated, row)
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusCreated, wrapRuleResponse(row))
}
// PATCH /admin/api/rules/{id} — partial update of a draft.
@@ -134,12 +219,15 @@ func handleAdminPatchRule(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
// Slice B.5 (t-paliad-305): accept both legacy + canonical JSON keys.
body.RulePatch.CoalesceCanonicalKeys()
row, err := dbSvc.ruleEditor.UpdateDraft(r.Context(), id, body.RulePatch, body.Reason)
if err != nil {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
}
// POST /admin/api/rules/{id}/clone-as-draft
@@ -161,7 +249,8 @@ func handleAdminCloneAsDraft(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusCreated, row)
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusCreated, wrapRuleResponse(row))
}
// POST /admin/api/rules/{id}/publish
@@ -183,7 +272,8 @@ func handleAdminPublishRule(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
}
// POST /admin/api/rules/{id}/archive
@@ -205,7 +295,8 @@ func handleAdminArchiveRule(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
}
// POST /admin/api/rules/{id}/restore
@@ -227,7 +318,8 @@ func handleAdminRestoreRule(w http.ResponseWriter, r *http.Request) {
writeRuleEditorError(w, err)
return
}
writeJSON(w, http.StatusOK, row)
adminRuleDeprecationHeaders(w)
writeJSON(w, http.StatusOK, wrapRuleResponse(row))
}
// GET /admin/api/rules/{id}/audit?offset=N&limit=M
@@ -419,3 +511,66 @@ func handleAdminResolveOrphan(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, map[string]string{"status": "resolved"})
}
// Slice B.6 (t-paliad-305) — 301 redirect helpers for the legacy
// /admin/rules* paths. New canonical paths live under
// /admin/procedural-events; the redirects keep external bookmarks,
// audit-log entries, and curl scripts working through one
// deprecation cycle.
//
// Three flavours:
//
// * redirectToProceduralEvents(newPath) — fixed redirect target
// (used by the parameter-less paths /admin/rules and
// /admin/api/rules).
// * redirectToProceduralEventEdit — page path with {id}/edit suffix.
// * redirectToProceduralEventAPI(suffix) — JSON API paths that carry
// an {id} and optional suffix (/clone-as-draft, /publish, …).
//
// All emit 301 Moved Permanently — caches and browsers learn the new
// URL once and stop hitting the legacy path. The IETF Deprecation
// header is added so machine clients see the migration signal
// alongside the redirect.
// redirectToProceduralEvents returns an http.HandlerFunc that 301s to
// the supplied destination path. Query string is preserved.
func redirectToProceduralEvents(dst string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
target := dst
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
w.Header().Set("Deprecation", `true; path="/admin/rules"`)
w.Header().Set("Link", `</admin/procedural-events>; rel="successor-version"`)
http.Redirect(w, r, target, http.StatusMovedPermanently)
}
}
// redirectToProceduralEventEdit 301s GET /admin/rules/{id}/edit →
// /admin/procedural-events/{id}/edit.
func redirectToProceduralEventEdit(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
target := "/admin/procedural-events/" + id + "/edit"
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
w.Header().Set("Deprecation", `true; path="/admin/rules/{id}/edit"`)
w.Header().Set("Link", `</admin/procedural-events/{id}/edit>; rel="successor-version"`)
http.Redirect(w, r, target, http.StatusMovedPermanently)
}
// redirectToProceduralEventAPI 301s /admin/api/rules/{id}[/suffix] →
// /admin/api/procedural-events/{id}[/suffix]. The optional suffix
// covers /clone-as-draft, /publish, /archive, /restore, /audit, /preview.
func redirectToProceduralEventAPI(suffix string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
target := "/admin/api/procedural-events/" + id + suffix
if r.URL.RawQuery != "" {
target += "?" + r.URL.RawQuery
}
w.Header().Set("Deprecation", `true; path="/admin/api/rules/{id}`+suffix+`"`)
w.Header().Set("Link", `</admin/api/procedural-events/{id}`+suffix+`>; rel="successor-version"`)
http.Redirect(w, r, target, http.StatusMovedPermanently)
}
}

View File

@@ -0,0 +1,199 @@
package handlers
import (
"net/http"
"strconv"
"strings"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// t-paliad-346 / m/paliad#153 B3 — universal search for the Litigation
// Builder. Returns events + scenarios + projects (Akten) keyed by type
// so the search dropdown can render typed result groups.
//
// GET /api/builder/search?q=<term>&limit=<n>
//
// Response shape:
//
// {
// "query": "<echoed q>",
// "events": [ EventSearchHit, ... ], // anchor_rule_id + proceeding_type embedded
// "scenarios": [ { id, name, status, updated_at }, ... ],
// "projects": [ { id, title, type, reference, case_number, matter_number, client_number }, ... ],
// "counts": { "events": N, "scenarios": M, "projects": K }
// }
//
// Each group is independently capped (default 8 events / 5 scenarios /
// 5 projects, max 30 per group). Missing services degrade gracefully —
// an unavailable group is returned as an empty array, not an error,
// so a knowledge-only deploy (DATABASE_URL unset) can still serve a
// best-effort empty response shape rather than a 503 wall.
type builderSearchScenarioHit struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
UpdatedAt string `json:"updated_at"`
}
type builderSearchProjectHit struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"`
Title string `json:"title"`
Reference *string `json:"reference,omitempty"`
CaseNumber *string `json:"case_number,omitempty"`
MatterNumber *string `json:"matter_number,omitempty"`
ClientNumber *string `json:"client_number,omitempty"`
}
type builderSearchResponse struct {
Query string `json:"query"`
Events []services.EventSearchHit `json:"events"`
Scenarios []builderSearchScenarioHit `json:"scenarios"`
Projects []builderSearchProjectHit `json:"projects"`
Counts builderSearchCounts `json:"counts"`
}
type builderSearchCounts struct {
Events int `json:"events"`
Scenarios int `json:"scenarios"`
Projects int `json:"projects"`
}
// handleBuilderSearch — GET /api/builder/search?q=<term>&limit=<n>
//
// Auth required. Returns 200 with empty groups when q is empty (matches
// the fristenrechner search ergonomic — frontend can boot without a
// pre-flight round trip).
func handleBuilderSearch(w http.ResponseWriter, r *http.Request) {
uid, ok := requireUser(w, r)
if !ok {
return
}
q := strings.TrimSpace(r.URL.Query().Get("q"))
perGroupLimit := parseBuilderSearchLimit(r.URL.Query().Get("limit"))
resp := builderSearchResponse{
Query: q,
Events: []services.EventSearchHit{},
Scenarios: []builderSearchScenarioHit{},
Projects: []builderSearchProjectHit{},
}
if q == "" {
// Match fristenrechner search: empty query → empty groups, not 400.
writeJSON(w, http.StatusOK, resp)
return
}
ctx := r.Context()
// Events: reuse the SearchEvents shape so anchor_rule_id +
// proceeding_type travel with each hit. UPC v1 (PRD §0.4) — the
// jurisdiction filter pins the corpus the builder serves today.
if dbSvc != nil && dbSvc.deadlineSearch != nil {
eventsResp, err := dbSvc.deadlineSearch.SearchEvents(ctx, q, services.EventSearchOptions{
Jurisdiction: "UPC",
Limit: perGroupLimit.events,
})
if err == nil && eventsResp != nil {
resp.Events = eventsResp.Events
}
}
// Scenarios: caller's own scenarios filtered by ILIKE on name.
// Borrows ListMyScenarios + filters in-memory; the list endpoint
// already caps at the small per-user fan-out and there's no index
// on (owner_id, name) yet — in-memory filter is cheap at 10s-of-
// rows scale.
if dbSvc != nil && dbSvc.scenarioBuilder != nil {
scenarios, err := dbSvc.scenarioBuilder.ListMyScenarios(ctx, uid, "active")
if err == nil {
needle := strings.ToLower(q)
hits := []builderSearchScenarioHit{}
for _, sc := range scenarios {
if !strings.Contains(strings.ToLower(sc.Name), needle) {
continue
}
hits = append(hits, builderSearchScenarioHit{
ID: sc.ID,
Name: sc.Name,
Status: sc.Status,
UpdatedAt: sc.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
})
if len(hits) >= perGroupLimit.scenarios {
break
}
}
resp.Scenarios = hits
}
}
// Projects (Akten): visible projects filtered by trigram/ILIKE on
// title, reference, client_number, matter_number. ProjectService.List
// already applies team-based RLS via visibilityPredicate.
if dbSvc != nil && dbSvc.projects != nil {
projects, err := dbSvc.projects.List(ctx, uid, services.ProjectFilter{
Search: q,
})
if err == nil {
hits := make([]builderSearchProjectHit, 0, len(projects))
for _, p := range projects {
hits = append(hits, builderSearchProjectHit{
ID: p.ID,
Type: p.Type,
Title: p.Title,
Reference: p.Reference,
CaseNumber: p.CaseNumber,
MatterNumber: p.MatterNumber,
ClientNumber: p.ClientNumber,
})
if len(hits) >= perGroupLimit.projects {
break
}
}
resp.Projects = hits
}
}
resp.Counts = builderSearchCounts{
Events: len(resp.Events),
Scenarios: len(resp.Scenarios),
Projects: len(resp.Projects),
}
writeJSON(w, http.StatusOK, resp)
}
type builderSearchPerGroup struct {
events int
scenarios int
projects int
}
// parseBuilderSearchLimit reads ?limit=<n> as a hint for the events
// group (largest expected hit count). Scenarios + projects use smaller
// caps because their drop-down rows are visually heavier. The shared
// caller-supplied bound is interpreted as the events cap; scenarios
// and projects are derived from it.
func parseBuilderSearchLimit(raw string) builderSearchPerGroup {
def := builderSearchPerGroup{events: 8, scenarios: 5, projects: 5}
if raw == "" {
return def
}
n, err := strconv.Atoi(raw)
if err != nil || n <= 0 {
return def
}
if n > 30 {
n = 30
}
return builderSearchPerGroup{
events: n,
scenarios: max(1, n/2),
projects: max(1, n/2),
}
}

View File

@@ -3,6 +3,7 @@ package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -12,6 +13,7 @@ import (
"time"
"mgit.msbls.de/m/paliad/internal/branding"
"mgit.msbls.de/m/paliad/internal/services"
)
const (
@@ -111,8 +113,34 @@ var fileRegistry = map[string]fileEntry{
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/_skeleton.en.docx",
},
// t-paliad-317 Composer Slice E — specialist firm-agnostic bases.
// Both live under Composer/ (not under HLC/) so a future non-HLC
// deployment serves the same cross-firm files. Body = anchor-only
// per Slice B; styles.xml carries the preset's typography.
composerBaseLGDuesseldorfSlug: {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx",
DownloadName: "LG-Düsseldorf Stil.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/Composer/lg-duesseldorf.docx",
},
composerBaseUPCFormalSlug: {
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/Composer/upc-formal.docx",
DownloadName: "UPC formal.docx",
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
RepoOwner: "m",
RepoName: "mWorkRepo",
FilePath: "6 - material/Templates/Word/Paliad/Composer/upc-formal.docx",
},
}
// t-paliad-317 Composer Slice E — slugs for the new specialist bases.
const (
composerBaseLGDuesseldorfSlug = "submission/composer/lg-duesseldorf.docx"
composerBaseUPCFormalSlug = "submission/composer/upc-formal.docx"
)
// skeletonSubmissionSlug names the universal skeleton template inside
// the shared fileRegistry cache. Exported via a const so handler code
// (resolveSubmissionTemplate, hlPatentsStyleSHA's sibling) refers to
@@ -402,6 +430,37 @@ func fetchFirmSkeletonBytes(ctx context.Context) ([]byte, string, error) {
return fetchSubmissionTemplateSlug(ctx, firmSkeletonSubmissionSlug)
}
// composerBaseSlugMap routes a Composer base.slug to the existing
// fileRegistry slug whose Gitea object backs it (t-paliad-313 Slice B).
// Slice A seeded two bases that already share .docx files with the v1
// fallback chain — no new Gitea uploads needed for those. Future bases
// (e.g. lg-duesseldorf, upc-formal in Slice E) register their own
// fileRegistry entries via the same shape and add a row here.
var composerBaseSlugMap = map[string]string{
"hlc-letterhead": firmSkeletonSubmissionSlug,
"neutral": skeletonSubmissionSlug,
"lg-duesseldorf": composerBaseLGDuesseldorfSlug,
"upc-formal": composerBaseUPCFormalSlug,
}
// fetchComposerBaseBytes returns the .docx bytes for a Composer base,
// pulled from the shared Gitea proxy cache. ErrComposerBaseNotProxied
// when the slug has no registered fileRegistry entry — a base authored
// without a file-registry mapping (rare; admin oversight) renders as
// "Vorlagenbasis nicht erreichbar" upstream of this call.
var ErrComposerBaseNotProxied = errors.New("composer base: Gitea slug not registered")
func fetchComposerBaseBytes(ctx context.Context, base *services.SubmissionBase) ([]byte, string, error) {
if base == nil {
return nil, "", fmt.Errorf("composer base: nil base")
}
slug, ok := composerBaseSlugMap[base.Slug]
if !ok {
return nil, "", fmt.Errorf("%w: base slug %q", ErrComposerBaseNotProxied, base.Slug)
}
return fetchSubmissionTemplateSlug(ctx, slug)
}
// fetchSubmissionTemplateSlug is the shared cache-aware fetcher used by
// the firm-skeleton and universal-skeleton accessors. Factored out so
// the two paths can't drift apart on caching semantics.

View File

@@ -4,6 +4,7 @@ import (
"encoding/json"
"errors"
"net/http"
"strings"
"github.com/google/uuid"
@@ -11,28 +12,41 @@ import (
"mgit.msbls.de/m/paliad/internal/services"
)
// Fristenrechner page handler: serves the static HTML. No DB dependency.
//
// Back-compat: the pre-split sidebar entry for "Verfahrensablauf" pointed at
// /tools/fristenrechner?path=a. After the t-paliad-179 split, that landing is
// owned by /tools/verfahrensablauf. A naked ?path=a (no Akte context — i.e.
// no ?project=) is the bookmarked-legacy-entry case → 302 to the new route.
// ?project=<uuid>&path=a is the Akte-mode internal wizard pathway and stays
// on /tools/fristenrechner so the wizard state survives a refresh.
func handleFristenrechnerPage(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
if q.Get("path") == "a" && q.Get("project") == "" {
http.Redirect(w, r, "/tools/verfahrensablauf", http.StatusFound)
return
// U4 (m/paliad#151) — legacy /tools/fristenrechner and
// /tools/verfahrensablauf folded into /tools/procedures via hard 301
// redirects. Per m's Q11 divergence in the design (no 2-week dual-ship
// window), bookmarks resolve via Location preservation of query params;
// no `?legacy=1` escape, no in-product affordance points back at the
// retired URLs after the merge.
func redirectToProcedures(w http.ResponseWriter, r *http.Request) {
loc := "/tools/procedures"
if raw := r.URL.RawQuery; raw != "" {
loc += "?" + raw
}
http.ServeFile(w, r, "dist/fristenrechner.html")
http.Redirect(w, r, loc, http.StatusMovedPermanently)
}
// Verfahrensablauf page handler (t-paliad-179 Slice 1): the dedicated
// abstract-browse surface for procedural shape. No DB dependency — the page
// shell is static HTML; the calculator API still drives the timeline render.
// handleFristenrechnerPage — kept as a registration name for the legacy
// URL so bookmarks (and the existing Sidebar history a former user may
// have cached) keep resolving. 301s to /tools/procedures.
func handleFristenrechnerPage(w http.ResponseWriter, r *http.Request) {
redirectToProcedures(w, r)
}
// handleVerfahrensablaufPage — symmetrical 301 to /tools/procedures.
func handleVerfahrensablaufPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/verfahrensablauf.html")
redirectToProcedures(w, r)
}
// Unified procedural-events tool page (m/paliad#151, design
// docs/design-unified-procedural-events-tool-2026-05-27.md). Consolidates
// Fristenrechner Mode A + Mode B + result + Verfahrensablauf into a
// single surface at /tools/procedures. No DB dependency — the page
// itself is static HTML; per-tab data flows over the existing
// /api/tools/fristenrechner/* endpoints.
func handleProceduresPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/procedures.html")
}
// POST /api/tools/fristenrechner — calculate the UI timeline for a proceeding.
@@ -77,6 +91,19 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
// slugs are silently dropped (no filter) so a stale frontend
// chip doesn't 400 the request.
AppealTarget string `json:"appealTarget,omitempty"`
// t-paliad-348 / yoUPC#178 — surface the engine's two new
// CalcOptions axes to the HTTP boundary:
//
// IncludeOptional: when true, priority='optional' rules
// surface on the timeline. Default false matches the
// engine's default (mandatory backbone only).
// TriggerEventAnchors: per-event-code anchor dates the
// engine consults for rules carrying trigger_event_id.
// When a rule's anchor is absent the engine renders the
// rule as IsConditional rather than fabricating a date
// off the proceeding's trigger date.
IncludeOptional bool `json:"includeOptional,omitempty"`
TriggerEventAnchors map[string]string `json:"triggerEventAnchors,omitempty"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
@@ -116,15 +143,17 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
}
resp, err := dbSvc.fristenrechner.Calculate(r.Context(), req.ProceedingType, req.TriggerDate, services.CalcOptions{
PriorityDateStr: req.PriorityDate,
Flags: req.Flags,
AnchorOverrides: req.AnchorOverrides,
CourtID: req.CourtID,
PerCardAppellant: addendum.PerCardAppellant,
SkipRules: addendum.SkipRules,
IncludeCCRFor: addendum.IncludeCCRFor,
IncludeHidden: req.IncludeHidden,
AppealTarget: req.AppealTarget,
PriorityDateStr: req.PriorityDate,
Flags: req.Flags,
AnchorOverrides: req.AnchorOverrides,
CourtID: req.CourtID,
PerCardAppellant: addendum.PerCardAppellant,
SkipRules: addendum.SkipRules,
IncludeCCRFor: addendum.IncludeCCRFor,
IncludeHidden: req.IncludeHidden,
AppealTarget: req.AppealTarget,
IncludeOptional: req.IncludeOptional,
TriggerEventAnchors: req.TriggerEventAnchors,
})
if err != nil {
if errors.Is(err, services.ErrUnknownProceedingType) {
@@ -204,6 +233,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 +249,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
@@ -238,7 +281,26 @@ func handleTriggerEventsList(w http.ResponseWriter, r *http.Request) {
// POST /api/tools/event-deadlines — compute all deadlines flowing from a
// trigger event + date. Body: {"triggerEventId": <int>, "triggerDate": "YYYY-MM-DD"}.
//
// DEPRECATED (m/paliad#149 Phase 2 P4 partial, t-paliad-331). This route
// serves the 73 orphan globals (sequencing_rules with proceeding_type_id
// IS NULL, addressed only via trigger_event_id). The route is held live
// until those 73 are reparented onto real proceeding-type chains via
// /admin/procedural-events (editorial work; tracked separately).
//
// Once the orphan count hits zero, the planned final-P4 lands:
// - DROP TABLE paliad.trigger_events
// - ALTER TABLE paliad.sequencing_rules DROP COLUMN trigger_event_id
// - remove this handler + EventDeadlineService + the 5 read sites
// enumerated in the design (deadline_rule_service.go:226,
// event_deadline_service.go:79+244, event_type_service.go:40+414,
// export_service.go:1680, cmd/gen-upc-snapshot/main.go:185-202).
//
// The Deprecation + Sunset response headers below let callers see the
// signal without breaking — see RFC 8594 / RFC 9745.
func handleEventDeadlinesCalculate(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Deprecation", "true")
w.Header().Set("Link", `<https://mgit.msbls.de/m/paliad/issues/149>; rel="deprecation"; type="text/html"`)
if dbSvc == nil || dbSvc.eventDeadline == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Fristenrechner ist vorübergehend nicht verfügbar (keine Datenbank).",

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

@@ -116,6 +116,18 @@ type Services struct {
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
SubmissionDraft *services.SubmissionDraftService
// t-paliad-313 (m/paliad#141) Composer Slice A + B — base catalog,
// per-draft section rows, render-pipeline assembler. All three
// nil in DATABASE_URL-less deploys (the Composer surfaces return
// 503 / hide the picker).
SubmissionBase *services.BaseService
SubmissionSection *services.SectionService
SubmissionComposer *services.SubmissionComposer
// t-paliad-315 Composer Slice C — building-block library + admin
// editor. Per Q2: paste sources only, no lineage on sections.
SubmissionBuildingBlock *services.BuildingBlockService
// t-paliad-265 / m/paliad#96 — per-event-card optional choices on
// the Verfahrensablauf timeline.
EventChoice *services.EventChoiceService
@@ -125,6 +137,17 @@ type Services struct {
// unset; the /api/scenarios routes return 503 in that case.
Scenario *services.ScenarioService
// m/paliad#149 Phase 2 P0 — per-project scenario_flags SSoT (mig 154).
// Drives Verfahrensablauf + Mode B result-view conditional rendering
// and per-rule selection state (`rule:<uuid>` keys).
ScenarioFlags *services.ScenarioFlagsService
// t-paliad-340 / m/paliad#153 B0 — Litigation Builder. CRUD over the
// new normalised scenario shape (paliad.scenarios with owner_id +
// scenario_proceedings + scenario_events + scenario_shares, mig 157).
// Nil when DATABASE_URL is unset — /api/builder/scenarios* routes 503.
ScenarioBuilder *services.ScenarioBuilderService
// Paliadin is wired when DATABASE_URL is set. The concrete backend
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
// (remote → mRiver via SSH) or local tmux availability. Stays nil
@@ -187,9 +210,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
projection: svc.Projection,
export: svc.Export,
backup: svc.Backup,
submissionDraft: svc.SubmissionDraft,
eventChoice: svc.EventChoice,
scenario: svc.Scenario,
submissionDraft: svc.SubmissionDraft,
submissionBase: svc.SubmissionBase,
submissionSection: svc.SubmissionSection,
submissionComposer: svc.SubmissionComposer,
submissionBuildingBlock: svc.SubmissionBuildingBlock,
eventChoice: svc.EventChoice,
scenario: svc.Scenario,
scenarioFlags: svc.ScenarioFlags,
scenarioBuilder: svc.ScenarioBuilder,
}
}
@@ -283,6 +312,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/tools/kostenrechner", handleKostenrechnerAPI)
protected.HandleFunc("GET /tools/fristenrechner", handleFristenrechnerPage)
protected.HandleFunc("GET /tools/verfahrensablauf", handleVerfahrensablaufPage)
protected.HandleFunc("GET /tools/procedures", handleProceduresPage)
protected.HandleFunc("POST /api/tools/fristenrechner", handleFristenrechnerAPI)
protected.HandleFunc("POST /api/tools/fristenrechner/calculate-rule", handleFristenrechnerCalculateRule)
protected.HandleFunc("GET /api/tools/proceeding-types", handleProceedingTypes)
@@ -291,7 +321,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)
@@ -359,6 +396,12 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("PATCH /api/projects/{id}", handleUpdateProject)
protected.HandleFunc("DELETE /api/projects/{id}", handleDeleteProject)
protected.HandleFunc("GET /api/projects/{id}/events", handleListProjectEvents)
// m/paliad#149 Phase 2 P0 — per-project scenario_flags SSoT (mig 154).
// Verfahrensablauf + Mode B result-view bind their conditional
// checkboxes here; P3 will add per-rule "rule:<uuid>" selection entries
// on top of the same endpoint.
protected.HandleFunc("GET /api/projects/{id}/scenario-flags", handleGetScenarioFlags)
protected.HandleFunc("PATCH /api/projects/{id}/scenario-flags", handlePatchScenarioFlags)
// t-paliad-171 / t-paliad-173 — SmartTimeline (Verlauf-tab redesign).
// /timeline returns the merged timeline (actuals + Slice 2 projections).
// /timeline/milestone is the "Eigener Meilenstein" write path.
@@ -408,6 +451,23 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}", handleGlobalPatchSubmissionDraft)
protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}", handleGlobalDeleteSubmissionDraft)
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/export", handleGlobalExportSubmissionDraft)
// t-paliad-313 (m/paliad#141) Composer Slice A — base catalog for
// the sidebar picker. Wide-open SELECT (any authenticated user);
// admin mutations are not exposed yet (Slice C).
protected.HandleFunc("GET /api/submission-bases", handleListSubmissionBases)
// t-paliad-313 (m/paliad#141) Composer Slice B — per-section PATCH
// 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)
protected.HandleFunc("POST /api/submission-building-blocks/{block_id}/insert-into/{section_id}", handleInsertBlockIntoSection)
// t-paliad-277 / m/paliad#109 — refresh project-derived variables on
// the draft. Strips overrides for project.* / parties.* / deadline.*
// / procedural_event.* / rule.* prefixes and bumps last_imported_at.
@@ -461,6 +521,30 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("DELETE /api/scenarios/{id}", handleScenarioDelete)
protected.HandleFunc("PUT /api/projects/{id}/active-scenario", handleSetActiveScenario)
// t-paliad-340 / m/paliad#153 B0 — Litigation Builder API over the
// new normalised scenario shape (mig 157). Coexists with the legacy
// /api/scenarios surface during the B0→B6 migration; B6 cleanup
// retires the legacy routes.
protected.HandleFunc("GET /api/builder/scenarios", handleBuilderScenariosList)
protected.HandleFunc("POST /api/builder/scenarios", handleBuilderScenarioCreate)
protected.HandleFunc("GET /api/builder/scenarios/{id}", handleBuilderScenarioGet)
protected.HandleFunc("PATCH /api/builder/scenarios/{id}", handleBuilderScenarioPatch)
protected.HandleFunc("POST /api/builder/scenarios/{id}/proceedings", handleBuilderProceedingCreate)
protected.HandleFunc("PATCH /api/builder/scenarios/{id}/proceedings/{pid}", handleBuilderProceedingPatch)
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/proceedings/{pid}", handleBuilderProceedingDelete)
protected.HandleFunc("POST /api/builder/scenarios/{id}/proceedings/{pid}/events", handleBuilderEventCreate)
protected.HandleFunc("PATCH /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventPatch)
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventDelete)
protected.HandleFunc("POST /api/builder/scenarios/{id}/shares", handleBuilderShareCreate)
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/shares/{sid}", handleBuilderShareDelete)
// m/paliad#153 B2 — read-only passthrough so the builder can render
// per-triplet flag toggles without a per-project round-trip.
protected.HandleFunc("GET /api/builder/scenario-flag-catalog", handleBuilderFlagCatalog)
// m/paliad#153 B3 — universal search (events + scenarios + projects).
protected.HandleFunc("GET /api/builder/search", handleBuilderSearch)
// Dev-only test route — gated to PaliadinOwnerEmail (m).
protected.HandleFunc("GET /dev/scenario-builder", handleBuilderDevTestPage)
// Partner units (structural partner-led units; legacy "Dezernate").
protected.HandleFunc("GET /api/partner-units", handleListPartnerUnits)
protected.HandleFunc("POST /api/partner-units", handleCreatePartnerUnit)
@@ -672,6 +756,16 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("DELETE /api/admin/firm-dashboard-default", adminGate(users, handleDeleteFirmDashboardDefault))
protected.HandleFunc("POST /api/me/dashboard-layout/promote", adminGate(users, handlePromoteDashboardLayoutToFirmDefault))
// t-paliad-315 (m/paliad#141) Composer Slice C — admin building blocks editor.
protected.HandleFunc("GET /admin/submission-building-blocks", adminGate(users, gateOnboarded(handleAdminBuildingBlocksPage)))
protected.HandleFunc("GET /api/admin/submission-building-blocks", adminGate(users, handleAdminListBuildingBlocks))
protected.HandleFunc("POST /api/admin/submission-building-blocks", adminGate(users, handleAdminCreateBuildingBlock))
protected.HandleFunc("GET /api/admin/submission-building-blocks/{block_id}", adminGate(users, handleAdminGetBuildingBlock))
protected.HandleFunc("PATCH /api/admin/submission-building-blocks/{block_id}", adminGate(users, handleAdminUpdateBuildingBlock))
protected.HandleFunc("DELETE /api/admin/submission-building-blocks/{block_id}", adminGate(users, handleAdminDeleteBuildingBlock))
protected.HandleFunc("GET /api/admin/submission-building-blocks/{block_id}/versions", adminGate(users, handleAdminListBuildingBlockVersions))
protected.HandleFunc("POST /api/admin/submission-building-blocks/{block_id}/restore/{version_id}", adminGate(users, handleAdminRestoreBuildingBlockVersion))
protected.HandleFunc("GET /api/admin/email-templates", adminGate(users, handleAdminListEmailTemplates))
protected.HandleFunc("GET /api/admin/email-templates/{key}/variables", adminGate(users, handleAdminEmailTemplateVariables))
protected.HandleFunc("GET /api/admin/email-templates/{key}/{lang}", adminGate(users, handleAdminGetEmailTemplate))
@@ -684,18 +778,43 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// t-paliad-089 — admin Event-Type moderation panel.
// t-paliad-191 Slice 11a — admin rule-editor API.
// t-paliad-192 Slice 11b — admin rule-editor UI pages + orphan list/resolve.
protected.HandleFunc("GET /admin/rules", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
protected.HandleFunc("GET /admin/api/rules", adminGate(users, handleAdminListRules))
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, handleAdminGetRule))
protected.HandleFunc("POST /admin/api/rules", adminGate(users, handleAdminCreateRule))
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, handleAdminPatchRule))
protected.HandleFunc("POST /admin/api/rules/{id}/clone-as-draft", adminGate(users, handleAdminCloneAsDraft))
protected.HandleFunc("POST /admin/api/rules/{id}/publish", adminGate(users, handleAdminPublishRule))
protected.HandleFunc("POST /admin/api/rules/{id}/archive", adminGate(users, handleAdminArchiveRule))
protected.HandleFunc("POST /admin/api/rules/{id}/restore", adminGate(users, handleAdminRestoreRule))
protected.HandleFunc("GET /admin/api/rules/{id}/audit", adminGate(users, handleAdminGetRuleAudit))
protected.HandleFunc("GET /admin/api/rules/{id}/preview", adminGate(users, handleAdminPreviewRule))
// Slice B.6 (t-paliad-305) — canonical URL paths under
// /admin/procedural-events with 301 redirects from the legacy
// /admin/rules paths so existing bookmarks and audit-log
// entries continue to resolve. New paths point at the same
// handlers; the canonical-URL name aligns with the umbrella
// term locked in Slice A.
protected.HandleFunc("GET /admin/procedural-events", adminGate(users, gateOnboarded(handleAdminRulesListPage)))
protected.HandleFunc("GET /admin/procedural-events/{id}/edit", adminGate(users, gateOnboarded(handleAdminRulesEditPage)))
protected.HandleFunc("GET /admin/api/procedural-events", adminGate(users, handleAdminListRules))
protected.HandleFunc("GET /admin/api/procedural-events/{id}", adminGate(users, handleAdminGetRule))
protected.HandleFunc("POST /admin/api/procedural-events", adminGate(users, handleAdminCreateRule))
protected.HandleFunc("PATCH /admin/api/procedural-events/{id}", adminGate(users, handleAdminPatchRule))
protected.HandleFunc("POST /admin/api/procedural-events/{id}/clone-as-draft", adminGate(users, handleAdminCloneAsDraft))
protected.HandleFunc("POST /admin/api/procedural-events/{id}/publish", adminGate(users, handleAdminPublishRule))
protected.HandleFunc("POST /admin/api/procedural-events/{id}/archive", adminGate(users, handleAdminArchiveRule))
protected.HandleFunc("POST /admin/api/procedural-events/{id}/restore", adminGate(users, handleAdminRestoreRule))
protected.HandleFunc("GET /admin/api/procedural-events/{id}/audit", adminGate(users, handleAdminGetRuleAudit))
protected.HandleFunc("GET /admin/api/procedural-events/{id}/preview", adminGate(users, handleAdminPreviewRule))
// Legacy /admin/rules paths — 301 redirect to the canonical
// /admin/procedural-events paths. One-slice deprecation window
// per design §8.2 (B.6 optional; m authorised the rename
// 2026-05-26). After the next slice that audits external
// references, these can be retired entirely.
protected.HandleFunc("GET /admin/rules", adminGate(users, redirectToProceduralEvents("/admin/procedural-events")))
protected.HandleFunc("GET /admin/rules/{id}/edit", adminGate(users, redirectToProceduralEventEdit))
protected.HandleFunc("GET /admin/api/rules", adminGate(users, redirectToProceduralEvents("/admin/api/procedural-events")))
protected.HandleFunc("GET /admin/api/rules/{id}", adminGate(users, redirectToProceduralEventAPI("")))
protected.HandleFunc("POST /admin/api/rules", adminGate(users, redirectToProceduralEvents("/admin/api/procedural-events")))
protected.HandleFunc("PATCH /admin/api/rules/{id}", adminGate(users, redirectToProceduralEventAPI("")))
protected.HandleFunc("POST /admin/api/rules/{id}/clone-as-draft", adminGate(users, redirectToProceduralEventAPI("/clone-as-draft")))
protected.HandleFunc("POST /admin/api/rules/{id}/publish", adminGate(users, redirectToProceduralEventAPI("/publish")))
protected.HandleFunc("POST /admin/api/rules/{id}/archive", adminGate(users, redirectToProceduralEventAPI("/archive")))
protected.HandleFunc("POST /admin/api/rules/{id}/restore", adminGate(users, redirectToProceduralEventAPI("/restore")))
protected.HandleFunc("GET /admin/api/rules/{id}/audit", adminGate(users, redirectToProceduralEventAPI("/audit")))
protected.HandleFunc("GET /admin/api/rules/{id}/preview", adminGate(users, redirectToProceduralEventAPI("/preview")))
protected.HandleFunc("GET /admin/api/orphans", adminGate(users, handleAdminListOrphans))
protected.HandleFunc("POST /admin/api/orphans/{id}/resolve", adminGate(users, handleAdminResolveOrphan))

View File

@@ -24,6 +24,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"strconv"
"strings"
@@ -360,6 +361,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
convID = ev.ConversationID
case services.StreamError:
errorEmitted = true
log.Printf("paliadin: stream error turn=%s code=%s retryable=%v message=%q",
turnID, ev.Code, ev.Retryable, ev.Message)
send(ch, turnEvent{
Kind: "error",
Data: map[string]any{
@@ -372,6 +375,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
case <-silenceTicker.C:
elapsed := time.Since(lastEventAt)
if elapsed >= silenceTimeout {
log.Printf("paliadin: silence timeout turn=%s elapsed=%s (silenceTimeout=%s)",
turnID, elapsed, silenceTimeout)
send(ch, turnEvent{
Kind: "error",
Data: map[string]any{
@@ -419,6 +424,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
}
}
if res.err != nil {
log.Printf("paliadin: backend returned error turn=%s err=%v errorEmittedAlready=%v",
turnID, res.err, errorEmitted)
if !errorEmitted {
send(ch, turnEvent{
Kind: "error",
@@ -432,6 +439,8 @@ func runStreamingTurn(turnID uuid.UUID, req services.TurnRequest, ch chan<- turn
}
result := res.result
if result == nil {
log.Printf("paliadin: backend returned nil result without error turn=%s errorEmittedAlready=%v",
turnID, errorEmitted)
// Shouldn't happen — backend contract returns either err
// or a result. Defensive bail.
if !errorEmitted {

View File

@@ -69,11 +69,27 @@ type dbServices struct {
// t-paliad-238 — submission draft editor.
submissionDraft *services.SubmissionDraftService
// t-paliad-313 — Composer base catalog + per-draft sections +
// (Slice B) the render pipeline assembling base + sections into a
// final .docx + (Slice C) building-block library.
submissionBase *services.BaseService
submissionSection *services.SectionService
submissionComposer *services.SubmissionComposer
submissionBuildingBlock *services.BuildingBlockService
// t-paliad-265 — per-event-card optional choices.
eventChoice *services.EventChoiceService
// Slice D — named scenario compositions (m/paliad#124 §5).
scenario *services.ScenarioService
// m/paliad#149 Phase 2 P0 — per-project scenario_flags SSoT (mig 154).
scenarioFlags *services.ScenarioFlagsService
// t-paliad-340 / m/paliad#153 B0 — Litigation Builder over the new
// normalised scenario shape (paliad.scenarios with owner_id +
// scenario_proceedings + scenario_events + scenario_shares, mig 157).
scenarioBuilder *services.ScenarioBuilderService
}
var dbSvc *dbServices

View File

@@ -0,0 +1,627 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// t-paliad-340 / m/paliad#153 B0 — REST endpoints over the new normalised
// scenario builder shape (paliad.scenarios with owner_id, +
// paliad.scenario_proceedings / scenario_events / scenario_shares).
//
// Endpoints live under /api/builder/scenarios/* to avoid clashing with
// the legacy /api/scenarios/* endpoints from m/paliad#124 Slice D. The
// B6 cleanup slice retires the legacy surface; until then both shapes
// coexist on the same paliad.scenarios table (the legacy paths require
// project_id IS NOT NULL OR an abstract created_by = caller; the builder
// paths require owner_id = caller).
//
// All handlers gate by requireScenarioBuilderService — 503 when the
// service is nil (DATABASE_URL unset). Auth is checked via requireUser;
// per-row visibility is enforced inside the service.
func requireScenarioBuilderService(w http.ResponseWriter) bool {
if dbSvc == nil || dbSvc.scenarioBuilder == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Litigation-Builder ist vorübergehend nicht verfügbar (keine Datenbank).",
})
return false
}
return true
}
// scenarioBuilderErrorToStatus maps service errors to HTTP statuses.
func scenarioBuilderErrorToStatus(err error) (int, string) {
switch {
case errors.Is(err, services.ErrScenarioBuilderNotVisible),
errors.Is(err, services.ErrNotVisible):
return http.StatusNotFound, "Szenario nicht gefunden"
case errors.Is(err, services.ErrInvalidInput):
return http.StatusBadRequest, err.Error()
}
return http.StatusInternalServerError, err.Error()
}
func writeBuilderError(w http.ResponseWriter, err error) {
status, msg := scenarioBuilderErrorToStatus(err)
writeJSON(w, status, map[string]string{"error": msg})
}
// ---------------------------------------------------------------------------
// Scenario CRUD
// ---------------------------------------------------------------------------
// handleBuilderScenariosList — GET /api/builder/scenarios?status=<active|archived|promoted|all>
func handleBuilderScenariosList(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
status := r.URL.Query().Get("status")
out, err := dbSvc.scenarioBuilder.ListMyScenarios(r.Context(), uid, status)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderScenarioCreate — POST /api/builder/scenarios
func handleBuilderScenarioCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
var input services.CreateBuilderScenarioInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.CreateScenario(r.Context(), uid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderScenarioGet — GET /api/builder/scenarios/{id}
func handleBuilderScenarioGet(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
out, err := dbSvc.scenarioBuilder.GetScenarioDeep(r.Context(), uid, id)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderScenarioPatch — PATCH /api/builder/scenarios/{id}
func handleBuilderScenarioPatch(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige ID"})
return
}
var input services.PatchBuilderScenarioInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.PatchScenario(r.Context(), uid, id, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// ---------------------------------------------------------------------------
// Proceedings
// ---------------------------------------------------------------------------
// handleBuilderProceedingCreate — POST /api/builder/scenarios/{id}/proceedings
func handleBuilderProceedingCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
var input services.AddProceedingInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.AddProceeding(r.Context(), uid, sid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderProceedingPatch — PATCH /api/builder/scenarios/{id}/proceedings/{pid}
func handleBuilderProceedingPatch(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
pid, err := uuid.Parse(r.PathValue("pid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
return
}
var input services.PatchProceedingInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.PatchProceeding(r.Context(), uid, sid, pid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderProceedingDelete — DELETE /api/builder/scenarios/{id}/proceedings/{pid}
func handleBuilderProceedingDelete(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
pid, err := uuid.Parse(r.PathValue("pid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
return
}
if err := dbSvc.scenarioBuilder.DeleteProceeding(r.Context(), uid, sid, pid); err != nil {
writeBuilderError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Events
// ---------------------------------------------------------------------------
// handleBuilderEventCreate — POST /api/builder/scenarios/{id}/proceedings/{pid}/events
func handleBuilderEventCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
pid, err := uuid.Parse(r.PathValue("pid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Proceeding-ID"})
return
}
var input services.AddEventInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.AddEvent(r.Context(), uid, sid, pid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderEventPatch — PATCH /api/builder/scenarios/{id}/events/{eid}
func handleBuilderEventPatch(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
eid, err := uuid.Parse(r.PathValue("eid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Event-ID"})
return
}
var input services.PatchEventInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.PatchEvent(r.Context(), uid, sid, eid, input)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusOK, out)
}
// handleBuilderEventDelete — DELETE /api/builder/scenarios/{id}/events/{eid}
func handleBuilderEventDelete(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
eid, err := uuid.Parse(r.PathValue("eid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Event-ID"})
return
}
if err := dbSvc.scenarioBuilder.DeleteEvent(r.Context(), uid, sid, eid); err != nil {
writeBuilderError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Shares
// ---------------------------------------------------------------------------
// handleBuilderShareCreate — POST /api/builder/scenarios/{id}/shares
// Body: {"shared_with_user_id": "<uuid>"}
func handleBuilderShareCreate(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
sid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
var body struct {
SharedWithUserID uuid.UUID `json:"shared_with_user_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
return
}
out, err := dbSvc.scenarioBuilder.AddShare(r.Context(), uid, sid, body.SharedWithUserID)
if err != nil {
writeBuilderError(w, err)
return
}
writeJSON(w, http.StatusCreated, out)
}
// handleBuilderShareDelete — DELETE /api/builder/scenarios/{id}/shares/{sid}
func handleBuilderShareDelete(w http.ResponseWriter, r *http.Request) {
if !requireScenarioBuilderService(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
scid, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
return
}
shid, err := uuid.Parse(r.PathValue("sid"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Share-ID"})
return
}
if err := dbSvc.scenarioBuilder.DeleteShare(r.Context(), uid, scid, shid); err != nil {
writeBuilderError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Scenario flag catalog passthrough (m/paliad#153 B2)
// ---------------------------------------------------------------------------
// handleBuilderFlagCatalog — GET /api/builder/scenario-flag-catalog
//
// Returns every row of paliad.scenario_flag_catalog so the Litigation
// Builder can render per-triplet flag toggles without a per-project
// round-trip. The catalog itself is global (no jurisdiction or
// proceeding scope baked into the table); which flags actually apply
// to a given proceeding type is decided by the calc engine via
// condition_expr at calculation time. The client renders every catalog
// flag and lets the user toggle them — flags with no effect on the
// active proceeding's rules simply have no condition_expr referencing
// them, so toggling is a no-op.
//
// 503 when ScenarioFlagsService is nil (DATABASE_URL unset); per-row
// visibility checks aren't needed because the catalog is global.
func handleBuilderFlagCatalog(w http.ResponseWriter, r *http.Request) {
if dbSvc == nil || dbSvc.scenarioFlags == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "Flag-Katalog vorübergehend nicht verfügbar (keine Datenbank).",
})
return
}
if _, ok := requireUser(w, r); !ok {
return
}
out, err := dbSvc.scenarioFlags.ListCatalog(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{
"error": "Flag-Katalog konnte nicht geladen werden",
})
return
}
writeJSON(w, http.StatusOK, out)
}
// ---------------------------------------------------------------------------
// Dev-only test route
// ---------------------------------------------------------------------------
// handleBuilderDevTestPage — GET /dev/scenario-builder
//
// Gated to services.PaliadinOwnerEmail (the same single-owner gate the
// /paliadin route uses). Every other authenticated user gets 404. Pure
// HTML — no JS bundle — so the page works even before B1 wires the real
// builder shell. Renders curl-equivalent forms for the B0 surface so the
// schema can be exercised end-to-end without Postman / shell scripts.
//
// This is the "dev-only test route" the head's task spec asked for. It
// disappears in B6 cleanup once the production builder UI ships at
// /tools/procedures.
func handleBuilderDevTestPage(w http.ResponseWriter, r *http.Request) {
if !requirePaliadinOwner(w, r) {
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(builderDevTestHTML))
}
const builderDevTestHTML = `<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Scenario Builder — Dev Test (B0)</title>
<style>
body { font-family: ui-monospace, Menlo, monospace; max-width: 880px; margin: 2em auto;
padding: 0 1em; color: #222; background: #fafaf7; }
h1, h2 { font-family: ui-sans-serif, system-ui, sans-serif; }
h1 { border-bottom: 4px solid #c6f41c; padding-bottom: .2em; }
section { background: #fff; border: 1px solid #ddd; border-radius: 4px;
padding: 1em 1.2em; margin: 1em 0; }
label { display: block; margin: .4em 0 .15em; font-size: .85em; color: #555; }
input, textarea, select, button { font: inherit; padding: .35em .5em; box-sizing: border-box; }
input[type="text"], input[type="number"], textarea { width: 100%; }
button { background: #c6f41c; border: 1px solid #9ec61f; cursor: pointer;
padding: .4em 1em; border-radius: 3px; margin: .2em 0; }
button.secondary { background: #eee; border-color: #ccc; }
pre.out { background: #1e1e1e; color: #e6e6e6; padding: .8em 1em; border-radius: 4px;
overflow: auto; max-height: 30em; font-size: .85em; }
.note { color: #777; font-size: .9em; }
.row { display: flex; gap: .5em; }
.row > * { flex: 1; }
</style>
</head>
<body>
<h1>Scenario Builder — Dev Test (B0)</h1>
<p class="note">t-paliad-340 / m/paliad#153 — DB-only slice. Exercises
paliad.scenarios (builder rows), scenario_proceedings, scenario_events,
scenario_shares via /api/builder/scenarios/*. Gated to PaliadinOwnerEmail.</p>
<section>
<h2>1. Liste meine Szenarien</h2>
<label>Status filter</label>
<select id="list-status">
<option value="">(default: alle)</option>
<option value="active">active</option>
<option value="archived">archived</option>
<option value="promoted">promoted</option>
<option value="all">all (explicit)</option>
</select>
<button onclick="listScenarios()">GET /api/builder/scenarios</button>
<pre class="out" id="list-out"></pre>
</section>
<section>
<h2>2. Szenario anlegen</h2>
<label>Name</label>
<input type="text" id="create-name" placeholder="(leer = Unbenanntes Szenario)">
<label>Notes (optional)</label>
<textarea id="create-notes" rows="2"></textarea>
<button onclick="createScenario()">POST /api/builder/scenarios</button>
<pre class="out" id="create-out"></pre>
</section>
<section>
<h2>3. Szenario abrufen (deep)</h2>
<label>Scenario ID</label>
<input type="text" id="get-id">
<button onclick="getScenario()">GET /api/builder/scenarios/{id}</button>
<pre class="out" id="get-out"></pre>
</section>
<section>
<h2>4. Verfahren hinzufügen</h2>
<label>Scenario ID</label>
<input type="text" id="proc-sid">
<label>proceeding_type_id (integer)</label>
<input type="number" id="proc-pt-id" placeholder="z.B. 7 für upc.inf.cfi">
<label>primary_party</label>
<select id="proc-party">
<option value="">(none)</option>
<option value="claimant">claimant</option>
<option value="defendant">defendant</option>
</select>
<button onclick="addProceeding()">POST .../proceedings</button>
<pre class="out" id="proc-out"></pre>
</section>
<section>
<h2>5. Event-Karte hinzufügen</h2>
<label>Scenario ID</label>
<input type="text" id="ev-sid">
<label>Proceeding ID</label>
<input type="text" id="ev-pid">
<label>custom_label (oder sequencing_rule_id / procedural_event_id)</label>
<input type="text" id="ev-label" placeholder="freitext-Karte">
<label>state</label>
<select id="ev-state">
<option value="planned">planned</option>
<option value="filed">filed</option>
<option value="skipped">skipped</option>
</select>
<button onclick="addEvent()">POST .../proceedings/{pid}/events</button>
<pre class="out" id="ev-out"></pre>
</section>
<section>
<h2>6. Status patchen (archive / restore)</h2>
<label>Scenario ID</label>
<input type="text" id="patch-sid">
<label>new status</label>
<select id="patch-status">
<option value="active">active</option>
<option value="archived">archived</option>
</select>
<button onclick="patchStatus()">PATCH /api/builder/scenarios/{id}</button>
<pre class="out" id="patch-out"></pre>
</section>
<script>
const j = (id, payload) =>
document.getElementById(id).textContent = JSON.stringify(payload, null, 2);
async function call(method, url, body) {
const opts = { method, headers: { 'Content-Type': 'application/json' } };
if (body !== undefined) opts.body = JSON.stringify(body);
const r = await fetch(url, opts);
const text = await r.text();
let parsed = text;
try { parsed = JSON.parse(text); } catch (_) {}
return { status: r.status, body: parsed };
}
async function listScenarios() {
const status = document.getElementById('list-status').value;
const q = status ? '?status=' + encodeURIComponent(status) : '';
j('list-out', await call('GET', '/api/builder/scenarios' + q));
}
async function createScenario() {
const name = document.getElementById('create-name').value;
const notes = document.getElementById('create-notes').value;
const body = {};
if (name) body.name = name;
if (notes) body.notes = notes;
j('create-out', await call('POST', '/api/builder/scenarios', body));
}
async function getScenario() {
const id = document.getElementById('get-id').value.trim();
if (!id) return j('get-out', { error: 'ID erforderlich' });
j('get-out', await call('GET', '/api/builder/scenarios/' + id));
}
async function addProceeding() {
const sid = document.getElementById('proc-sid').value.trim();
const ptID = parseInt(document.getElementById('proc-pt-id').value, 10);
const party = document.getElementById('proc-party').value;
if (!sid || !ptID) return j('proc-out', { error: 'sid + proceeding_type_id erforderlich' });
const body = { proceeding_type_id: ptID };
if (party) body.primary_party = party;
j('proc-out', await call('POST', '/api/builder/scenarios/' + sid + '/proceedings', body));
}
async function addEvent() {
const sid = document.getElementById('ev-sid').value.trim();
const pid = document.getElementById('ev-pid').value.trim();
const label = document.getElementById('ev-label').value.trim();
const state = document.getElementById('ev-state').value;
if (!sid || !pid || !label) return j('ev-out', { error: 'sid + pid + custom_label erforderlich' });
j('ev-out', await call('POST',
'/api/builder/scenarios/' + sid + '/proceedings/' + pid + '/events',
{ custom_label: label, state }));
}
async function patchStatus() {
const sid = document.getElementById('patch-sid').value.trim();
const status = document.getElementById('patch-status').value;
if (!sid) return j('patch-out', { error: 'sid erforderlich' });
j('patch-out', await call('PATCH', '/api/builder/scenarios/' + sid, { status }));
}
</script>
</body>
</html>`

View File

@@ -0,0 +1,85 @@
package handlers
import (
"encoding/json"
"net/http"
"github.com/google/uuid"
)
// GET /api/projects/{id}/scenario-flags returns the project's current
// flag map and the catalog. See ScenarioFlagsService.Get for semantics.
func handleGetScenarioFlags(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
view, err := dbSvc.scenarioFlags.Get(r.Context(), uid, id)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, view)
}
// PATCH /api/projects/{id}/scenario-flags merges a partial delta into
// the project's scenario_flags. Body shape:
//
// { "with_ccr": true, "with_amend": null, "rule:<uuid>": false }
//
// `null` deletes a key from the map so the priority-driven default
// returns; bool values are persisted verbatim.
func handlePatchScenarioFlags(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
id, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
// Decode as map[string]*bool so JSON null cleanly resolves to nil
// (= delete the key) while bool literals stay distinguishable from
// the zero value.
var raw map[string]json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&raw); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return
}
delta := make(map[string]*bool, len(raw))
for k, v := range raw {
if len(v) == 0 || string(v) == "null" {
delta[k] = nil
continue
}
var b bool
if err := json.Unmarshal(v, &b); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"error": "scenario-flag values must be bool or null (got non-bool for key " + k + ")",
})
return
}
bv := b
delta[k] = &bv
}
view, err := dbSvc.scenarioFlags.Patch(r.Context(), uid, id, delta)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, view)
}

View File

@@ -0,0 +1,96 @@
package handlers
// Submission base catalog handler — Composer Slice A (t-paliad-313,
// m/paliad#141, design doc docs/design-submission-generator-v2-2026-05-26.md
// §5.1 / Slice A acceptance).
//
// Endpoint: GET /api/submission-bases → list of active bases visible
// to the requesting firm. The sidebar picker on the draft editor reads
// this once on page load and caches in-memory; the response shape is
// stable across the picker's lifetime.
//
// Visibility: the catalog is shared firm-wide (per the design + mig
// 146's wide-open RLS SELECT policy). The handler still requires
// authentication; anonymous users 401.
//
// Filtering: the response includes the firm's own bases AND the
// firm-agnostic ones (firm IS NULL). The Go service-side filter passes
// branding.Name as the firm hint; cross-firm cases (e.g. a future
// non-HLC deployment) get their own filtered slice naturally.
import (
"net/http"
"mgit.msbls.de/m/paliad/internal/branding"
"mgit.msbls.de/m/paliad/internal/services"
)
// submissionBaseRow is the on-the-wire shape returned by the list
// endpoint. Mirrors services.SubmissionBase but drops the raw bytes
// and exposes the parsed section spec inline so the picker can show a
// preview of the default section count without an extra round-trip.
type submissionBaseRow struct {
ID string `json:"id"`
Slug string `json:"slug"`
Firm *string `json:"firm,omitempty"`
ProceedingFamily *string `json:"proceeding_family,omitempty"`
LabelDE string `json:"label_de"`
LabelEN string `json:"label_en"`
DescriptionDE *string `json:"description_de,omitempty"`
DescriptionEN *string `json:"description_en,omitempty"`
GiteaPath string `json:"gitea_path"`
IsDefaultFor []string `json:"is_default_for"`
IsActive bool `json:"is_active"`
SectionCount int `json:"section_count"`
}
type submissionBaseListResponse struct {
Bases []submissionBaseRow `json:"bases"`
}
// handleListSubmissionBases backs GET /api/submission-bases.
func handleListSubmissionBases(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
if dbSvc.submissionBase == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "submission bases not configured",
})
return
}
rows, err := dbSvc.submissionBase.List(r.Context(), branding.Name)
if err != nil {
writeServiceError(w, err)
return
}
out := make([]submissionBaseRow, 0, len(rows))
for i := range rows {
out = append(out, baseRowFromService(&rows[i]))
}
writeJSON(w, http.StatusOK, submissionBaseListResponse{Bases: out})
}
// baseRowFromService projects a services.SubmissionBase into the
// on-the-wire row shape.
func baseRowFromService(b *services.SubmissionBase) submissionBaseRow {
return submissionBaseRow{
ID: b.ID.String(),
Slug: b.Slug,
Firm: b.Firm,
ProceedingFamily: b.ProceedingFamily,
LabelDE: b.LabelDE,
LabelEN: b.LabelEN,
DescriptionDE: b.DescriptionDE,
DescriptionEN: b.DescriptionEN,
GiteaPath: b.GiteaPath,
IsDefaultFor: b.IsDefaultFor,
IsActive: b.IsActive,
SectionCount: len(b.SectionSpec.Defaults),
}
}

View File

@@ -0,0 +1,482 @@
package handlers
// Composer building-block handlers — t-paliad-315 Slice C.
//
// Two surfaces:
//
// 1. Lawyer-facing picker (any authenticated user):
// GET /api/submission-building-blocks?section_key=…&proceeding_family=…&q=…
// POST /api/submission-building-blocks/{block_id}/insert-into/{section_id}
//
// The picker list is visibility-tier-filtered (private/team/firm/
// global) at the service layer. Insert is the paste mechanic
// ratified by Q2 (m, 2026-05-26): plain text copy of
// content_md_<lang> into submission_sections.content_md_<lang>.
// No lineage stamped on the section.
//
// 2. Admin editor (adminGate via auth.RequireAdminFunc):
// GET /api/admin/submission-building-blocks
// POST /api/admin/submission-building-blocks
// GET /api/admin/submission-building-blocks/{block_id}
// PATCH /api/admin/submission-building-blocks/{block_id}
// DELETE /api/admin/submission-building-blocks/{block_id}
// GET /api/admin/submission-building-blocks/{block_id}/versions
// POST /api/admin/submission-building-blocks/{block_id}/restore/{version_id}
//
// Plus the page route /admin/submission-building-blocks (list +
// edit shell, hydrated client-side).
import (
"context"
"encoding/json"
"errors"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// blockJSON is the on-the-wire shape for both the picker and admin
// surfaces.
type buildingBlockJSON struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Firm *string `json:"firm,omitempty"`
SectionKey string `json:"section_key"`
ProceedingFamily *string `json:"proceeding_family,omitempty"`
TitleDE string `json:"title_de"`
TitleEN string `json:"title_en"`
DescriptionDE *string `json:"description_de,omitempty"`
DescriptionEN *string `json:"description_en,omitempty"`
ContentMDDE string `json:"content_md_de"`
ContentMDEN string `json:"content_md_en"`
AuthorID *uuid.UUID `json:"author_id,omitempty"`
Visibility string `json:"visibility"`
IsPublished bool `json:"is_published"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type buildingBlockListResponse struct {
Blocks []buildingBlockJSON `json:"blocks"`
}
// blockJSONFromService projects services.BuildingBlock into the wire shape.
func blockJSONFromService(b *services.BuildingBlock) buildingBlockJSON {
return buildingBlockJSON{
ID: b.ID,
Slug: b.Slug,
Firm: b.Firm,
SectionKey: b.SectionKey,
ProceedingFamily: b.ProceedingFamily,
TitleDE: b.TitleDE,
TitleEN: b.TitleEN,
DescriptionDE: b.DescriptionDE,
DescriptionEN: b.DescriptionEN,
ContentMDDE: b.ContentMDDE,
ContentMDEN: b.ContentMDEN,
AuthorID: b.AuthorID,
Visibility: b.Visibility,
IsPublished: b.IsPublished,
CreatedAt: b.CreatedAt,
UpdatedAt: b.UpdatedAt,
}
}
// ─────────────────────────────────────────────────────────────────────
// Lawyer-facing picker
// ─────────────────────────────────────────────────────────────────────
func handleListBuildingBlocks(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionBuildingBlock == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
return
}
q := r.URL.Query()
filter := services.BlockListFilter{
SectionKey: strings.TrimSpace(q.Get("section_key")),
ProceedingFamily: strings.TrimSpace(q.Get("proceeding_family")),
Search: strings.TrimSpace(q.Get("q")),
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
rows, err := dbSvc.submissionBuildingBlock.ListVisible(ctx, uid, filter)
if err != nil {
writeServiceError(w, err)
return
}
out := make([]buildingBlockJSON, 0, len(rows))
for i := range rows {
out = append(out, blockJSONFromService(&rows[i]))
}
writeJSON(w, http.StatusOK, buildingBlockListResponse{Blocks: out})
}
func handleInsertBlockIntoSection(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionBuildingBlock == nil || dbSvc.submissionSection == nil || dbSvc.submissionDraft == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
return
}
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
if !ok {
return
}
sectionID, ok := parseUUIDPath(w, r, "section_id", "section id")
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
// Visibility on the section: section.draft_id must point to a
// draft the caller owns. Composer Slice B's same owner gate.
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 _, err := dbSvc.submissionDraft.Get(ctx, uid, sec.DraftID); err != nil {
writeSubmissionDraftServiceError(w, err)
return
}
updated, err := dbSvc.submissionBuildingBlock.InsertIntoSection(ctx, uid, blockID, sectionID, dbSvc.submissionSection)
if err != nil {
if errors.Is(err, services.ErrBuildingBlockNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
return
}
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.StatusOK, sectionJSONFromService(updated))
}
// ─────────────────────────────────────────────────────────────────────
// Admin editor
// ─────────────────────────────────────────────────────────────────────
func handleAdminListBuildingBlocks(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.submissionBuildingBlock == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
rows, err := dbSvc.submissionBuildingBlock.ListAllForAdmin(ctx)
if err != nil {
writeServiceError(w, err)
return
}
out := make([]buildingBlockJSON, 0, len(rows))
for i := range rows {
out = append(out, blockJSONFromService(&rows[i]))
}
writeJSON(w, http.StatusOK, buildingBlockListResponse{Blocks: out})
}
func handleAdminGetBuildingBlock(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.submissionBuildingBlock == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
return
}
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
b, err := dbSvc.submissionBuildingBlock.GetForAdmin(ctx, blockID)
if err != nil {
if errors.Is(err, services.ErrBuildingBlockNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, blockJSONFromService(b))
}
type buildingBlockCreateInput struct {
Slug string `json:"slug"`
Firm *string `json:"firm,omitempty"`
SectionKey string `json:"section_key"`
ProceedingFamily *string `json:"proceeding_family,omitempty"`
TitleDE string `json:"title_de"`
TitleEN string `json:"title_en"`
DescriptionDE *string `json:"description_de,omitempty"`
DescriptionEN *string `json:"description_en,omitempty"`
ContentMDDE string `json:"content_md_de"`
ContentMDEN string `json:"content_md_en"`
Visibility string `json:"visibility"`
IsPublished bool `json:"is_published"`
}
func handleAdminCreateBuildingBlock(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionBuildingBlock == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
return
}
var in buildingBlockCreateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
b, err := dbSvc.submissionBuildingBlock.Create(ctx, uid, services.CreateInput{
Slug: in.Slug,
Firm: in.Firm,
SectionKey: in.SectionKey,
ProceedingFamily: in.ProceedingFamily,
TitleDE: in.TitleDE,
TitleEN: in.TitleEN,
DescriptionDE: in.DescriptionDE,
DescriptionEN: in.DescriptionEN,
ContentMDDE: in.ContentMDDE,
ContentMDEN: in.ContentMDEN,
Visibility: in.Visibility,
IsPublished: in.IsPublished,
})
if err != nil {
if errors.Is(err, services.ErrInvalidInput) || errors.Is(err, services.ErrBuildingBlockInvalidVisibility) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusCreated, blockJSONFromService(b))
}
type buildingBlockUpdateInput struct {
Slug *string `json:"slug,omitempty"`
Firm *string `json:"firm,omitempty"`
FirmSet bool `json:"-"`
SectionKey *string `json:"section_key,omitempty"`
ProceedingFamily *string `json:"proceeding_family,omitempty"`
ProceedingFamilySet bool `json:"-"`
TitleDE *string `json:"title_de,omitempty"`
TitleEN *string `json:"title_en,omitempty"`
DescriptionDE *string `json:"description_de,omitempty"`
DescriptionDESet bool `json:"-"`
DescriptionEN *string `json:"description_en,omitempty"`
DescriptionENSet bool `json:"-"`
ContentMDDE *string `json:"content_md_de,omitempty"`
ContentMDEN *string `json:"content_md_en,omitempty"`
Visibility *string `json:"visibility,omitempty"`
IsPublished *bool `json:"is_published,omitempty"`
Note *string `json:"note,omitempty"`
}
func (u *buildingBlockUpdateInput) UnmarshalJSON(data []byte) error {
type alias buildingBlockUpdateInput
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
*u = buildingBlockUpdateInput(a)
raw := map[string]json.RawMessage{}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
_, u.FirmSet = raw["firm"]
_, u.ProceedingFamilySet = raw["proceeding_family"]
_, u.DescriptionDESet = raw["description_de"]
_, u.DescriptionENSet = raw["description_en"]
return nil
}
func handleAdminUpdateBuildingBlock(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionBuildingBlock == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
return
}
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
if !ok {
return
}
var in buildingBlockUpdateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
patch := services.UpdatePatch{
Slug: in.Slug,
SectionKey: in.SectionKey,
TitleDE: in.TitleDE,
TitleEN: in.TitleEN,
ContentMDDE: in.ContentMDDE,
ContentMDEN: in.ContentMDEN,
Visibility: in.Visibility,
IsPublished: in.IsPublished,
Note: in.Note,
}
if in.FirmSet {
patch.Firm = &in.Firm
}
if in.ProceedingFamilySet {
patch.ProceedingFamily = &in.ProceedingFamily
}
if in.DescriptionDESet {
patch.DescriptionDE = &in.DescriptionDE
}
if in.DescriptionENSet {
patch.DescriptionEN = &in.DescriptionEN
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
b, err := dbSvc.submissionBuildingBlock.Update(ctx, uid, blockID, patch)
if err != nil {
if errors.Is(err, services.ErrBuildingBlockNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
return
}
if errors.Is(err, services.ErrBuildingBlockInvalidVisibility) {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, blockJSONFromService(b))
}
func handleAdminDeleteBuildingBlock(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionBuildingBlock == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
return
}
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
if err := dbSvc.submissionBuildingBlock.SoftDelete(ctx, uid, blockID); err != nil {
if errors.Is(err, services.ErrBuildingBlockNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block not found"})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusNoContent, nil)
}
func handleAdminListBuildingBlockVersions(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if dbSvc.submissionBuildingBlock == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
return
}
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Second)
defer cancel()
rows, err := dbSvc.submissionBuildingBlock.ListVersions(ctx, blockID)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"versions": rows})
}
func handleAdminRestoreBuildingBlockVersion(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionBuildingBlock == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "building blocks not configured"})
return
}
blockID, ok := parseUUIDPath(w, r, "block_id", "block id")
if !ok {
return
}
versionID, ok := parseUUIDPath(w, r, "version_id", "version id")
if !ok {
return
}
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
b, err := dbSvc.submissionBuildingBlock.RestoreVersion(ctx, uid, blockID, versionID)
if err != nil {
if errors.Is(err, services.ErrBuildingBlockNotFound) {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "block or version not found"})
return
}
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, blockJSONFromService(b))
}
// handleAdminBuildingBlocksPage serves the admin editor shell. The
// client bundle hydrates the list + edit UI.
func handleAdminBuildingBlocksPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/admin-submission-building-blocks.html")
}

View File

@@ -83,6 +83,11 @@ type submissionDraftView struct {
// so the frontend can render the multi-select picker in one round-
// trip. Empty when the draft has no project attached.
AvailableParties []submissionDraftPartyJSON `json:"available_parties"`
// Sections is the per-draft section stack (t-paliad-313 Slice A).
// Slice A renders these read-only; the lawyer sees what the
// Composer seeded but can't yet edit prose. nil for pre-Composer
// drafts (base_id NULL, no submission_sections rows).
Sections []submissionSectionJSON `json:"sections"`
}
// submissionDraftPartyJSON is the minimal party row the editor sidebar
@@ -106,8 +111,30 @@ type submissionDraftJSON struct {
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
LastExportedSHA *string `json:"last_exported_sha,omitempty"`
LastImportedAt *time.Time `json:"last_imported_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// BaseID — Composer base reference (t-paliad-313). NULL on
// pre-Composer drafts; the editor sidebar surfaces this in the
// base picker. PATCH accepts {"base_id": "<uuid>"} or
// {"base_id": null} to set or clear.
BaseID *uuid.UUID `json:"base_id"`
ComposerMeta map[string]any `json:"composer_meta"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// submissionSectionJSON is the on-the-wire row for each per-draft
// section. Slice A renders these read-only — the lawyer sees the
// section stack but doesn't yet edit prose. Slice B makes content_md_*
// editable + adds the PATCH endpoint.
type submissionSectionJSON struct {
ID uuid.UUID `json:"id"`
SectionKey string `json:"section_key"`
OrderIndex int `json:"order_index"`
Kind string `json:"kind"`
LabelDE string `json:"label_de"`
LabelEN string `json:"label_en"`
Included bool `json:"included"`
ContentMDDE string `json:"content_md_de"`
ContentMDEN string `json:"content_md_en"`
}
type submissionRuleSummary struct {
@@ -132,6 +159,41 @@ type submissionDraftPatchInput struct {
Variables *services.PlaceholderMap `json:"variables,omitempty"`
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
Language *string `json:"language,omitempty"`
// BaseID accepts three states per the JSON contract:
// field absent → no change (json:"-")
// {"base_id": "<uuid>"} → set to picked base
// {"base_id": null} → clear (return to v1 fallback)
// We model this with a **uuid.UUID inside a custom UnmarshalJSON
// in case extends; for now the simpler `*uuid.UUID` + presence
// flag covers Slice A's set-base flow. Clearing is exposed but
// rarely used (the editor always picks a base; clearing is for
// admin-recovery flows).
BaseID *uuid.UUID `json:"base_id,omitempty"`
BaseIDSet bool `json:"-"`
}
// UnmarshalJSON on submissionDraftPatchInput sets BaseIDSet=true if
// the "base_id" key appears in the payload (regardless of whether
// the value is null or a uuid string). Lets the handler distinguish
// "field absent" (no change) from "field set to null" (clear).
func (p *submissionDraftPatchInput) UnmarshalJSON(data []byte) error {
// Phase 1: decode into a raw map to detect key presence.
raw := map[string]json.RawMessage{}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
// Phase 2: decode the typed fields. Use an alias to skip this
// custom UnmarshalJSON during the re-parse.
type alias submissionDraftPatchInput
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
*p = submissionDraftPatchInput(a)
if _, ok := raw["base_id"]; ok {
p.BaseIDSet = true
}
return nil
}
// ─────────────────────────────────────────────────────────────────────
@@ -372,6 +434,9 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
SelectedParties: input.SelectedParties,
Language: input.Language,
}
if input.BaseIDSet {
patch.BaseID = &input.BaseID
}
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
if err != nil {
writeSubmissionDraftServiceError(w, err)
@@ -501,16 +566,10 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
docx, resolved, tplSHA, composerUsed, err := exportSubmissionDraft(ctx, d)
if err != nil {
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
return
}
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
if err != nil {
log.Printf("submission_drafts: export render (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
log.Printf("submission_drafts: export (draft=%s): %v", draftID, err)
writeSubmissionExportError(w, err)
return
}
@@ -523,7 +582,7 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
if err := dbSvc.submissionDraft.MarkExported(bgCtx, d.ID, tplSHA); err != nil {
log.Printf("submission_drafts: mark exported (draft=%s): %v", draftID, err)
}
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA); err != nil {
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA, composerUsed); err != nil {
log.Printf("submission_drafts: audit insert failed (draft=%s): %v", draftID, err)
}
if err := writeSubmissionDraftProjectEvent(bgCtx, d, resolved, filename); err != nil {
@@ -538,6 +597,82 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
}
}
// exportSubmissionDraft is the shared render entry point used by both
// the project-scoped and global export handlers (t-paliad-313 Slice B).
// Branches on draft.BaseID: if set AND the base + bytes resolve, the
// Composer pipeline assembles the document; otherwise the v1
// template-only path stays the fallback. composerUsed = true means the
// metadata jsonb on the audit row carries "composer": true so admins
// can tell the two paths apart in the feed.
//
// Returns (bytes, resolved-bag, templateSHA, composerUsed, err).
func exportSubmissionDraft(ctx context.Context, d *services.SubmissionDraft) ([]byte, *services.SubmissionVarsResult, string, bool, error) {
if d.BaseID != nil && dbSvc.submissionBase != nil && dbSvc.submissionSection != nil && dbSvc.submissionComposer != nil {
base, err := dbSvc.submissionBase.GetByID(ctx, *d.BaseID)
switch {
case err == nil:
baseBytes, baseSHA, err := fetchComposerBaseBytes(ctx, base)
if err == nil {
sections, err := dbSvc.submissionSection.ListForDraft(ctx, d.ID)
if err != nil {
return nil, nil, "", false, fmt.Errorf("list sections: %w", err)
}
bag, resolved, err := dbSvc.submissionDraft.BuildRenderBag(ctx, d)
if err != nil {
return nil, nil, "", false, err
}
docx, err := dbSvc.submissionComposer.Compose(ctx, services.ComposeOptions{
Sections: sections,
Base: base,
BaseBytes: baseBytes,
Lang: resolved.Lang,
Vars: bag,
Missing: services.DefaultMissingMarker(resolved.Lang),
})
if err != nil {
return nil, nil, "", false, fmt.Errorf("composer: %w", err)
}
return docx, resolved, baseSHA, true, nil
}
log.Printf("submission_drafts: composer base bytes fetch failed (draft=%s base=%s): %v — falling back to v1 path", d.ID, base.Slug, err)
case errors.Is(err, services.ErrBaseNotFound):
log.Printf("submission_drafts: composer base missing (draft=%s base_id=%s) — falling back to v1 path", d.ID, *d.BaseID)
default:
return nil, nil, "", false, fmt.Errorf("composer base lookup: %w", err)
}
}
// v1 fallback: template-only render via resolveSubmissionTemplate +
// SubmissionDraftService.Export. Unchanged behaviour for
// pre-Composer drafts.
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
if err != nil {
return nil, nil, "", false, fmt.Errorf("template upstream: %w", err)
}
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
if err != nil {
return nil, nil, "", false, fmt.Errorf("render: %w", err)
}
return docx, resolved, tplSHA, false, nil
}
// writeSubmissionExportError maps a render-time error to an HTTP
// response. The shape mirrors what the handlers used to inline.
func writeSubmissionExportError(w http.ResponseWriter, err error) {
if err == nil {
return
}
msg := err.Error()
switch {
case strings.Contains(msg, "template upstream"):
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
case strings.Contains(msg, "composer:") || strings.Contains(msg, "render:") || strings.Contains(msg, "list sections"):
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
}
}
// handleSubmissionDraftPage serves dist/submission-draft.html for the
// dedicated draft editor at /projects/{id}/submissions/{code}/draft
// (and …/draft/{draft_id}). Project visibility is enforced server-side
@@ -713,6 +848,11 @@ type globalDraftPatchInput struct {
// SelectedParties: present-but-empty array resets to "all parties",
// present non-empty array restricts to subset, absent = no change.
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
// BaseID + baseIDProvided mirror the ProjectID pattern — present
// (regardless of value) means "set"; absent means "no change". Set
// by UnmarshalJSON. t-paliad-313 Composer Slice A.
BaseID *uuid.UUID `json:"base_id,omitempty"`
baseIDProvided bool
}
func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
@@ -722,6 +862,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
Language *string `json:"language,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
BaseID *uuid.UUID `json:"base_id,omitempty"`
}
var a alias
if err := json.Unmarshal(data, &a); err != nil {
@@ -732,12 +873,15 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
g.Language = a.Language
g.ProjectID = a.ProjectID
g.SelectedParties = a.SelectedParties
// Detect whether "project_id" was present in the JSON object.
g.BaseID = a.BaseID
// Detect whether "project_id" / "base_id" were present in the JSON
// object.
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
_, g.projectIDProvided = raw["project_id"]
_, g.baseIDProvided = raw["base_id"]
return nil
}
@@ -778,6 +922,10 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
pid := in.ProjectID // may be nil → detach
patch.ProjectID = &pid
}
if in.baseIDProvided {
bid := in.BaseID // may be nil → clear
patch.BaseID = &bid
}
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
if err != nil {
@@ -890,16 +1038,10 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, tplSHA, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
docx, resolved, tplSHA, composerUsed, err := exportSubmissionDraft(ctx, d)
if err != nil {
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
return
}
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
if err != nil {
log.Printf("submission_drafts: export render (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
log.Printf("submission_drafts: export (draft=%s): %v", draftID, err)
writeSubmissionExportError(w, err)
return
}
@@ -910,7 +1052,7 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
if err := dbSvc.submissionDraft.MarkExported(bgCtx, d.ID, tplSHA); err != nil {
log.Printf("submission_drafts: mark exported (draft=%s): %v", draftID, err)
}
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA); err != nil {
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA, composerUsed); err != nil {
log.Printf("submission_drafts: audit insert failed (draft=%s): %v", draftID, err)
}
if err := writeSubmissionDraftProjectEvent(bgCtx, d, resolved, filename); err != nil {
@@ -952,6 +1094,30 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
Lang: lang,
HasTemplate: true,
AvailableParties: []submissionDraftPartyJSON{},
Sections: []submissionSectionJSON{},
}
// Composer Slice A — surface seeded sections (read-only). Empty
// when the draft has no base + no section rows (pre-Composer
// drafts that haven't been auto-upgraded — that's Slice C).
if dbSvc.submissionSection != nil {
secs, err := dbSvc.submissionSection.ListForDraft(ctx, d.ID)
if err != nil {
return nil, err
}
for _, sec := range secs {
view.Sections = append(view.Sections, submissionSectionJSON{
ID: sec.ID,
SectionKey: sec.SectionKey,
OrderIndex: sec.OrderIndex,
Kind: sec.Kind,
LabelDE: sec.LabelDE,
LabelEN: sec.LabelEN,
Included: sec.Included,
ContentMDDE: sec.ContentMDDE,
ContentMDEN: sec.ContentMDEN,
})
}
}
merged, resolved, err := dbSvc.submissionDraft.BuildRenderBag(ctx, d)
@@ -1135,6 +1301,10 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
if lang == "" {
lang = "de"
}
meta := d.ComposerMeta
if meta == nil {
meta = map[string]any{}
}
return submissionDraftJSON{
ID: d.ID,
ProjectID: d.ProjectID,
@@ -1147,6 +1317,8 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
LastExportedAt: d.LastExportedAt,
LastExportedSHA: d.LastExportedSHA,
LastImportedAt: d.LastImportedAt,
BaseID: d.BaseID,
ComposerMeta: meta,
CreatedAt: d.CreatedAt,
UpdatedAt: d.UpdatedAt,
}
@@ -1160,7 +1332,7 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
// 'user' with scope_root = draft.user_id; the audit feed therefore
// surfaces these exports on the user's row rather than against a
// (non-existent) project.
func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *services.SubmissionDraft, filename, templateSHA string) error {
func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *services.SubmissionDraft, filename, templateSHA string, composerUsed bool) error {
meta := map[string]any{
"submission_code": d.SubmissionCode,
"draft_id": d.ID.String(),
@@ -1168,6 +1340,15 @@ func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *ser
"filename": filename,
"template_sha": templateSHA,
}
// t-paliad-313 Slice B — composer flag in metadata so admins can
// tell the two render paths apart in the audit feed without
// adding a new event_type.
if composerUsed {
meta["composer"] = true
if d.BaseID != nil {
meta["base_id"] = d.BaseID.String()
}
}
body, _ := json.Marshal(meta)
var (
actorID any

View File

@@ -0,0 +1,332 @@
package handlers
// Submission section handlers — Composer Slice B (t-paliad-313). Backs
// the inline editor on /projects/{id}/submissions/{code}/draft/{draft_id}
// where the lawyer types prose into each section.
//
// Endpoint:
//
// PATCH /api/submission-drafts/{draft_id}/sections/{section_id}
//
// Body shape (all fields optional — absent = no change):
//
// {
// "content_md_de": "...",
// "content_md_en": "...",
// "included": true|false,
// "label_de": "...",
// "label_en": "...",
// "order_index": 3
// }
//
// Visibility: ownership of the draft is checked via
// SubmissionDraftService.Get (404 on no-access), then the section is
// fetched + verified to belong to that draft. The DB-side RLS policy
// (mig 148) enforces the same gate independently.
//
// Returns 200 + the refreshed section row on success.
//
// This is global-scoped (no /projects/{id}/ prefix) because the
// section's owning draft already carries the project_id; routing on
// section_id alone keeps the URL shape stable across project-scoped
// and project-less drafts.
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// submissionSectionPatchInput is the JSON shape accepted by PATCH.
type submissionSectionPatchInput struct {
ContentMDDE *string `json:"content_md_de,omitempty"`
ContentMDEN *string `json:"content_md_en,omitempty"`
Included *bool `json:"included,omitempty"`
LabelDE *string `json:"label_de,omitempty"`
LabelEN *string `json:"label_en,omitempty"`
OrderIndex *int `json:"order_index,omitempty"`
}
// submissionSectionPatchTimeout caps the round-trip.
const submissionSectionPatchTimeout = 10 * time.Second
func handlePatchSubmissionSection(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()
// Owner-scope on the draft (RLS mirror; this gives us the typed
// 404 + the path for the "section belongs to a different draft"
// case below).
draft, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
if err != nil {
writeSubmissionDraftServiceError(w, err)
return
}
existing, 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 existing.DraftID != draft.ID {
// Section exists but doesn't belong to this draft — surface as
// 404 to keep the "no fishing for foreign drafts" property.
writeJSON(w, http.StatusNotFound, map[string]string{"error": "section not found"})
return
}
var input submissionSectionPatchInput
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
patch := services.SectionPatch{
ContentMDDE: input.ContentMDDE,
ContentMDEN: input.ContentMDEN,
Included: input.Included,
LabelDE: input.LabelDE,
LabelEN: input.LabelEN,
OrderIndex: input.OrderIndex,
}
updated, err := dbSvc.submissionSection.Update(ctx, sectionID, patch)
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
}
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[].
func sectionJSONFromService(sec *services.SubmissionSection) submissionSectionJSON {
return submissionSectionJSON{
ID: sec.ID,
SectionKey: sec.SectionKey,
OrderIndex: sec.OrderIndex,
Kind: sec.Kind,
LabelDE: sec.LabelDE,
LabelEN: sec.LabelEN,
Included: sec.Included,
ContentMDDE: sec.ContentMDDE,
ContentMDEN: sec.ContentMDEN,
}
}

View File

@@ -200,7 +200,7 @@ func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([
pt.code AS proceeding_code,
pt.name AS proceeding_name,
pt.name_en AS proceeding_name_en
FROM paliad.deadline_rules dr
FROM paliad.deadline_rules_unified dr
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
WHERE dr.is_active = true
AND dr.lifecycle_state = 'published'
@@ -208,7 +208,7 @@ func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([
AND dr.submission_code IS NOT NULL
AND dr.submission_code <> ''
AND pt.is_active = true
ORDER BY pt.code ASC, dr.submission_code ASC`)
ORDER BY pt.code ASC, dr.sequence_order ASC, dr.submission_code ASC`)
if err != nil {
return nil, nil, err
}

View File

@@ -6,78 +6,54 @@ import (
"testing"
)
// /tools/fristenrechner?path=a was the pre-split sidebar entry for the
// "Verfahrensablauf" surface. After t-paliad-179 Slice 1 that intent
// owns its own /tools/verfahrensablauf route — so a naked ?path=a hit
// must 302 to the new URL to preserve bookmarked legacy links.
//
// The Akte-mode internal wizard pathway (?project=<uuid>&path=a) is
// NOT a top-level entry — it's wizard state set by client-side
// history.replaceState. That URL must keep serving the fristenrechner
// shell so a mid-wizard refresh doesn't bounce away.
func TestHandleFristenrechnerPage_LegacyPathARedirect(t *testing.T) {
// U4 (m/paliad#151) — both legacy URLs hard-cut to /tools/procedures
// with HTTP 301. Query strings carry through so bookmarks like
// /tools/fristenrechner?event=upc.inf.cfi.soc&trigger_date=2026-04-01
// resolve to /tools/procedures?event=…&trigger_date=… without losing
// the user's intent.
func TestLegacyToolsPagesRedirect(t *testing.T) {
cases := []struct {
name string
path string
wantStatus int
wantLoc string
name string
path string
handler func(http.ResponseWriter, *http.Request)
wantLoc string
}{
{
name: "naked path=a → redirect",
path: "/tools/fristenrechner?path=a",
wantStatus: http.StatusFound,
wantLoc: "/tools/verfahrensablauf",
name: "fristenrechner naked",
path: "/tools/fristenrechner",
handler: handleFristenrechnerPage,
wantLoc: "/tools/procedures",
},
{
name: "path=a with project= → no redirect (Akte-mode wizard)",
path: "/tools/fristenrechner?project=abc-123&path=a",
wantStatus: http.StatusOK,
name: "fristenrechner with query",
path: "/tools/fristenrechner?event=upc.inf.cfi.soc&trigger_date=2026-04-01",
handler: handleFristenrechnerPage,
wantLoc: "/tools/procedures?event=upc.inf.cfi.soc&trigger_date=2026-04-01",
},
{
name: "no path param → no redirect",
path: "/tools/fristenrechner",
wantStatus: http.StatusOK,
name: "verfahrensablauf naked",
path: "/tools/verfahrensablauf",
handler: handleVerfahrensablaufPage,
wantLoc: "/tools/procedures",
},
{
name: "path=b → no redirect (Pathway B stays)",
path: "/tools/fristenrechner?path=b",
wantStatus: http.StatusOK,
name: "verfahrensablauf with proceeding",
path: "/tools/verfahrensablauf?proceeding=upc.inf.cfi&side=claimant",
handler: handleVerfahrensablaufPage,
wantLoc: "/tools/procedures?proceeding=upc.inf.cfi&side=claimant",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
w := httptest.NewRecorder()
handleFristenrechnerPage(w, req)
if w.Code != tc.wantStatus {
// http.ServeFile may write 404 if dist/fristenrechner.html
// is missing under `go test` (CI runs without a frontend
// build). We only care that we did NOT redirect in those
// cases — collapse 200 and 404 into "not a redirect".
if tc.wantStatus == http.StatusOK && w.Code != http.StatusFound {
return
}
t.Fatalf("status = %d, want %d", w.Code, tc.wantStatus)
tc.handler(w, req)
if w.Code != http.StatusMovedPermanently {
t.Fatalf("status = %d, want %d", w.Code, http.StatusMovedPermanently)
}
if tc.wantLoc != "" {
if got := w.Header().Get("Location"); got != tc.wantLoc {
t.Fatalf("Location = %q, want %q", got, tc.wantLoc)
}
if got := w.Header().Get("Location"); got != tc.wantLoc {
t.Fatalf("Location = %q, want %q", got, tc.wantLoc)
}
})
}
}
// The new /tools/verfahrensablauf route registers as a 1-liner page
// handler that ServeFiles dist/verfahrensablauf.html. We assert the
// handler does NOT redirect — if the dist artefact is missing under
// `go test`, ServeFile may return 404, but it must never return a 3xx.
func TestHandleVerfahrensablaufPage_NoRedirect(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/tools/verfahrensablauf", nil)
w := httptest.NewRecorder()
handleVerfahrensablaufPage(w, req)
if w.Code >= 300 && w.Code < 400 {
t.Fatalf("verfahrensablauf must not redirect; got %d → %s",
w.Code, w.Header().Get("Location"))
}
}

Some files were not shown because too many files have changed in this diff Show More