Compare commits
33 Commits
mai/ritchi
...
mai/cronus
| Author | SHA1 | Date | |
|---|---|---|---|
| 0aaa523494 | |||
| d49ff55c41 | |||
| ae1c0b861d | |||
| c8999e2a8b | |||
| 0365e84dd1 | |||
| d6a5dedb2b | |||
| 9940dd8216 | |||
| f6add95d0a | |||
| 480332a5f5 | |||
| 97d90ce651 | |||
| 3a4e99cb92 | |||
| 3533d79a25 | |||
| 2a69f7fc6c | |||
| 39353d49ed | |||
| d36cc9ee15 | |||
| a9fd979cdb | |||
| c48fa93e3d | |||
| 5f7a66bbec | |||
| 490c8a8c8c | |||
| b1c9e8dd97 | |||
| 9aee9e4101 | |||
| 810b65463e | |||
| 33c5fb2983 | |||
| 76d38c4c84 | |||
| 233547297c | |||
| ba3e0795f8 | |||
| 8dfdd77079 | |||
| 4571bd4980 | |||
| 7584b4f428 | |||
| 70985d88b0 | |||
| 06d6c7540e | |||
| 3e55ff8294 | |||
| 2a2c5b8033 |
@@ -242,6 +242,10 @@ 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-246 Slice A — Backup Mode runner. Wired only when
|
||||
|
||||
738
docs/assessment-deadline-system-2026-05-27.md
Normal file
738
docs/assessment-deadline-system-2026-05-27.md
Normal 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 1–6 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.
|
||||
776
docs/design-deadline-system-revision-2026-05-27.md
Normal file
776
docs/design-deadline-system-revision-2026-05-27.md
Normal 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:34–14: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.
|
||||
568
docs/design-unified-procedural-events-tool-2026-05-27.md
Normal file
568
docs/design-unified-procedural-events-tool-2026-05-27.md
Normal 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 Pflicht→Gewählt→Alle.
|
||||
- **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.
|
||||
@@ -106,8 +106,10 @@ function fmtDateTime(iso: string): string {
|
||||
}
|
||||
|
||||
function parseRuleIDFromPath(): string {
|
||||
// /admin/procedural-events/{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]) : "";
|
||||
}
|
||||
|
||||
|
||||
507
frontend/src/client/fristenrechner-mode-a.ts
Normal file
507
frontend/src/client/fristenrechner-mode-a.ts
Normal 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">→</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 "📥";
|
||||
case "hearing": return "🏛️";
|
||||
case "decision": return "⚖️";
|
||||
case "order": return "📜";
|
||||
default: return "🔍";
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
@@ -23,6 +23,10 @@ export interface FollowUpRule {
|
||||
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;
|
||||
@@ -80,11 +84,14 @@ let currentProjectId: string | null = null;
|
||||
|
||||
// Public API ----------------------------------------------------------
|
||||
|
||||
// isOverhaulMode reports whether the page is in overhaul mode (S2+).
|
||||
// True when `?overhaul=1` is present. Once S5 flips the flag, the
|
||||
// reverse check (?legacy=1) replaces this.
|
||||
// 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("overhaul") === "1";
|
||||
return new URLSearchParams(window.location.search).get("legacy") !== "1";
|
||||
}
|
||||
|
||||
// resolveProjectId reads the active Akte from the URL query string.
|
||||
@@ -94,6 +101,64 @@ function resolveProjectId(): string | null {
|
||||
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">⚡</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">🧭</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.
|
||||
@@ -325,6 +390,10 @@ function renderRule(r: FollowUpRule, lang: "de" | "en"): string {
|
||||
? `<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>`
|
||||
: "";
|
||||
@@ -334,7 +403,7 @@ function renderRule(r: FollowUpRule, lang: "de" | "en"): string {
|
||||
: `<button type="button" class="fristen-overhaul-rule-edit-date" data-rule-id="${escAttr(r.rule_id)}" title="${escAttr(t("deadlines.overhaul.edit_date.title"))}" aria-label="${escAttr(t("deadlines.overhaul.edit_date.title"))}">${escHtml(t("deadlines.overhaul.edit_date.label"))}</button>`;
|
||||
|
||||
return `
|
||||
<li class="fristen-overhaul-rule${disabled ? " is-disabled" : ""}" data-rule-id="${escAttr(r.rule_id)}">
|
||||
<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" : ""} />
|
||||
@@ -345,6 +414,7 @@ function renderRule(r: FollowUpRule, lang: "de" | "en"): string {
|
||||
<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>` : ""}
|
||||
@@ -473,6 +543,10 @@ function countSelected(): number {
|
||||
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++;
|
||||
}
|
||||
@@ -493,6 +567,10 @@ async function submitWriteBack(): Promise<void> {
|
||||
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;
|
||||
@@ -550,6 +628,10 @@ function auditReason(): string {
|
||||
// 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;
|
||||
|
||||
47
frontend/src/client/fristenrechner-wizard.test.ts
Normal file
47
frontend/src/client/fristenrechner-wizard.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
711
frontend/src/client/fristenrechner-wizard.ts
Normal file
711
frontend/src/client/fristenrechner-wizard.ts
Normal 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 "📥";
|
||||
case "hearing": return "🏛️";
|
||||
case "decision": return "⚖️";
|
||||
case "order": return "📜";
|
||||
case "missed": return "⏲";
|
||||
default: return "📅";
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -30,7 +30,12 @@ import {
|
||||
type EventChoice,
|
||||
type ChoiceKind,
|
||||
} from "./views/event-card-choices";
|
||||
import { isOverhaulMode, mountResultView } from "./fristenrechner-result";
|
||||
import { isOverhaulMode, mountModeShell, mountResultView } from "./fristenrechner-result";
|
||||
import {
|
||||
fetchScenarioFlags,
|
||||
patchScenarioFlags,
|
||||
type ScenarioFlagsDelta,
|
||||
} from "./scenario-flags";
|
||||
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
@@ -147,11 +152,12 @@ function bootOverhaulMode(): void {
|
||||
const courtId = params.get("court_id") || undefined;
|
||||
|
||||
if (!eventRef) {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (root) {
|
||||
root.hidden = false;
|
||||
root.innerHTML = `<div class="fristen-overhaul-nudge">${t("deadlines.overhaul.empty")}</div>`;
|
||||
}
|
||||
// No trigger event locked yet → show the mode tab pair + active
|
||||
// mode panel (S3 = Mode A direct search; S4 will add Mode B
|
||||
// wizard). The mode param in the URL picks which tab opens
|
||||
// first; default is search (S3).
|
||||
const mode = (params.get("mode") || "search") === "wizard" ? "wizard" : "search";
|
||||
void mountModeShell(mode);
|
||||
return;
|
||||
}
|
||||
void mountResultView({ eventRef, triggerDate, party, courtId });
|
||||
@@ -700,15 +706,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
// t-paliad-323 Slice S2 — Fristenrechner overhaul boot.
|
||||
// When ?overhaul=1 is set, hide the legacy three-step wizard /
|
||||
// Pathway A+B shells and mount the new result view in their place.
|
||||
// Deep-linkable via ?overhaul=1&event=<code>&trigger_date=…&project=…
|
||||
// (the trigger date defaults to today when omitted). S3 (Mode A
|
||||
// search) and S4 (Mode B wizard) will land users here once they
|
||||
// identify a trigger event — for now the surface is reached only
|
||||
// via deep link, but ?overhaul=1 alone shows the empty shell so
|
||||
// the path is exercisable end-to-end.
|
||||
// t-paliad-323 Slice S5 — Fristenrechner overhaul is the default
|
||||
// boot path. The legacy three-step wizard / Pathway A+B shells are
|
||||
// reachable only via `?legacy=1` for a two-week deprecation window
|
||||
// after this commit lands. Deep-linkable via
|
||||
// /tools/fristenrechner?event=<code>&trigger_date=…&project=…
|
||||
// for the result view, or via
|
||||
// /tools/fristenrechner → Mode A (search) tab
|
||||
// /tools/fristenrechner?mode=wizard → Mode B wizard tab
|
||||
// The `?overhaul=1` deep links from S2-S4 still resolve (the
|
||||
// detector treats absence of `?legacy=1` as "overhaul").
|
||||
if (isOverhaulMode()) {
|
||||
bootOverhaulMode();
|
||||
return;
|
||||
@@ -748,15 +755,25 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
const ccrFlag = document.getElementById("ccr-flag") as HTMLInputElement | null;
|
||||
if (ccrFlag) ccrFlag.addEventListener("change", () => {
|
||||
syncInfAmendEnabled();
|
||||
persistScenarioFlagFromCheckbox(ccrFlag, "with_ccr");
|
||||
scheduleProcCalc(0);
|
||||
});
|
||||
|
||||
const infAmendFlag = document.getElementById("inf-amend-flag") as HTMLInputElement | null;
|
||||
if (infAmendFlag) infAmendFlag.addEventListener("change", () => scheduleProcCalc(0));
|
||||
if (infAmendFlag) infAmendFlag.addEventListener("change", () => {
|
||||
persistScenarioFlagFromCheckbox(infAmendFlag, "with_amend");
|
||||
scheduleProcCalc(0);
|
||||
});
|
||||
const revAmendFlag = document.getElementById("rev-amend-flag") as HTMLInputElement | null;
|
||||
if (revAmendFlag) revAmendFlag.addEventListener("change", () => scheduleProcCalc(0));
|
||||
if (revAmendFlag) revAmendFlag.addEventListener("change", () => {
|
||||
persistScenarioFlagFromCheckbox(revAmendFlag, "with_amend");
|
||||
scheduleProcCalc(0);
|
||||
});
|
||||
const revCciFlag = document.getElementById("rev-cci-flag") as HTMLInputElement | null;
|
||||
if (revCciFlag) revCciFlag.addEventListener("change", () => scheduleProcCalc(0));
|
||||
if (revCciFlag) revCciFlag.addEventListener("change", () => {
|
||||
persistScenarioFlagFromCheckbox(revCciFlag, "with_cci");
|
||||
scheduleProcCalc(0);
|
||||
});
|
||||
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
if (courtPicker) courtPicker.addEventListener("change", () => scheduleProcCalc(0));
|
||||
|
||||
@@ -2378,6 +2395,11 @@ function selectProject(project: ProjectOption) {
|
||||
// a cascade already on screen (rare but possible via popstate) picks
|
||||
// up the new narrowing.
|
||||
triggerCascadeRefresh();
|
||||
// m/paliad#149 Phase 2 P0: pre-fill the scenario-flag checkboxes
|
||||
// from the project's persisted state (mig 154 SSoT). Fire-and-forget;
|
||||
// a network failure leaves the page in its default state and the
|
||||
// helper logs to console.
|
||||
void hydrateScenarioFlagsForProject(project.id);
|
||||
}
|
||||
|
||||
function selectAdhoc(forum: AdhocForum) {
|
||||
@@ -2388,6 +2410,43 @@ function selectAdhoc(forum: AdhocForum) {
|
||||
triggerCascadeRefresh();
|
||||
}
|
||||
|
||||
// m/paliad#149 Phase 2 P0 — load the project's persisted scenario_flags
|
||||
// (mig 154) and pre-tick the page-level flag checkboxes. The flag-id ↔
|
||||
// checkbox-id mapping mirrors readFlags() above: the page exposes two
|
||||
// distinct "with_amend" checkboxes (one nested under with_ccr for
|
||||
// upc.inf.cfi, one standalone for upc.rev.cfi), so we use selectedType
|
||||
// to decide which checkbox the persisted value belongs to.
|
||||
async function hydrateScenarioFlagsForProject(projectId: string): Promise<void> {
|
||||
const view = await fetchScenarioFlags(projectId);
|
||||
if (!view) return;
|
||||
const setChecked = (id: string, val: boolean | undefined): void => {
|
||||
const el = document.getElementById(id) as HTMLInputElement | null;
|
||||
if (!el) return;
|
||||
el.checked = val === true;
|
||||
};
|
||||
const ccr = view.flags["with_ccr"];
|
||||
const amend = view.flags["with_amend"];
|
||||
const cci = view.flags["with_cci"];
|
||||
setChecked("ccr-flag", ccr);
|
||||
// with_amend lands on the proceeding-specific checkbox; both share
|
||||
// the same scenario_flag key (the determinator narrows which proceeding
|
||||
// is actually active before the calculator reads the flags).
|
||||
setChecked("inf-amend-flag", amend);
|
||||
setChecked("rev-amend-flag", amend);
|
||||
setChecked("rev-cci-flag", cci);
|
||||
}
|
||||
|
||||
// persistScenarioFlagFromCheckbox writes the checkbox's current value
|
||||
// into the project's scenario_flags via PATCH. No-op when no project is
|
||||
// selected (kontextfrei mode); failure is logged and the UI keeps its
|
||||
// optimistic state — the next selectProject() / hydrate call will
|
||||
// reconcile if needed.
|
||||
function persistScenarioFlagFromCheckbox(el: HTMLInputElement, flagKey: string): void {
|
||||
if (currentStep1Context.kind !== "project" || !currentStep1Context.projectId) return;
|
||||
const delta: ScenarioFlagsDelta = { [flagKey]: el.checked };
|
||||
void patchScenarioFlags(currentStep1Context.projectId, delta);
|
||||
}
|
||||
|
||||
function clearStep1Context() {
|
||||
currentStep1Context = { kind: "none" };
|
||||
writeStep1ContextToURL(currentStep1Context);
|
||||
@@ -2653,33 +2712,16 @@ interface EventCategoryNode {
|
||||
let eventCategoryTree: EventCategoryNode[] | null = null;
|
||||
let eventCategoryFetchInflight: Promise<EventCategoryNode[]> | null = null;
|
||||
|
||||
// Top-level cascade roots that represent forward-looking workflows ("I
|
||||
// want to file X, what deadlines does my action trigger?") rather than
|
||||
// the backward-looking calc the Fristenrechner is built for ("event Y
|
||||
// happened, what deadlines spawn?"). m's 2026-05-20 ask (m/paliad#57):
|
||||
// remove these from the "Was ist passiert?" picker — they belong in a
|
||||
// future forward-workflow tool, not here. The DB rows stay so that
|
||||
// future tool can pick them back up; we just hide them at the UI layer.
|
||||
const HIDDEN_CASCADE_ROOTS: ReadonlySet<string> = new Set([
|
||||
"ich-moechte-einreichen",
|
||||
]);
|
||||
|
||||
// t-paliad-323 Slice S6: the cascade endpoint
|
||||
// /api/tools/fristenrechner/event-categories was retired alongside
|
||||
// HIDDEN_CASCADE_ROOTS. loadEventCategoryTree stays as a stub that
|
||||
// returns an empty tree — every caller below it sits in the legacy
|
||||
// Pathway B cascade which `?legacy=1` mode never boots into after
|
||||
// initB1Cascade's early-return guard (see L3598). The whole subtree
|
||||
// is dead-coded; a follow-up will lift it out wholesale.
|
||||
async function loadEventCategoryTree(): Promise<EventCategoryNode[]> {
|
||||
if (eventCategoryTree) return eventCategoryTree;
|
||||
if (eventCategoryFetchInflight) return eventCategoryFetchInflight;
|
||||
eventCategoryFetchInflight = (async () => {
|
||||
try {
|
||||
const r = await fetch("/api/tools/fristenrechner/event-categories");
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||||
const data = await r.json();
|
||||
const raw = (data.tree || []) as EventCategoryNode[];
|
||||
eventCategoryTree = raw.filter((n) => !HIDDEN_CASCADE_ROOTS.has(n.slug));
|
||||
return eventCategoryTree;
|
||||
} finally {
|
||||
eventCategoryFetchInflight = null;
|
||||
}
|
||||
})();
|
||||
return eventCategoryFetchInflight;
|
||||
eventCategoryTree = [];
|
||||
return eventCategoryTree;
|
||||
}
|
||||
|
||||
function readB1PathFromURL(): string {
|
||||
@@ -3594,30 +3636,14 @@ async function loadAndRenderB1() {
|
||||
}
|
||||
|
||||
async function initB1Cascade() {
|
||||
const panel = document.getElementById("fristen-b1-panel");
|
||||
if (!panel) return;
|
||||
|
||||
// t-paliad-180: mode-radio retired; the row-stack's mode-row click
|
||||
// handler drives tree↔filter routing. No standalone change listener
|
||||
// needed here — showBMode() triggers loadAndRenderB1 when the
|
||||
// pathway enters tree mode.
|
||||
|
||||
// Initial render if the URL already lands in tree mode.
|
||||
const sp = new URLSearchParams(window.location.search);
|
||||
if (sp.get("path") === "b" && sp.get("mode") === "tree") {
|
||||
loadAndRenderB1();
|
||||
}
|
||||
|
||||
// popstate restores the cascade depth.
|
||||
window.addEventListener("popstate", () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.get("path") === "b" && params.get("mode") === "tree") {
|
||||
// Always re-render — tree may not have loaded yet on first popstate.
|
||||
currentActiveRow = null;
|
||||
cascadeAutoWalkStopAfter = null;
|
||||
loadAndRenderB1();
|
||||
}
|
||||
});
|
||||
// t-paliad-323 Slice S6: the legacy Pathway B row-stack / cascade
|
||||
// is dead-coded. Mode A (S3) + Mode B wizard (S4) replace it; the
|
||||
// overhaul default boot (S5) handles every user route. Early-return
|
||||
// here keeps the legacy module imports linked (for ?legacy=1 entry)
|
||||
// while ensuring no cascade fetch / row-stack render fires. The
|
||||
// helper bodies stay for one cleanup follow-up that lifts the whole
|
||||
// subtree out.
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initB1Cascade);
|
||||
@@ -3718,23 +3744,11 @@ function getActiveForumsParam(): string {
|
||||
}
|
||||
|
||||
function initForumFilter() {
|
||||
// Hydrate from URL on first load.
|
||||
for (const slug of readForumsFromURL()) {
|
||||
activeForums.add(slug);
|
||||
}
|
||||
renderForumChips();
|
||||
|
||||
// Restore on browser nav.
|
||||
window.addEventListener("popstate", () => {
|
||||
activeForums.clear();
|
||||
for (const slug of readForumsFromURL()) {
|
||||
activeForums.add(slug);
|
||||
}
|
||||
renderForumChips();
|
||||
});
|
||||
|
||||
// Re-render labels on language change.
|
||||
onLangChange(() => renderForumChips());
|
||||
// t-paliad-323 Slice S6: dead-coded alongside initB1Cascade. The
|
||||
// legacy forum-chip strip lived in the Pathway B B2-search panel
|
||||
// which the overhaul has retired. Helper bodies stay for the
|
||||
// follow-up cleanup that lifts the whole Pathway B subtree.
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initForumFilter);
|
||||
@@ -4018,49 +4032,11 @@ async function persistInboxPref(ch: InboxChannel) {
|
||||
}
|
||||
|
||||
async function initInboxFilter() {
|
||||
// t-paliad-180: the standalone inbox chip strip is retired; inbox
|
||||
// state still drives cascade narrowing + B2 fine-bucket sync, just
|
||||
// surfaced through the row-stack row now. This init still hydrates
|
||||
// from URL / saved preference + wires the popstate restore.
|
||||
if (!document.getElementById("fristen-b1-panel")) return;
|
||||
|
||||
let initial: InboxChannel = readInboxFromURL();
|
||||
if (initial === null) {
|
||||
try {
|
||||
const resp = await fetch("/api/me", { credentials: "same-origin" });
|
||||
if (resp.ok) {
|
||||
const me = (await resp.json()) as { forum_pref?: string | null };
|
||||
if (me.forum_pref && INBOX_CHANNEL_VALUES.has(me.forum_pref)) {
|
||||
initial = me.forum_pref as InboxChannel;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Anonymous visitor or transient error — leave the chip unset.
|
||||
}
|
||||
}
|
||||
applyInboxFilter(initial);
|
||||
|
||||
// Sync B2 fine-bucket chips from the inbox on hydrate ONLY when the
|
||||
// URL doesn't explicitly carry ?forum=… — an explicit forum= comes
|
||||
// from a shared link and should win over the user's saved inbox
|
||||
// preference. initForumFilter (which runs first) has already
|
||||
// populated activeForums from URL forum=, so we leave it alone here.
|
||||
if (initial !== null && readForumsFromURL().length === 0) {
|
||||
applyFineForumsFromInbox(initial);
|
||||
writeForumsToURL(true);
|
||||
}
|
||||
|
||||
window.addEventListener("popstate", () => {
|
||||
const newInbox = readInboxFromURL();
|
||||
applyInboxFilter(newInbox);
|
||||
// popstate can land on a URL with inbox= but no forum= (the user
|
||||
// navigated to a state where derivation should re-apply). Don't
|
||||
// touch activeForums when forum= is explicit — initForumFilter's
|
||||
// own popstate handler has already loaded it from the URL.
|
||||
if (newInbox !== null && readForumsFromURL().length === 0) {
|
||||
applyFineForumsFromInbox(newInbox);
|
||||
}
|
||||
});
|
||||
// t-paliad-323 Slice S6: dead-coded alongside initB1Cascade /
|
||||
// initForumFilter. The inbox-channel row lived inside Pathway B's
|
||||
// row-stack which the overhaul has retired. Helper bodies stay
|
||||
// for the follow-up cleanup that lifts the whole subtree.
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", initInboxFilter);
|
||||
|
||||
@@ -1023,7 +1023,16 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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",
|
||||
@@ -1036,6 +1045,54 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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",
|
||||
@@ -4161,7 +4218,16 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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",
|
||||
@@ -4174,6 +4240,54 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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",
|
||||
|
||||
125
frontend/src/client/scenario-flags.ts
Normal file
125
frontend/src/client/scenario-flags.ts
Normal 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);
|
||||
}
|
||||
96
frontend/src/client/verfahrensablauf-detail-mode.test.ts
Normal file
96
frontend/src/client/verfahrensablauf-detail-mode.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
125
frontend/src/client/verfahrensablauf-detail-mode.ts
Normal file
125
frontend/src/client/verfahrensablauf-detail-mode.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,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 +61,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
|
||||
@@ -470,8 +505,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 +531,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 +828,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;
|
||||
@@ -908,17 +1064,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 +1101,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 +1159,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
|
||||
|
||||
@@ -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;
|
||||
@@ -613,13 +618,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}
|
||||
|
||||
@@ -1246,6 +1246,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"
|
||||
@@ -1259,12 +1261,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"
|
||||
@@ -1378,6 +1385,8 @@ export type I18nKey =
|
||||
| "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"
|
||||
@@ -1388,8 +1397,36 @@ export type I18nKey =
|
||||
| "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"
|
||||
@@ -1397,6 +1434,22 @@ export type I18nKey =
|
||||
| "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"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -327,6 +327,31 @@ export function renderVerfahrensablauf(): string {
|
||||
<span data-i18n="deadlines.step3">Ergebnis</span>
|
||||
</h3>
|
||||
|
||||
{/* m/paliad#149 Phase 2 P3 — three-way detail filter.
|
||||
Controls how much of the procedural shape renders:
|
||||
just mandatory; mandatory + selected (default); or
|
||||
every option including unselected ones rendered
|
||||
muted. State persists in localStorage under
|
||||
verfahrensablauf:view_mode. The toggle drives a
|
||||
client-side filter pre-render; the calc payload
|
||||
stays the same, so flipping is instant. */}
|
||||
<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">
|
||||
|
||||
21
internal/db/migrations/154_scenario_flags_ssot.down.sql
Normal file
21
internal/db/migrations/154_scenario_flags_ssot.down.sql
Normal 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;
|
||||
139
internal/db/migrations/154_scenario_flags_ssot.up.sql
Normal file
139
internal/db/migrations/154_scenario_flags_ssot.up.sql
Normal 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;
|
||||
43
internal/db/migrations/155_upc_apl_resplit.down.sql
Normal file
43
internal/db/migrations/155_upc_apl_resplit.down.sql
Normal 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;
|
||||
191
internal/db/migrations/155_upc_apl_resplit.up.sql
Normal file
191
internal/db/migrations/155_upc_apl_resplit.up.sql
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -204,6 +205,15 @@ func handleFristenrechnerCalculateRule(w http.ResponseWriter, r *http.Request) {
|
||||
// Returns 503 with an empty array when DATABASE_URL is unset so the page
|
||||
// still renders (buttons are server-rendered from tsx and don't depend on
|
||||
// this endpoint for existence, only for dynamic list updates).
|
||||
//
|
||||
// Optional query params (Fristenrechner overhaul S3, m/paliad#146):
|
||||
// jurisdiction - "UPC" | "DE" | "EPA" | "DPMA". Narrows the chip
|
||||
// pool to one jurisdiction. Empty = any.
|
||||
// kind - "proceeding" | "phase" | "side_action" | "meta".
|
||||
// Narrows to one structural kind from the taxonomy
|
||||
// cleanup (m/paliad#147, mig 153). Mode A passes
|
||||
// "proceeding" to exclude phase / side_action / meta
|
||||
// rows. Empty = any.
|
||||
func handleProceedingTypes(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.fristenrechner == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
@@ -211,7 +221,12 @@ func handleProceedingTypes(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
return
|
||||
}
|
||||
types, err := dbSvc.fristenrechner.ListFristenrechnerTypes(r.Context())
|
||||
opts := services.ProceedingListOptions{
|
||||
Jurisdiction: strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("jurisdiction"))),
|
||||
Kind: strings.TrimSpace(r.URL.Query().Get("kind")),
|
||||
EventKind: strings.TrimSpace(r.URL.Query().Get("event_kind")),
|
||||
}
|
||||
types, err := dbSvc.fristenrechner.ListProceedings(r.Context(), opts)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "konnte Verfahrenstypen nicht laden"})
|
||||
return
|
||||
@@ -238,7 +253,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).",
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -137,6 +137,11 @@ 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
|
||||
|
||||
// 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
|
||||
@@ -206,6 +211,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
submissionBuildingBlock: svc.SubmissionBuildingBlock,
|
||||
eventChoice: svc.EventChoice,
|
||||
scenario: svc.Scenario,
|
||||
scenarioFlags: svc.ScenarioFlags,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +314,13 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/tools/courts", handleCourtsList)
|
||||
protected.HandleFunc("GET /api/tools/fristenrechner/search", handleFristenrechnerSearch)
|
||||
protected.HandleFunc("GET /api/tools/fristenrechner/follow-ups", handleFristenrechnerFollowUps)
|
||||
protected.HandleFunc("GET /api/tools/fristenrechner/event-categories", handleFristenrechnerEventCategories)
|
||||
// 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)
|
||||
@@ -376,6 +388,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.
|
||||
|
||||
@@ -82,6 +82,9 @@ type dbServices struct {
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
85
internal/handlers/scenario_flags.go
Normal file
85
internal/handlers/scenario_flags.go
Normal 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)
|
||||
}
|
||||
136
internal/services/condition_expr_validator.go
Normal file
136
internal/services/condition_expr_validator.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// condition_expr grammar per design §4.1 (m/paliad#149 Phase 2 P2):
|
||||
//
|
||||
// CondExpr := { "flag": "<known_flag>" }
|
||||
// | { "op": "and"|"or", "args": [<CondExpr>, <CondExpr>, ...] }
|
||||
//
|
||||
// Leaf nodes reference a flag in paliad.scenario_flag_catalog by key.
|
||||
// Composite nodes are recursive — and/or take ≥1 arg each. JSON null
|
||||
// (or empty bytes) is also accepted — that's the "no gate" shape and
|
||||
// stores as a NULL column.
|
||||
//
|
||||
// The validator is called from RuleEditorService.Create and
|
||||
// UpdateDraft before the row is written. Surfaces friendly errors
|
||||
// wrapping ErrInvalidInput so the handler maps cleanly to 400.
|
||||
|
||||
// ValidateConditionExpr parses the bytes as a CondExpr and verifies
|
||||
// every leaf flag is present in the scenario_flag_catalog (one DB
|
||||
// lookup, regardless of expression depth). Empty/null input is OK —
|
||||
// caller stores NULL.
|
||||
func ValidateConditionExpr(ctx context.Context, db *sqlx.DB, raw json.RawMessage) error {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil
|
||||
}
|
||||
var parsed condExprNode
|
||||
if err := json.Unmarshal(raw, &parsed); err != nil {
|
||||
return fmt.Errorf("%w: condition_expr is not valid JSON: %v", ErrInvalidInput, err)
|
||||
}
|
||||
flagNames := map[string]struct{}{}
|
||||
if err := walkCondExpr(&parsed, flagNames); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(flagNames) == 0 {
|
||||
// Empty leaf set is impossible for a valid CondExpr — walkCondExpr
|
||||
// would have rejected it. Defensive belt-and-braces.
|
||||
return fmt.Errorf("%w: condition_expr resolved to zero leaf flags", ErrInvalidInput)
|
||||
}
|
||||
keys := make([]string, 0, len(flagNames))
|
||||
for k := range flagNames {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
known, err := loadCatalogFlagKeys(ctx, db, keys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range keys {
|
||||
if _, ok := known[k]; !ok {
|
||||
return fmt.Errorf("%w: condition_expr references unknown flag %q (not in paliad.scenario_flag_catalog)", ErrInvalidInput, k)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// condExprNode is the loose-typed parse target. Either Flag is set
|
||||
// (leaf) or Op + Args (composite); the validator below enforces
|
||||
// mutual exclusivity.
|
||||
type condExprNode struct {
|
||||
Flag *string `json:"flag,omitempty"`
|
||||
Op *string `json:"op,omitempty"`
|
||||
Args []condExprNode `json:"args,omitempty"`
|
||||
// Extra catches stray keys so we can reject typos like "fla" or
|
||||
// "operator" loudly instead of silently treating them as composite.
|
||||
Extra map[string]json.RawMessage `json:"-"`
|
||||
}
|
||||
|
||||
// walkCondExpr descends the tree, collecting flag names and validating
|
||||
// every node's shape.
|
||||
func walkCondExpr(n *condExprNode, flagNames map[string]struct{}) error {
|
||||
hasFlag := n.Flag != nil
|
||||
hasOp := n.Op != nil
|
||||
hasArgs := n.Args != nil
|
||||
|
||||
if hasFlag && (hasOp || hasArgs) {
|
||||
return fmt.Errorf("%w: condition_expr node has both 'flag' and 'op'/'args' — leaf and composite shapes are mutually exclusive", ErrInvalidInput)
|
||||
}
|
||||
if !hasFlag && !hasOp {
|
||||
return fmt.Errorf("%w: condition_expr node must carry either 'flag' (leaf) or 'op'+'args' (composite)", ErrInvalidInput)
|
||||
}
|
||||
|
||||
if hasFlag {
|
||||
if *n.Flag == "" {
|
||||
return fmt.Errorf("%w: condition_expr leaf has empty flag", ErrInvalidInput)
|
||||
}
|
||||
flagNames[*n.Flag] = struct{}{}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Composite — op must be "and" or "or"; args must be non-empty.
|
||||
op := *n.Op
|
||||
if op != "and" && op != "or" {
|
||||
return fmt.Errorf("%w: condition_expr op=%q must be 'and' or 'or'", ErrInvalidInput, op)
|
||||
}
|
||||
if len(n.Args) == 0 {
|
||||
return fmt.Errorf("%w: condition_expr composite op=%q has empty args", ErrInvalidInput, op)
|
||||
}
|
||||
for i := range n.Args {
|
||||
if err := walkCondExpr(&n.Args[i], flagNames); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadCatalogFlagKeys returns the subset of `flagKeys` present in
|
||||
// paliad.scenario_flag_catalog. One round-trip regardless of how many
|
||||
// keys the expression carries.
|
||||
func loadCatalogFlagKeys(ctx context.Context, db *sqlx.DB, flagKeys []string) (map[string]struct{}, error) {
|
||||
if len(flagKeys) == 0 {
|
||||
return map[string]struct{}{}, nil
|
||||
}
|
||||
rows, err := db.QueryContext(ctx,
|
||||
`SELECT flag_key FROM paliad.scenario_flag_catalog WHERE flag_key = ANY($1)`,
|
||||
pq.Array(flagKeys))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup scenario_flag_catalog: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]struct{}{}
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = struct{}{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
166
internal/services/condition_expr_validator_test.go
Normal file
166
internal/services/condition_expr_validator_test.go
Normal file
@@ -0,0 +1,166 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
)
|
||||
|
||||
// openTestPool returns a sqlx.DB connected via TEST_DATABASE_URL.
|
||||
// Returns nil + skips the test when the env var is unset, mirroring
|
||||
// the pattern used by sibling live-DB tests in this package.
|
||||
func openTestPool(t *testing.T) *sqlx.DB {
|
||||
t.Helper()
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
return nil
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
return pool
|
||||
}
|
||||
|
||||
// TestValidateConditionExprShapes covers the grammar shapes (leaf,
|
||||
// composite, nested composite) and the rejection paths. The catalog
|
||||
// lookup is exercised via the live DB in TestValidateConditionExpr_Live18
|
||||
// below; here we use json-only shape checks to keep the unit tests
|
||||
// independent of database availability.
|
||||
func TestValidateConditionExprShapes(t *testing.T) {
|
||||
// Bypass the DB-backed flag-existence check by passing nil db with
|
||||
// an expression that has no leaves once unmarshalled. Since the
|
||||
// grammar walker rejects empty/invalid shapes BEFORE the DB lookup,
|
||||
// shape-only assertions work without a pool. For the leaf-flag
|
||||
// existence check we'd need a fixture DB — that's the live test.
|
||||
ctx := context.Background()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
wantError string // empty = success-path placeholder
|
||||
wantInvalid bool
|
||||
}{
|
||||
{name: "empty input", input: ``, wantInvalid: false},
|
||||
{name: "JSON null", input: `null`, wantInvalid: false},
|
||||
{name: "bad JSON", input: `{flag:`, wantInvalid: true, wantError: "valid JSON"},
|
||||
{name: "leaf with empty flag", input: `{"flag":""}`, wantInvalid: true, wantError: "empty flag"},
|
||||
{name: "leaf AND op", input: `{"flag":"x","op":"and"}`, wantInvalid: true, wantError: "mutually exclusive"},
|
||||
{name: "neither flag nor op", input: `{}`, wantInvalid: true, wantError: "must carry either"},
|
||||
{name: "bad op", input: `{"op":"xor","args":[{"flag":"x"}]}`, wantInvalid: true, wantError: "must be 'and' or 'or'"},
|
||||
{name: "empty args", input: `{"op":"and","args":[]}`, wantInvalid: true, wantError: "empty args"},
|
||||
{name: "nested bad shape", input: `{"op":"and","args":[{"flag":"x"},{"flag":""}]}`, wantInvalid: true, wantError: "empty flag"},
|
||||
}
|
||||
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
err := ValidateConditionExpr(ctx, nil, json.RawMessage(c.input))
|
||||
if c.wantInvalid {
|
||||
if err == nil {
|
||||
t.Fatalf("expected error, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("error %v is not ErrInvalidInput", err)
|
||||
}
|
||||
if c.wantError != "" && !strings.Contains(err.Error(), c.wantError) {
|
||||
t.Errorf("error %q missing substring %q", err.Error(), c.wantError)
|
||||
}
|
||||
return
|
||||
}
|
||||
// success-path: empty/null inputs go through without an err.
|
||||
// Anything else hits the DB lookup with nil pool → nil-deref;
|
||||
// that path is covered by the live test below.
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error for %q, got %v", c.input, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateConditionExpr_LiveCatalog runs the validator against the
|
||||
// real paliad.scenario_flag_catalog (the 3 seeded flags) using a sample
|
||||
// of each grammar shape. Skips when DATABASE_URL isn't set.
|
||||
func TestValidateConditionExpr_LiveCatalog(t *testing.T) {
|
||||
pool := openTestPool(t)
|
||||
if pool == nil {
|
||||
t.Skip("DATABASE_URL not set — skipping live-catalog validation")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
good := []string{
|
||||
`{"flag":"with_ccr"}`,
|
||||
`{"flag":"with_amend"}`,
|
||||
`{"flag":"with_cci"}`,
|
||||
`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`,
|
||||
`{"op":"or","args":[{"flag":"with_ccr"},{"flag":"with_cci"}]}`,
|
||||
`{"op":"and","args":[{"flag":"with_ccr"},{"op":"or","args":[{"flag":"with_amend"},{"flag":"with_cci"}]}]}`,
|
||||
}
|
||||
for _, g := range good {
|
||||
if err := ValidateConditionExpr(ctx, pool, json.RawMessage(g)); err != nil {
|
||||
t.Errorf("expected %q to validate, got %v", g, err)
|
||||
}
|
||||
}
|
||||
|
||||
bad := []struct{ in, contains string }{
|
||||
{`{"flag":"with_nonsense"}`, "unknown flag"},
|
||||
{`{"op":"and","args":[{"flag":"with_ccr"},{"flag":"never_seen"}]}`, "unknown flag"},
|
||||
}
|
||||
for _, b := range bad {
|
||||
err := ValidateConditionExpr(ctx, pool, json.RawMessage(b.in))
|
||||
if err == nil {
|
||||
t.Errorf("expected %q to fail validation", b.in)
|
||||
continue
|
||||
}
|
||||
if !strings.Contains(err.Error(), b.contains) {
|
||||
t.Errorf("error %q for %q missing substring %q", err.Error(), b.in, b.contains)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestConditionExpr_AllLiveRowsValidate exercises the validator on every
|
||||
// row currently in paliad.sequencing_rules. Per design §4.1: "all 18
|
||||
// existing rows must validate" — this test enforces the invariant on
|
||||
// every deploy so a new editorial entry that breaks the grammar fails
|
||||
// CI before it lands.
|
||||
func TestConditionExpr_AllLiveRowsValidate(t *testing.T) {
|
||||
pool := openTestPool(t)
|
||||
if pool == nil {
|
||||
t.Skip("DATABASE_URL not set — skipping live-rows test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
|
||||
rows, err := pool.QueryContext(ctx,
|
||||
`SELECT id, condition_expr::text
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE condition_expr IS NOT NULL
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'`)
|
||||
if err != nil {
|
||||
t.Fatalf("load condition_expr rows: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var id, expr string
|
||||
if err := rows.Scan(&id, &expr); err != nil {
|
||||
t.Fatalf("scan: %v", err)
|
||||
}
|
||||
count++
|
||||
if err := ValidateConditionExpr(ctx, pool, json.RawMessage(expr)); err != nil {
|
||||
t.Errorf("rule %s carries non-conforming condition_expr %s: %v", id, expr, err)
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
t.Fatalf("rows err: %v", err)
|
||||
}
|
||||
if count == 0 {
|
||||
t.Skip("no condition_expr rows in DB — nothing to validate")
|
||||
}
|
||||
t.Logf("validated %d live condition_expr rows", count)
|
||||
}
|
||||
@@ -82,13 +82,77 @@ func (s *FristenrechnerService) CalculateRule(ctx context.Context, params CalcRu
|
||||
// specific surface (the wire shape FristenrechnerType is owned by the
|
||||
// package but the SQL filter is paliad-side).
|
||||
func (s *FristenrechnerService) ListFristenrechnerTypes(ctx context.Context) ([]lp.FristenrechnerType, error) {
|
||||
rows, err := s.rules.db.QueryxContext(ctx, `
|
||||
SELECT code, name, name_en, jurisdiction
|
||||
FROM paliad.proceeding_types
|
||||
WHERE category = 'fristenrechner' AND is_active = true
|
||||
ORDER BY sort_order`)
|
||||
return s.ListProceedings(ctx, ProceedingListOptions{})
|
||||
}
|
||||
|
||||
// ProceedingListOptions narrows ListProceedings. Empty values = no
|
||||
// filter on that axis. Added for the Fristenrechner overhaul S3
|
||||
// (m/paliad#146): Mode A's "Verfahren" filter chip strip needs to scope
|
||||
// the proceeding pool by the user's Forum pick (jurisdiction) and by
|
||||
// kind='proceeding' to exclude the phase / side_action / meta rows
|
||||
// landed in the taxonomy cleanup (m/paliad#147, mig 153).
|
||||
type ProceedingListOptions struct {
|
||||
// Jurisdiction narrows to one jurisdiction code (UPC / DE / EPA /
|
||||
// DPMA). Empty = any.
|
||||
Jurisdiction string
|
||||
// Kind narrows to one structural kind (proceeding / phase /
|
||||
// side_action / meta). Mode A passes "proceeding" to exclude the
|
||||
// phase / side_action / meta rows from the chip strip. Empty = any.
|
||||
//
|
||||
// Filter referenced before mig 153 lands the column → callers
|
||||
// pre-mig get a "column kind does not exist" error from Postgres.
|
||||
// Sequenced per docs/design-proceeding-types-taxonomy-2026-05-26.md
|
||||
// §7 option (c): mig 153 merges to main before the S3 PR ships.
|
||||
Kind string
|
||||
// EventKind narrows to proceedings that have at least one published
|
||||
// sequencing rule anchored on a procedural event of the requested
|
||||
// kind ("filing" | "hearing" | "decision" | "order"). Powers the
|
||||
// Fristenrechner overhaul Mode B R3 wizard row (§3.2): after R1
|
||||
// picks an event_kind, R3 should only chip proceedings whose event
|
||||
// roster contains at least one event of that kind. Empty = no
|
||||
// event-kind narrowing.
|
||||
EventKind string
|
||||
}
|
||||
|
||||
// ListProceedings returns the proceeding_types chips the Fristenrechner
|
||||
// overhaul Mode A renders in its filter strip. Filters apply
|
||||
// progressively: pre-mig 153 Kind=="" is the safe default; post-mig 153
|
||||
// Mode A passes Kind="proceeding" to exclude the phase / side_action /
|
||||
// meta rows.
|
||||
func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts ProceedingListOptions) ([]lp.FristenrechnerType, error) {
|
||||
where := []string{
|
||||
"category = 'fristenrechner'",
|
||||
"is_active = true",
|
||||
}
|
||||
args := []any{}
|
||||
add := func(clause string, val any) {
|
||||
args = append(args, val)
|
||||
where = append(where, fmt.Sprintf(clause, len(args)))
|
||||
}
|
||||
if opts.Jurisdiction != "" {
|
||||
add("jurisdiction = $%d", opts.Jurisdiction)
|
||||
}
|
||||
if opts.Kind != "" {
|
||||
add("kind = $%d", opts.Kind)
|
||||
}
|
||||
if opts.EventKind != "" {
|
||||
add(`EXISTS (
|
||||
SELECT 1 FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE sr.proceeding_type_id = paliad.proceeding_types.id
|
||||
AND sr.is_active = true AND sr.lifecycle_state = 'published'
|
||||
AND pe.is_active = true AND pe.lifecycle_state = 'published'
|
||||
AND pe.event_kind = $%d
|
||||
)`, opts.EventKind)
|
||||
}
|
||||
query := `SELECT code, name, name_en, jurisdiction
|
||||
FROM paliad.proceeding_types
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
ORDER BY sort_order`
|
||||
|
||||
rows, err := s.rules.db.QueryxContext(ctx, query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list fristenrechner types: %w", err)
|
||||
return nil, fmt.Errorf("list proceedings: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
|
||||
@@ -52,6 +52,14 @@ type FollowUpRule struct {
|
||||
TitleEN string `json:"title_en"`
|
||||
Priority string `json:"priority"`
|
||||
PrimaryParty *string `json:"primary_party,omitempty"`
|
||||
// IsCrossParty is true when the requesting party is "claimant" or
|
||||
// "defendant" AND the rule's primary_party is the opposite side
|
||||
// (m/paliad#149 Phase 2 S1, design §2.4). Cross-party rows are
|
||||
// displayed with a `Gegenseitig` badge + muted style + unchecked
|
||||
// default, and are unconditionally excluded from the Akte
|
||||
// "Save as project deadlines" write-back. NULL/both/court rules
|
||||
// are never cross-party regardless of perspective.
|
||||
IsCrossParty bool `json:"is_cross_party"`
|
||||
DurationValue *int `json:"duration_value,omitempty"`
|
||||
DurationUnit *string `json:"duration_unit,omitempty"`
|
||||
Timing *string `json:"timing,omitempty"`
|
||||
@@ -80,10 +88,14 @@ type FollowUpRule struct {
|
||||
// (the sequencing_rule with procedural_event_id matching), then walks
|
||||
// one hop down via parent_id to collect immediate follow-ups.
|
||||
//
|
||||
// When party is non-empty, follow-ups are filtered to rules whose
|
||||
// primary_party matches OR is "both" (so a defendant filter still
|
||||
// returns bilateral procedural moves like Vertraulichkeitsantrag-
|
||||
// Erwiderung).
|
||||
// Cross-party display (m/paliad#149 Phase 2 S1, design §2.4): the server
|
||||
// returns ALL follow-ups regardless of party — including the opposing
|
||||
// side's filings — and annotates each row with `is_cross_party` so the
|
||||
// UI can render the Gegenseitig badge + muted style. The party param
|
||||
// is kept as a perspective qualifier (it drives is_cross_party computation
|
||||
// and remains in the response context), but no longer filters which
|
||||
// rows are returned. Honest UX: the workflow continues on the other
|
||||
// side and the lawyer can see what they file vs what they receive.
|
||||
func (s *FristenrechnerService) LookupFollowUps(
|
||||
ctx context.Context,
|
||||
eventRef string,
|
||||
@@ -116,7 +128,7 @@ func (s *FristenrechnerService) LookupFollowUps(
|
||||
// Pull the proceeding_type metadata once so we can pass it
|
||||
// downstream to populate the trigger card and to seed the
|
||||
// CalculateRule lookup (which uses RuleID anyway).
|
||||
rows, err := s.queryFollowUpRows(ctx, anchor.AnchorRuleID, party)
|
||||
rows, err := s.queryFollowUpRows(ctx, anchor.AnchorRuleID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -137,6 +149,7 @@ func (s *FristenrechnerService) LookupFollowUps(
|
||||
v := r.PrimaryParty.String
|
||||
fr.PrimaryParty = &v
|
||||
}
|
||||
fr.IsCrossParty = isCrossPartyRow(party, r.PrimaryParty)
|
||||
if r.DurationValue.Valid {
|
||||
v := int(r.DurationValue.Int32)
|
||||
fr.DurationValue = &v
|
||||
@@ -340,12 +353,18 @@ type followUpRow struct {
|
||||
}
|
||||
|
||||
// queryFollowUpRows pulls the immediate-children rules of an anchor.
|
||||
// Party filter is inclusive of "both" so bilateral moves stay visible
|
||||
// when the user picks claimant or defendant.
|
||||
//
|
||||
// Cross-party display (m/paliad#149 Phase 2 S1, design §2.4): no longer
|
||||
// filters by party. The server returns every published+active child;
|
||||
// LookupFollowUps annotates each row with `is_cross_party` so the UI
|
||||
// can render opposing-side rows with the Gegenseitig badge instead of
|
||||
// silently dropping them. Hiding cross-party rows 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, and the lawyer needs to see that move exists).
|
||||
func (s *FristenrechnerService) queryFollowUpRows(
|
||||
ctx context.Context,
|
||||
anchorRuleID uuid.UUID,
|
||||
party string,
|
||||
) ([]followUpRow, error) {
|
||||
where := []string{
|
||||
"sr.parent_id = $1",
|
||||
@@ -355,16 +374,6 @@ func (s *FristenrechnerService) queryFollowUpRows(
|
||||
"pe.lifecycle_state = 'published'",
|
||||
}
|
||||
args := []any{anchorRuleID}
|
||||
if party == "claimant" || party == "defendant" {
|
||||
args = append(args, party)
|
||||
where = append(where, fmt.Sprintf(
|
||||
"(sr.primary_party = $%d OR sr.primary_party = 'both' OR sr.primary_party IS NULL)",
|
||||
len(args)))
|
||||
} else if party != "" {
|
||||
// "court" / "both" — exact match
|
||||
args = append(args, party)
|
||||
where = append(where, fmt.Sprintf("sr.primary_party = $%d", len(args)))
|
||||
}
|
||||
|
||||
query := `
|
||||
SELECT sr.id AS rule_id,
|
||||
@@ -402,3 +411,31 @@ func (s *FristenrechnerService) queryFollowUpRows(
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// isCrossPartyRow reports whether the row represents an opposing-side
|
||||
// filing relative to the requesting perspective.
|
||||
//
|
||||
// Returns true only when:
|
||||
// - the perspective is "claimant" or "defendant" (the two
|
||||
// binary-opposed sides Paliad models today)
|
||||
// - the row's primary_party is the opposite side
|
||||
//
|
||||
// "both" / "court" / NULL primary_party are never cross-party — they
|
||||
// apply to all sides or to the court itself. An empty perspective
|
||||
// (kontextfrei / "I'm just browsing") also yields false: with no
|
||||
// perspective there is no opposing side. The flag is purely display
|
||||
// metadata; cross-party rows still appear in the result, just with the
|
||||
// Gegenseitig badge + muted styling per design §2.4.
|
||||
func isCrossPartyRow(perspective string, primaryParty sql.NullString) bool {
|
||||
if perspective != "claimant" && perspective != "defendant" {
|
||||
return false
|
||||
}
|
||||
if !primaryParty.Valid {
|
||||
return false
|
||||
}
|
||||
p := primaryParty.String
|
||||
if p == "" || p == "both" || p == "court" {
|
||||
return false
|
||||
}
|
||||
return p != perspective
|
||||
}
|
||||
|
||||
|
||||
156
internal/services/fristenrechner_proceedings_test.go
Normal file
156
internal/services/fristenrechner_proceedings_test.go
Normal file
@@ -0,0 +1,156 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// TestListProceedings covers the proceeding chip-pool query that powers
|
||||
// the Fristenrechner overhaul Mode A "Verfahren" filter strip (S3,
|
||||
// design §3.1). The legacy callers go through ListFristenrechnerTypes
|
||||
// (no filters) — that path stays green here. The new ListProceedings
|
||||
// API accepts Jurisdiction + Kind filters; the Kind filter requires
|
||||
// mig 153 to have landed, so this test skips the Kind=proceeding case
|
||||
// when the column doesn't yet exist.
|
||||
func TestListProceedings(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
holidays := NewHolidayService(pool)
|
||||
courts := NewCourtService(pool)
|
||||
fr := NewFristenrechnerService(rules, holidays, courts)
|
||||
|
||||
t.Run("no filters returns the legacy fristenrechner set", func(t *testing.T) {
|
||||
got, err := fr.ListProceedings(ctx, ProceedingListOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("list proceedings: %v", err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("expected non-empty proceeding list")
|
||||
}
|
||||
// Sanity — upc.inf.cfi should always be in the active set.
|
||||
found := false
|
||||
for _, p := range got {
|
||||
if p.Code == "upc.inf.cfi" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("upc.inf.cfi not in proceedings list")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("jurisdiction=UPC narrows to UPC-only", func(t *testing.T) {
|
||||
got, err := fr.ListProceedings(ctx, ProceedingListOptions{Jurisdiction: "UPC"})
|
||||
if err != nil {
|
||||
t.Fatalf("list proceedings UPC: %v", err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("expected UPC proceedings")
|
||||
}
|
||||
for _, p := range got {
|
||||
if p.Group != "UPC" {
|
||||
t.Errorf("non-UPC proceeding leaked: %s (group=%q)", p.Code, p.Group)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("jurisdiction=DE returns DE proceedings", func(t *testing.T) {
|
||||
got, err := fr.ListProceedings(ctx, ProceedingListOptions{Jurisdiction: "DE"})
|
||||
if err != nil {
|
||||
t.Fatalf("list proceedings DE: %v", err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("expected DE proceedings")
|
||||
}
|
||||
for _, p := range got {
|
||||
if p.Group != "DE" {
|
||||
t.Errorf("non-DE proceeding leaked: %s (group=%q)", p.Code, p.Group)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ListFristenrechnerTypes legacy alias still works", func(t *testing.T) {
|
||||
got, err := fr.ListFristenrechnerTypes(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("list fristenrechner types: %v", err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("expected non-empty types")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("kind=proceeding narrows to primary proceedings only", func(t *testing.T) {
|
||||
got, err := fr.ListProceedings(ctx, ProceedingListOptions{Kind: "proceeding"})
|
||||
if err != nil {
|
||||
t.Fatalf("list proceedings kind=proceeding: %v", err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("expected non-empty primary-proceeding list")
|
||||
}
|
||||
// upc.inf.cfi is unambiguously a primary proceeding — must
|
||||
// survive the filter.
|
||||
found := false
|
||||
for _, p := range got {
|
||||
if p.Code == "upc.inf.cfi" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("upc.inf.cfi missing from kind=proceeding list")
|
||||
}
|
||||
// upc.cfi.interim is the canonical phase row (per mig 153 +
|
||||
// taxonomy doc §0.4 Group B) — must NOT appear.
|
||||
for _, p := range got {
|
||||
if p.Code == "upc.cfi.interim" {
|
||||
t.Errorf("phase row upc.cfi.interim leaked into kind=proceeding")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("event_kind=filing narrows to proceedings with filing events", func(t *testing.T) {
|
||||
got, err := fr.ListProceedings(ctx, ProceedingListOptions{
|
||||
Jurisdiction: "UPC",
|
||||
Kind: "proceeding",
|
||||
EventKind: "filing",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list proceedings UPC+filing: %v", err)
|
||||
}
|
||||
if len(got) == 0 {
|
||||
t.Fatalf("expected UPC proceedings with filing events")
|
||||
}
|
||||
// upc.inf.cfi has at least one rule anchored on a filing event
|
||||
// (Klageerhebung, SoD, etc.) — must survive.
|
||||
found := false
|
||||
for _, p := range got {
|
||||
if p.Code == "upc.inf.cfi" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("upc.inf.cfi missing from UPC + event_kind=filing list")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -104,6 +104,15 @@ func (s *DeadlineSearchService) SearchEvents(ctx context.Context, q string, opts
|
||||
"pe.is_active = true",
|
||||
"pe.lifecycle_state = 'published'",
|
||||
"pt.is_active = true",
|
||||
// S1a (m/paliad#149 Phase 2 design §2.2): spawn-only rules are
|
||||
// consequences, not triggers — a user who picks "Berufung
|
||||
// einlegen" wants the appeal-tree root, not the inf.cfi spawn
|
||||
// link that *opens* that tree. Filter them out at the picker.
|
||||
// Terminal leaves (Duplik etc.) stay pickable: their own anchor
|
||||
// rule is non-spawn, so they surface and their result-view
|
||||
// renders an empty follow-up list — honest UX per t-paliad-327
|
||||
// §3a.4 / the design's "stay pickable" carve-out.
|
||||
"sr.is_spawn = false",
|
||||
}
|
||||
args := []any{}
|
||||
add := func(clause string, val any) {
|
||||
|
||||
@@ -117,10 +117,13 @@ func TestLookupEvents(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
// Should hit the 7 rules under the unified upc.apl that
|
||||
// carry applies_to_target={endentscheidung} (Slice B1 mig 134).
|
||||
// Should hit the 7 merits-track rules that carry
|
||||
// applies_to_target={endentscheidung} (Slice B1 mig 134).
|
||||
// Post-mig 155 (m/paliad#149 P1): the unified upc.apl was split
|
||||
// back into merits/cost/order — the endentscheidung anchors live
|
||||
// under upc.apl.merits (id=11).
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected upc.apl endentscheidung rules after B1 mig")
|
||||
t.Fatal("expected upc.apl.merits endentscheidung rules after B1 mig")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
@@ -137,8 +140,8 @@ func TestLookupEvents(t *testing.T) {
|
||||
t.Errorf("anchor row %s missing endentscheidung target: %v",
|
||||
m.Rule.Name, m.Rule.AppliesToTarget)
|
||||
}
|
||||
if m.ProceedingType.Code != "upc.apl.unified" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.unified",
|
||||
if m.ProceedingType.Code != "upc.apl.merits" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.merits",
|
||||
m.Rule.Name, m.ProceedingType.Code)
|
||||
}
|
||||
}
|
||||
@@ -153,10 +156,11 @@ func TestLookupEvents(t *testing.T) {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
// mig 138 (t-paliad-303, m/paliad#134) extends the 7 merits-track
|
||||
// rules under upc.apl.unified with applies_to_target ⊇ {schadensbemessung}
|
||||
// because R.224 is uniform across substantive R.118 decisions.
|
||||
// rules with applies_to_target ⊇ {schadensbemessung} because
|
||||
// R.224 is uniform across substantive R.118 decisions. Post-mig
|
||||
// 155 the merits track lives at upc.apl.merits (id=11).
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected upc.apl schadensbemessung rules after mig 138 backfill")
|
||||
t.Fatal("expected upc.apl.merits schadensbemessung rules after mig 138 backfill")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
@@ -173,8 +177,8 @@ func TestLookupEvents(t *testing.T) {
|
||||
t.Errorf("anchor row %s missing schadensbemessung target: %v",
|
||||
m.Rule.Name, m.Rule.AppliesToTarget)
|
||||
}
|
||||
if m.ProceedingType.Code != "upc.apl.unified" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.unified",
|
||||
if m.ProceedingType.Code != "upc.apl.merits" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.merits",
|
||||
m.Rule.Name, m.ProceedingType.Code)
|
||||
}
|
||||
}
|
||||
@@ -189,11 +193,12 @@ func TestLookupEvents(t *testing.T) {
|
||||
t.Fatalf("LookupEvents: %v", err)
|
||||
}
|
||||
// mig 138 (t-paliad-303, m/paliad#134) extends the 7 order-track
|
||||
// rules under upc.apl.unified with applies_to_target ⊇ {bucheinsicht}
|
||||
// because R.220.2 / R.224.2.b / R.235.2 / R.237 / R.238.2 are
|
||||
// uniform across the orders they appeal.
|
||||
// rules with applies_to_target ⊇ {bucheinsicht} because R.220.2 /
|
||||
// R.224.2.b / R.235.2 / R.237 / R.238.2 are uniform across the
|
||||
// orders they appeal. Post-mig 155 the order track lives at
|
||||
// upc.apl.order (id=20).
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("expected upc.apl bucheinsicht rules after mig 138 backfill")
|
||||
t.Fatal("expected upc.apl.order bucheinsicht rules after mig 138 backfill")
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.DepthFromAnchor != 1 {
|
||||
@@ -210,8 +215,8 @@ func TestLookupEvents(t *testing.T) {
|
||||
t.Errorf("anchor row %s missing bucheinsicht target: %v",
|
||||
m.Rule.Name, m.Rule.AppliesToTarget)
|
||||
}
|
||||
if m.ProceedingType.Code != "upc.apl.unified" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.unified",
|
||||
if m.ProceedingType.Code != "upc.apl.order" {
|
||||
t.Errorf("anchor row %s came from %s, want upc.apl.order",
|
||||
m.Rule.Name, m.ProceedingType.Code)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -213,6 +213,15 @@ func (s *RuleEditorService) Create(ctx context.Context, input CreateRuleInput, r
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// m/paliad#149 Phase 2 P2 (design §4.1) — lock the condition_expr
|
||||
// grammar to leaf {flag} or composite {op:'and'|'or', args:[…]}.
|
||||
// Surfaces an ErrInvalidInput before the row hits the DB so the
|
||||
// rule editor gets a friendly 400 instead of relying on a future
|
||||
// jsonb CHECK constraint that would land as a generic 500.
|
||||
if err := ValidateConditionExpr(ctx, s.db, input.ConditionExpr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
@@ -310,6 +319,15 @@ func (s *RuleEditorService) UpdateDraft(ctx context.Context, id uuid.UUID, patch
|
||||
}
|
||||
}
|
||||
|
||||
// m/paliad#149 Phase 2 P2 (design §4.1) — validate condition_expr
|
||||
// patches. Nil patch field = "don't change" (no validation needed);
|
||||
// non-nil = the new value must match the grammar.
|
||||
if patch.ConditionExpr != nil {
|
||||
if err := ValidateConditionExpr(ctx, s.db, patch.ConditionExpr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
|
||||
375
internal/services/scenario_flags_service.go
Normal file
375
internal/services/scenario_flags_service.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
"github.com/lib/pq"
|
||||
)
|
||||
|
||||
// ScenarioFlagsService owns the per-project scenario state — the
|
||||
// single source of truth introduced in mig 154 (m/paliad#149 Phase 2 P0).
|
||||
//
|
||||
// The state lives in paliad.projects.scenario_flags (jsonb object) and
|
||||
// carries two key shapes:
|
||||
//
|
||||
// - **Named flags** — keys whose name appears in paliad.scenario_flag_catalog
|
||||
// (today: with_ccr / with_amend / with_cci). These drive condition_expr
|
||||
// evaluation in pkg/litigationplanner and the Verfahrensablauf
|
||||
// scenario-strip UI.
|
||||
//
|
||||
// - **Per-rule selection deviations** — keys of shape "rule:<uuid>".
|
||||
// They record an explicit deviation from the rule's priority-driven
|
||||
// default (mandatory always selected; recommended default-selected;
|
||||
// optional default-unselected). The UUID must resolve to an
|
||||
// active+published sequencing_rule on the project's proceeding type.
|
||||
//
|
||||
// Values are always JSON booleans. Missing keys take the priority-driven
|
||||
// default — the absence of an entry is the absence of a deviation.
|
||||
//
|
||||
// All writes go through Patch (PATCH semantics: keys not in the delta are
|
||||
// left untouched; passing `null` for a key deletes it from the map so the
|
||||
// default behaviour returns). Patch validates every key + every UUID
|
||||
// before persisting; a single bad key fails the whole patch.
|
||||
type ScenarioFlagsService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
}
|
||||
|
||||
func NewScenarioFlagsService(db *sqlx.DB, projects *ProjectService) *ScenarioFlagsService {
|
||||
return &ScenarioFlagsService{db: db, projects: projects}
|
||||
}
|
||||
|
||||
// ScenarioFlagCatalogEntry mirrors one row of paliad.scenario_flag_catalog.
|
||||
type ScenarioFlagCatalogEntry struct {
|
||||
FlagKey string `db:"flag_key" json:"flag_key"`
|
||||
LabelDE string `db:"label_de" json:"label_de"`
|
||||
LabelEN string `db:"label_en" json:"label_en"`
|
||||
Description *string `db:"description" json:"description,omitempty"`
|
||||
HiddenUnlessSet bool `db:"hidden_unless_set" json:"hidden_unless_set"`
|
||||
}
|
||||
|
||||
// ScenarioFlagsView is the GET response shape — the live flag map plus
|
||||
// the catalog the UI needs to render the scenario-flags strip.
|
||||
type ScenarioFlagsView struct {
|
||||
Flags map[string]bool `json:"flags"`
|
||||
Catalog []ScenarioFlagCatalogEntry `json:"catalog"`
|
||||
}
|
||||
|
||||
// rulePrefix is the prefix that distinguishes a per-rule selection
|
||||
// entry from a named flag. Kept lowercase to match the catalog's
|
||||
// CHECK constraint pattern.
|
||||
const rulePrefix = "rule:"
|
||||
|
||||
// ruleKeyRe parses "rule:<uuid>" into the UUID portion. Uses the
|
||||
// case-insensitive uuid regex so callers can paste either lower or
|
||||
// uppercase UUIDs.
|
||||
var ruleKeyRe = regexp.MustCompile(`^rule:([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$`)
|
||||
|
||||
// Get returns the current scenario state for a project. Visibility-gated
|
||||
// via paliad.can_see_project (mirrors EventChoiceService.requireProjectVisible).
|
||||
//
|
||||
// The returned map is never nil; an empty object means "every rule takes
|
||||
// the priority-driven default". The catalog is always populated so the
|
||||
// UI can render the scenario-strip without a second round-trip.
|
||||
func (s *ScenarioFlagsService) Get(ctx context.Context, userID, projectID uuid.UUID) (*ScenarioFlagsView, error) {
|
||||
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var raw []byte
|
||||
err := s.db.GetContext(ctx, &raw,
|
||||
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read scenario_flags: %w", err)
|
||||
}
|
||||
|
||||
flags, err := decodeFlagMap(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode scenario_flags: %w", err)
|
||||
}
|
||||
|
||||
catalog, err := s.ListCatalog(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ScenarioFlagsView{Flags: flags, Catalog: catalog}, nil
|
||||
}
|
||||
|
||||
// ListCatalog returns every paliad.scenario_flag_catalog row, ordered by
|
||||
// added_at so the seeded with_ccr / with_amend / with_cci tier surfaces
|
||||
// first and later-added flags appear after.
|
||||
func (s *ScenarioFlagsService) ListCatalog(ctx context.Context) ([]ScenarioFlagCatalogEntry, error) {
|
||||
out := []ScenarioFlagCatalogEntry{}
|
||||
if err := s.db.SelectContext(ctx, &out,
|
||||
`SELECT flag_key, label_de, label_en, description, hidden_unless_set
|
||||
FROM paliad.scenario_flag_catalog
|
||||
ORDER BY added_at ASC, flag_key ASC`); err != nil {
|
||||
return nil, fmt.Errorf("list flag catalog: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Patch merges a partial delta into the project's scenario_flags. Per
|
||||
// the design (§2.3): keys not in the delta are left untouched; a key
|
||||
// set to `nil` (JSON null) is deleted from the map so the default
|
||||
// returns; bool values are stored verbatim.
|
||||
//
|
||||
// Every key in the delta is validated before any write happens:
|
||||
//
|
||||
// - keys matching "rule:<uuid>" must resolve to an active+published
|
||||
// sequencing_rule whose proceeding_type matches the project's
|
||||
// proceeding_type_id;
|
||||
// - all other keys must appear in paliad.scenario_flag_catalog.
|
||||
//
|
||||
// Bad keys raise ErrInvalidInput with a message that names the offending
|
||||
// key. The whole patch is rejected on the first bad key — no partial
|
||||
// writes.
|
||||
func (s *ScenarioFlagsService) Patch(
|
||||
ctx context.Context,
|
||||
userID, projectID uuid.UUID,
|
||||
delta map[string]*bool,
|
||||
) (*ScenarioFlagsView, error) {
|
||||
if err := s.requireProjectVisible(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(delta) == 0 {
|
||||
return s.Get(ctx, userID, projectID)
|
||||
}
|
||||
|
||||
if err := s.validateDelta(ctx, projectID, delta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if err := setAuditReasonTx(ctx, tx,
|
||||
fmt.Sprintf("scenario-flags PATCH by user %s on project %s", userID, projectID)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var raw []byte
|
||||
if err := tx.GetContext(ctx, &raw,
|
||||
`SELECT scenario_flags FROM paliad.projects WHERE id = $1 FOR UPDATE`,
|
||||
projectID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
return nil, fmt.Errorf("lock project row: %w", err)
|
||||
}
|
||||
|
||||
current, err := decodeFlagMap(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("decode current scenario_flags: %w", err)
|
||||
}
|
||||
|
||||
for k, v := range delta {
|
||||
if v == nil {
|
||||
delete(current, k)
|
||||
continue
|
||||
}
|
||||
current[k] = *v
|
||||
}
|
||||
|
||||
merged, err := json.Marshal(current)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("encode merged scenario_flags: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.projects
|
||||
SET scenario_flags = $1::jsonb,
|
||||
updated_at = now()
|
||||
WHERE id = $2`, merged, projectID); err != nil {
|
||||
return nil, fmt.Errorf("write scenario_flags: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit scenario-flags patch: %w", err)
|
||||
}
|
||||
|
||||
catalog, err := s.ListCatalog(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ScenarioFlagsView{Flags: current, Catalog: catalog}, nil
|
||||
}
|
||||
|
||||
// validateDelta runs every key in the delta through the appropriate
|
||||
// validator. Returns the first error it finds — callers receive
|
||||
// ErrInvalidInput wrapped with the offending key.
|
||||
func (s *ScenarioFlagsService) validateDelta(
|
||||
ctx context.Context,
|
||||
projectID uuid.UUID,
|
||||
delta map[string]*bool,
|
||||
) error {
|
||||
var (
|
||||
ruleUUIDs []uuid.UUID
|
||||
flagKeys []string
|
||||
ruleIDsKey = map[string]uuid.UUID{}
|
||||
)
|
||||
for k := range delta {
|
||||
if k == "" {
|
||||
return fmt.Errorf("%w: empty key in scenario_flags delta", ErrInvalidInput)
|
||||
}
|
||||
if m := ruleKeyRe.FindStringSubmatch(k); m != nil {
|
||||
u, err := uuid.Parse(m[1])
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %q has malformed UUID", ErrInvalidInput, k)
|
||||
}
|
||||
ruleUUIDs = append(ruleUUIDs, u)
|
||||
ruleIDsKey[k] = u
|
||||
continue
|
||||
}
|
||||
flagKeys = append(flagKeys, k)
|
||||
}
|
||||
|
||||
if len(flagKeys) > 0 {
|
||||
known, err := s.knownFlagKeys(ctx, flagKeys)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, k := range flagKeys {
|
||||
if _, ok := known[k]; !ok {
|
||||
return fmt.Errorf("%w: scenario flag %q is not in scenario_flag_catalog", ErrInvalidInput, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(ruleUUIDs) > 0 {
|
||||
if err := s.validateRuleUUIDs(ctx, projectID, ruleUUIDs, ruleIDsKey, delta); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// knownFlagKeys returns the subset of `flagKeys` that exists in the
|
||||
// catalog. Used to reject writes that name unknown flags.
|
||||
func (s *ScenarioFlagsService) knownFlagKeys(ctx context.Context, flagKeys []string) (map[string]struct{}, error) {
|
||||
if len(flagKeys) == 0 {
|
||||
return map[string]struct{}{}, nil
|
||||
}
|
||||
rows, err := s.db.QueryContext(ctx,
|
||||
`SELECT flag_key FROM paliad.scenario_flag_catalog WHERE flag_key = ANY($1)`,
|
||||
pq.Array(flagKeys))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("lookup flag catalog: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]struct{}{}
|
||||
for rows.Next() {
|
||||
var k string
|
||||
if err := rows.Scan(&k); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[k] = struct{}{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// validateRuleUUIDs ensures every rule:<uuid> entry in the delta
|
||||
// references a sequencing_rule that:
|
||||
//
|
||||
// 1. exists, is active, and lifecycle_state='published'
|
||||
// 2. belongs to the project's current proceeding_type_id
|
||||
// 3. is NOT priority='mandatory' when the value is `false` (mandatory
|
||||
// rules cannot be deselected — that's a UX lie disguised as data)
|
||||
func (s *ScenarioFlagsService) validateRuleUUIDs(
|
||||
ctx context.Context,
|
||||
projectID uuid.UUID,
|
||||
ids []uuid.UUID,
|
||||
keyByUUID map[string]uuid.UUID,
|
||||
delta map[string]*bool,
|
||||
) error {
|
||||
var ptID sql.NullInt64
|
||||
if err := s.db.GetContext(ctx, &ptID,
|
||||
`SELECT proceeding_type_id FROM paliad.projects WHERE id = $1`,
|
||||
projectID); err != nil {
|
||||
return fmt.Errorf("load project proceeding_type_id: %w", err)
|
||||
}
|
||||
if !ptID.Valid {
|
||||
return fmt.Errorf("%w: project %s has no proceeding_type_id — per-rule selection entries require one", ErrInvalidInput, projectID)
|
||||
}
|
||||
|
||||
type row struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
Priority string `db:"priority"`
|
||||
}
|
||||
rows := []row{}
|
||||
idStrs := make([]string, len(ids))
|
||||
for i, id := range ids {
|
||||
idStrs[i] = id.String()
|
||||
}
|
||||
if err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT id, priority
|
||||
FROM paliad.sequencing_rules
|
||||
WHERE id = ANY($1::uuid[])
|
||||
AND proceeding_type_id = $2
|
||||
AND is_active = true
|
||||
AND lifecycle_state = 'published'`,
|
||||
pq.Array(idStrs), ptID.Int64); err != nil {
|
||||
return fmt.Errorf("validate rule UUIDs: %w", err)
|
||||
}
|
||||
priorityByID := make(map[uuid.UUID]string, len(rows))
|
||||
for _, r := range rows {
|
||||
priorityByID[r.ID] = r.Priority
|
||||
}
|
||||
for key, id := range keyByUUID {
|
||||
prio, ok := priorityByID[id]
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: rule %s is not an active+published rule on the project's proceeding type", ErrInvalidInput, id)
|
||||
}
|
||||
val := delta[key]
|
||||
if val != nil && !*val && prio == "mandatory" {
|
||||
return fmt.Errorf("%w: rule %s is mandatory and cannot be deselected", ErrInvalidInput, id)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *ScenarioFlagsService) requireProjectVisible(ctx context.Context, userID, projectID uuid.UUID) error {
|
||||
visible, err := s.projects.CanSee(ctx, userID, projectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !visible {
|
||||
return ErrNotVisible
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeFlagMap returns a (key → bool) map from the raw jsonb bytes.
|
||||
// Stored values that aren't bool are silently dropped — they should
|
||||
// never occur (the service rejects them on write) but defensive read
|
||||
// avoids crashing the API if a hand-written row sneaks through.
|
||||
func decodeFlagMap(raw []byte) (map[string]bool, error) {
|
||||
if len(raw) == 0 {
|
||||
return map[string]bool{}, nil
|
||||
}
|
||||
var anyMap map[string]any
|
||||
if err := json.Unmarshal(raw, &anyMap); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make(map[string]bool, len(anyMap))
|
||||
for k, v := range anyMap {
|
||||
if b, ok := v.(bool); ok {
|
||||
out[k] = b
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -30,18 +30,29 @@ func (h SnapshotHoliday) appliesTo(country, regime string) bool {
|
||||
}
|
||||
|
||||
func (h SnapshotHoliday) isVacation() bool { return h.HolidayType == "vacation" }
|
||||
func (h SnapshotHoliday) isClosure() bool { return h.HolidayType == "closure" }
|
||||
|
||||
// isClosure accepts both "public_holiday" and "closure" so the
|
||||
// embedded calendar matches paliad's HolidayService.IsClosure
|
||||
// reconciliation (internal/services/holidays.go ~L132). Live DB rows
|
||||
// use "public_holiday"; "closure" is kept as a legacy synonym so old
|
||||
// hand-crafted snapshots still parse correctly.
|
||||
func (h SnapshotHoliday) isClosure() bool {
|
||||
return h.HolidayType == "public_holiday" || h.HolidayType == "closure"
|
||||
}
|
||||
|
||||
// SnapshotHolidayCalendar serves HolidayCalendar against the embedded
|
||||
// holiday slice. The semantics mirror paliad's HolidayService:
|
||||
//
|
||||
// - IsNonWorkingDay = weekend OR a closure/vacation row matching
|
||||
// the (country, regime) pair
|
||||
// - IsNonWorkingDay = weekend OR a closure row matching the
|
||||
// (country, regime) pair. "Vacation" rows are informational only
|
||||
// and do not block — see t-paliad-121 / IsNonWorkingDay godoc.
|
||||
// - AdjustForNonWorkingDays = walk forward day-by-day until
|
||||
// IsNonWorkingDay returns false (bounded at 60 iters)
|
||||
// - AdjustForNonWorkingDaysBackward = same but stepping -1 day
|
||||
// - AdjustForNonWorkingDaysWithReason = forward walk + structured
|
||||
// reason payload (vacation > public_holiday > weekend)
|
||||
// reason payload (vacation > public_holiday > weekend) — vacation
|
||||
// kind fires only when a vacation row overlaps a weekend or
|
||||
// closure that is doing the rolling.
|
||||
type SnapshotHolidayCalendar struct {
|
||||
byDate map[string][]SnapshotHoliday // keyed by YYYY-MM-DD
|
||||
}
|
||||
@@ -60,8 +71,18 @@ func NewHolidayCalendar() (*SnapshotHolidayCalendar, error) {
|
||||
return cal, nil
|
||||
}
|
||||
|
||||
// IsNonWorkingDay returns true on weekends or closure/vacation
|
||||
// holidays applicable to the given country/regime.
|
||||
// IsNonWorkingDay returns true on weekends or closure-type holidays
|
||||
// applicable to the given (country, regime).
|
||||
//
|
||||
// "Vacation" entries (today: UPC summer + winter judicial vacations
|
||||
// per the UPC AC decision on judicial vacations 2023-05-26) are
|
||||
// deliberately excluded — the Court continues to operate during them
|
||||
// and they do not extend procedural deadlines (RoP / AC decision-on-
|
||||
// judicial-vacation). They stay in holidays.json as informational
|
||||
// metadata so callers can still surface "this date overlaps with UPC
|
||||
// vacation" if they want. Mirrors HolidayService.IsNonWorkingDay in
|
||||
// internal/services — see t-paliad-121 for the policy decision and
|
||||
// t-paliad-332 for the snapshot-side alignment.
|
||||
func (c *SnapshotHolidayCalendar) IsNonWorkingDay(date time.Time, country, regime string) bool {
|
||||
if wd := date.Weekday(); wd == time.Saturday || wd == time.Sunday {
|
||||
return true
|
||||
@@ -71,7 +92,7 @@ func (c *SnapshotHolidayCalendar) IsNonWorkingDay(date time.Time, country, regim
|
||||
if !h.appliesTo(country, regime) {
|
||||
continue
|
||||
}
|
||||
if h.isClosure() || h.isVacation() {
|
||||
if h.isClosure() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,30 +3,330 @@
|
||||
"date": "2026-01-01",
|
||||
"name": "Neujahr",
|
||||
"country": "DE",
|
||||
"holiday_type": "closure"
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2026-04-03",
|
||||
"name": "Karfreitag",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2026-04-05",
|
||||
"name": "Ostersonntag",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2026-04-06",
|
||||
"name": "Ostermontag",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-01",
|
||||
"name": "Tag der Arbeit",
|
||||
"country": "DE",
|
||||
"holiday_type": "closure"
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-14",
|
||||
"name": "Christi Himmelfahrt",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-24",
|
||||
"name": "Pfingstsonntag",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2026-05-25",
|
||||
"name": "Pfingstmontag",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-27",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-28",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-29",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-30",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-07-31",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-03",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-04",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-05",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-06",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-07",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-10",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-11",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-12",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-13",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-14",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-17",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-18",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-19",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-20",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-21",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-24",
|
||||
"name": "UPC Sommerpause",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-25",
|
||||
"name": "UPC Sommerpause",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-26",
|
||||
"name": "UPC Sommerpause",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-27",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-08-28",
|
||||
"name": "UPC Summer Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-10-03",
|
||||
"name": "Tag der Deutschen Einheit",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2026-12-24",
|
||||
"name": "UPC Winter Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-12-25",
|
||||
"name": "1. Weihnachtstag",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2026-12-26",
|
||||
"name": "2. Weihnachtstag",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2026-12-28",
|
||||
"name": "UPC Winter Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-12-29",
|
||||
"name": "UPC Winter Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-12-30",
|
||||
"name": "UPC Winter Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2026-12-31",
|
||||
"name": "UPC Winter Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2027-01-01",
|
||||
"name": "Neujahr",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2027-01-04",
|
||||
"name": "UPC Winter Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2027-01-05",
|
||||
"name": "UPC Winter Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2027-01-06",
|
||||
"name": "UPC Winter Vacation",
|
||||
"regime": "UPC",
|
||||
"holiday_type": "vacation"
|
||||
},
|
||||
{
|
||||
"date": "2027-03-26",
|
||||
"name": "Karfreitag",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2027-03-28",
|
||||
"name": "Ostersonntag",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2027-03-29",
|
||||
"name": "Ostermontag",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2027-05-01",
|
||||
"name": "Tag der Arbeit",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2027-05-06",
|
||||
"name": "Christi Himmelfahrt",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2027-05-16",
|
||||
"name": "Pfingstsonntag",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2027-05-17",
|
||||
"name": "Pfingstmontag",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2027-10-03",
|
||||
"name": "Tag der Deutschen Einheit",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2027-12-25",
|
||||
"name": "1. Weihnachtstag",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
},
|
||||
{
|
||||
"date": "2027-12-26",
|
||||
"name": "2. Weihnachtstag",
|
||||
"country": "DE",
|
||||
"holiday_type": "public_holiday"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"version": "2026-05-26-1-placeholder",
|
||||
"generated_at": "2026-05-26T15:00:00Z",
|
||||
"version": "2026-05-27-1-holidays-only",
|
||||
"generated_at": "2026-05-27T12:58:00Z",
|
||||
"paliad_commit": "",
|
||||
"source_db_label": "placeholder — operator must run `make snapshot-upc` against prod once mig 134/135 are applied",
|
||||
"source_db_label": "paliad prod (100.99.98.201:11833) — holidays.json only; rules/proceedings/courts remain placeholder until cmd/gen-upc-snapshot is updated for the post-mig-140 schema (paliad.deadline_rules was dropped)",
|
||||
"rule_count": 2,
|
||||
"proceeding_count": 2,
|
||||
"trigger_event_count": 0,
|
||||
"holiday_count": 5,
|
||||
"holiday_count": 55,
|
||||
"court_count": 2
|
||||
}
|
||||
|
||||
@@ -177,6 +177,48 @@ func TestSnapshotHolidayCalendar(t *testing.T) {
|
||||
if adj.Weekday() != time.Monday {
|
||||
t.Errorf("adjusted weekday = %v, want Monday", adj.Weekday())
|
||||
}
|
||||
|
||||
// t-paliad-332: UPC vacations are informational only — a deadline
|
||||
// landing on a vacation day must NOT be rolled forward. Mirrors
|
||||
// the paliad-side policy fixed in t-paliad-121 (the Court keeps
|
||||
// running through judicial vacations, so vacation rows live in
|
||||
// the snapshot for label payloads but don't extend deadlines).
|
||||
//
|
||||
// 2026-08-04 is a Tuesday inside UPC Summer Vacation — must stay
|
||||
// put on the (DE, UPC) calendar.
|
||||
sommerpauseDay := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
|
||||
if sommerpauseDay.Weekday() == time.Saturday || sommerpauseDay.Weekday() == time.Sunday {
|
||||
t.Fatalf("test premise broken: 2026-08-04 should not be a weekend (got %v)",
|
||||
sommerpauseDay.Weekday())
|
||||
}
|
||||
if hc.IsNonWorkingDay(sommerpauseDay, "DE", "UPC") {
|
||||
t.Error("UPC Summer Vacation weekday must not be non-working (t-paliad-332)")
|
||||
}
|
||||
adjV, _, wasV := hc.AdjustForNonWorkingDays(sommerpauseDay, "DE", "UPC")
|
||||
if wasV {
|
||||
t.Error("expected NO adjustment for vacation-only day (t-paliad-332)")
|
||||
}
|
||||
if !adjV.Equal(sommerpauseDay) {
|
||||
t.Errorf("adjusted = %v, want %v (vacation must not roll, t-paliad-332)",
|
||||
adjV.Format("2006-01-02"), sommerpauseDay.Format("2006-01-02"))
|
||||
}
|
||||
|
||||
// Sanity-pin: a UPC Winter Vacation date that is ALSO adjacent
|
||||
// to weekend + Neujahr (the scenario m flagged on youpc.org —
|
||||
// "rolled from 2027-01-02 (UPC Winter Vacation)"). 2027-01-02 is
|
||||
// a Saturday; the roll must cross Sat/Sun → Mon 2027-01-04, which
|
||||
// is in UPC Winter Vacation but no longer blocks → stops there.
|
||||
// Pre-fix this rolled all the way to Thu 2027-01-07.
|
||||
jan2 := time.Date(2027, 1, 2, 0, 0, 0, 0, time.UTC)
|
||||
adjW, _, wasW := hc.AdjustForNonWorkingDays(jan2, "DE", "UPC")
|
||||
if !wasW {
|
||||
t.Error("Sat 2027-01-02 must roll forward (weekend)")
|
||||
}
|
||||
want := time.Date(2027, 1, 4, 0, 0, 0, 0, time.UTC)
|
||||
if !adjW.Equal(want) {
|
||||
t.Errorf("Sat 2027-01-02 adjusted to %v, want %v (vacation no longer rolls, t-paliad-332)",
|
||||
adjW.Format("2006-01-02"), want.Format("2006-01-02"))
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnapshotCourtRegistry pins (country, regime) resolution.
|
||||
|
||||
Reference in New Issue
Block a user