Compare commits
36 Commits
mai/atlas/
...
mai/knuth/
| Author | SHA1 | Date | |
|---|---|---|---|
| 73f49c46ed | |||
| c80723fc85 | |||
| 1ed75c56e3 | |||
| 7945bfb364 | |||
| bfb38aab41 | |||
| 9fe06094a8 | |||
| c8f310c62c | |||
| 7554e86673 | |||
| 23b151c0f3 | |||
| 1718ea2eae | |||
| 39c8ef343b | |||
| 48a07ef4ef | |||
| bb3d7aabd7 | |||
| c8390dd02a | |||
| c8261da492 | |||
| 0568d340a7 | |||
| 60907e7153 | |||
| 66b08813c4 | |||
| 0aaa523494 | |||
| d49ff55c41 | |||
| ae1c0b861d | |||
| c8999e2a8b | |||
| 0365e84dd1 | |||
| d6a5dedb2b | |||
| 9940dd8216 | |||
| f6add95d0a | |||
| 480332a5f5 | |||
| 97d90ce651 | |||
| 3a4e99cb92 | |||
| 3533d79a25 | |||
| 2a69f7fc6c | |||
| 39353d49ed | |||
| d36cc9ee15 | |||
| a9fd979cdb | |||
| c48fa93e3d | |||
| 5f7a66bbec |
@@ -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
|
||||
|
||||
510
docs/design-procedures-workflow-tracker-2026-05-27.md
Normal file
510
docs/design-procedures-workflow-tracker-2026-05-27.md
Normal file
@@ -0,0 +1,510 @@
|
||||
# Design — `/tools/procedures` workflow tracker (m/paliad#152)
|
||||
|
||||
**Task:** t-paliad-337
|
||||
**Gitea:** m/paliad#152
|
||||
**Inventor:** atlas (shift-1, fresh — name-recycle, not the atlas from earlier today)
|
||||
**Date:** 2026-05-27
|
||||
**Branch:** `mai/atlas/inventor-extend-tools`
|
||||
**Status:** Draft — coder gate held; m to ratify the remaining open questions via `AskUserQuestion` before any coder shift.
|
||||
|
||||
**Builds on:**
|
||||
- `docs/design-unified-procedural-events-tool-2026-05-27.md` (cronus's U0-U4 design, shipped today as `/tools/procedures`)
|
||||
- `docs/design-deadline-system-revision-2026-05-27.md` §3.3 + §3.3a (atlas Phase 2 model layer + view-mode toggle)
|
||||
- `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26 Mode A+B+result, shipped via t-paliad-322)
|
||||
|
||||
**Reframe note (2026-05-27 21:01):** the first draft of this doc overengineered the surface — three-view toggle, separate compound drawer, separate Konstellationen drawer. m re-anchored: "clean display of timelines that have potential forks the user can select. UX should be key. It should be easy to find your thing." This rewrite collapses to a single canonical shape and folds the zoom / constellation / cross-cut concepts into it. The pre-grilling §13 + the 11-Q batch in §14 of the first draft are gone — superseded by m's 4 answers in §0.2 and the smaller open-question set in §10.
|
||||
|
||||
---
|
||||
|
||||
## §0 Premises
|
||||
|
||||
### §0.1 What shipped today and what m hit
|
||||
|
||||
`/tools/procedures` (U0-U4, knuth, m/paliad#151) is a **catalog browser**:
|
||||
- 4 always-visible tabs (Verfahren wählen / Direkt suchen / Geführt / Aus Akte).
|
||||
- Shared filter strip + search box at the top (markup-only in U0).
|
||||
- Two output shapes — TREE (Verfahrensablauf) and LINEAR (Mode A/B result view) — bound to specific entry tabs.
|
||||
|
||||
m's bugs (2026-05-27 20:43 / 20:46):
|
||||
|
||||
1. 4 tabs visible → pre-form leaks across them, page feels like 4 disjoint workflows.
|
||||
2. Result view fires too many rules incl. conditional-flag-off + curie's 7 compound rules.
|
||||
3. Proaktiv/Gericht/Reaktiv columns are a stance grouping, not a sequence anchor.
|
||||
4. No "you are here" marker.
|
||||
5. Sequence isn't visualised as a sequence — flat priority groups, not chained.
|
||||
|
||||
m's reframe (verbatim, 20:43): "view proceedings with all possible constellations and the sequences and determine **where we are** in that sequence and **what steps are coming next** for any given procedural event."
|
||||
|
||||
Tightened by m on 21:01:
|
||||
|
||||
> "clean display of timelines that have potential forks the user can select. procedural_events that act as triggers for mandatory or optional events. And there is a limited type of proceedings — a sequence of the events builds the proceeding. Some aux proceedings, some main… but a lot is connected. UX should be key. It should be easy to find your thing."
|
||||
|
||||
### §0.2 The four m-answers that lock the architecture
|
||||
|
||||
Asked back during the grilling round at 20:57, answered 21:01:
|
||||
|
||||
| | inventor's grilling question | m's answer | what it locks |
|
||||
|---|---|---|---|
|
||||
| 1 | One canonical shape or still 3 views? | "I still want zoomability for one event and all events it triggers. But that can be from within the full timeline/tree as well." | **One canonical view** (full timeline/tree); zoom is an *interaction* on it, not a separate view. The Anchor / Verfahren / Konstellationen toggle is dropped. |
|
||||
| 2 | What's a "fork" — scenario flags only / +optionals / everything? | "c" (everything: flags + optionals + appeal-target + court-set picks) | **Every choice point in the data is a fork.** Optionals (priority='optional') + conditional flags + appeal-target + perspective + court-set scheduling. Inline pickers. |
|
||||
| 3 | "Easy to find" — timeline-as-index / search box / proceeding picker first? | "all of these — text search, filter pills, a display of the resulting proceedings timelines" | **Find = combined affordance.** Text search + filter pills + the displayed result *is* the matched proceeding timelines. The page never has chrome that isn't either the find affordance or the timelines themselves. |
|
||||
| 4 | Aux proceedings inline or drillable? | "inline" | **Aux proceedings draw inline as expandable child timelines** hanging off the spawn point in the parent timeline. The full connected graph is one visible thing. |
|
||||
|
||||
### §0.3 Live data the tracker has to work against
|
||||
|
||||
Verified 2026-05-27 against `paliad.sequencing_rules` (231 published / 242 total):
|
||||
- 110 chained (parent_id not null) — most rules in a chain.
|
||||
- 78 trigger-rooted, 4 spawns (cross-PT), 47 court-set, 18 conditional (6 `with_ccr` / 4 `with_amend` / 4 `with_cci` / 4 compound `with_ccr AND with_amend`).
|
||||
- Biggest single proceeding: `upc.inf.cfi` (50 rules).
|
||||
- ~46 proceeding types total (UPC 35 / DE 5 / EPA 3 / DPMA 3).
|
||||
- `paliad.deadlines` carries both `procedural_event_id` and `sequencing_rule_id` → Akte actuals overlay is a direct join.
|
||||
|
||||
### §0.4 Scope
|
||||
|
||||
**In:** redesign the `/tools/procedures` surface as a single timeline-tree view with inline forks + a combined find affordance.
|
||||
|
||||
**Out:**
|
||||
- Calculator changes.
|
||||
- Editorial backfill (curie's t-paliad-333 owns the 7 compound rules + R.109 chain). This design is *independent* of curie's column-shape work; compound rules surface inline via parent_id like any other rule, with whatever annotation curie ships.
|
||||
- `/admin/procedural-events` write surface.
|
||||
- `/projects/{id}` Verlauf / SmartTimeline — cross-link only.
|
||||
- youpc.org cross-repo / Outlook sync / PDF export.
|
||||
|
||||
---
|
||||
|
||||
## §1 The single canonical shape
|
||||
|
||||
One page. One view. Top section = find affordance. Below = matched proceeding timelines, each as an inline-forked tree, vertically stacked.
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────────┐
|
||||
│ [🔍 Suche: Klageerwiderung_____________________] │
|
||||
│ Forum: [● UPC] [DE] [EPA] [DPMA] │
|
||||
│ Verfahren: [● Verletzung] [● Widerklage] [Berufung] [Nichtigkeit] … │
|
||||
│ Partei: [Klägerseite] [● Beklagtenseite] │
|
||||
│ Akte: HL-2024-001 ▼ Stichtag: 2026-04-01 │
|
||||
│ │
|
||||
│ 2 Verfahren passen — Anker: Klageerwiderung (HL-2024-001) │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ upc.inf.cfi · Verletzungsverfahren UPC ─────────────────────────┐
|
||||
│ │
|
||||
│ ● Klageerhebung (R.13) 2026-01-15 · Klg · M │
|
||||
│ │ │
|
||||
│ ▼ ● Klageerwiderung (R.23.1) 2026-04-01 · Bekl · M │
|
||||
│ │ ━━━━ DU BIST HIER ━━━━ │
|
||||
│ │ Optionen für dieses Ereignis: │
|
||||
│ │ ☑ Widerklage auf Nichtigkeit │
|
||||
│ │ ☐ Antrag Patentänderung (R.30) │
|
||||
│ │ ☐ Vorläufige Einwendungen │
|
||||
│ │ │
|
||||
│ ├─● Replik (R.29.a/b) 2026-06-01 · Klg · M │
|
||||
│ │ ├─● Duplik (R.29.c) 2026-07-01 · Bekl · M │
|
||||
│ │ └─● Replik auf Defence to CCR (R.29.d) 2026-08-01 · Klg · M │
|
||||
│ │ └─● Rejoinder (R.29.e) 2026-09-01 · Bekl · M │
|
||||
│ │ │
|
||||
│ ├─● Widerklage auf Nichtigkeit ✓ │
|
||||
│ │ └─▼ Tochterverfahren upc.rev.cfi ▾ │
|
||||
│ │ │ │
|
||||
│ │ ├─● Antrag Patentänderung (R.50) optional ☐ │
|
||||
│ │ ├─● Hauptverhandlung [Gericht] │
|
||||
│ │ └─● Entscheidung [Gericht] │
|
||||
│ │ │
|
||||
│ └─● Vorläufige Einwendungen ☐ (optional, ausgewählt: nein) │
|
||||
│ │
|
||||
│ ● Mündliche Verhandlung [Gericht bestimmt] │
|
||||
│ │ │
|
||||
│ └─● Urteil [Gericht] │
|
||||
│ └─▼ Berufungsverfahren upc.apl ▸ (auf Endentscheidung) │
|
||||
│ │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌─ upc.ccr.cfi · Widerklage auf Nichtigkeit (Tochter, oben verlinkt) ┐
|
||||
│ … │
|
||||
└────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
No tabs. No view toggle. The output reacts to the find affordance, the anchor pin, and per-node fork selections.
|
||||
|
||||
### §1.1 The shape's components
|
||||
|
||||
1. **Find header** (sticky at top): search input + filter pills + Akte/date row + a one-line result summary. §2.
|
||||
2. **Timeline-trees** (the page body): one block per matched proceeding, full chain + inline forks + inline aux branches. §3-§5.
|
||||
3. **Anchor pin** (when set): the "DU BIST HIER" band on a specific node, optionally with zoom mode collapsing everything else. §6.
|
||||
|
||||
That's the entire UI surface. No drawers, no separate drillable panes, no constellation viewer. Forks are inline checkboxes; aux proceedings are inline expandable subtrees; zoom is an interaction on the existing rendering.
|
||||
|
||||
---
|
||||
|
||||
## §2 The find affordance
|
||||
|
||||
m's #3 answer makes this load-bearing: text + pills + result-timelines are all the same affordance. As the user narrows, the timelines below filter; as the timelines change, the result-count summary updates; clicking a node in a timeline auto-narrows the filter pills to that proceeding (optional sugar).
|
||||
|
||||
### §2.1 Composition
|
||||
|
||||
| Control | Source | Composes via | Persists in |
|
||||
|---|---|---|---|
|
||||
| Free-text search | input box, debounced 200ms | OR-against (procedural_event.name DE/EN, rule_code, aliases) | `?q=<text>` |
|
||||
| Forum pill row | static enum (UPC/DE/EPA/DPMA), single-select | AND | `?forum=<id>` |
|
||||
| Verfahren pill row | proceeding_type chips, multi-select (deduped from active forum) | AND (any-of) | `?procs=<csv>` |
|
||||
| Partei pill row | claimant / defendant / both / — (or auto from Akte) | AND | `?party=<x>` |
|
||||
| Akte picker | dropdown of user's projects | seeds Verfahren + Partei + scenario_flags + anchor | `?project=<uuid>` |
|
||||
| Stichtag (date) | date input, defaults today | feeds computed dates throughout the timelines | `?trigger_date=<iso>` |
|
||||
|
||||
All controls live in one sticky header. The header keeps its height stable so the timelines below don't reflow on every keystroke.
|
||||
|
||||
### §2.2 Cold open behaviour
|
||||
|
||||
No URL params, no Akte:
|
||||
- Search box empty, all forums neutral, all proceeding pills neutral. Show a curated default of the most-common proceedings: `upc.inf.cfi`, `upc.rev.cfi`, `upc.apl.merits`, `de.inf.lg`, `epa.opp.opd`, `dpma.opp.dpma`. (See Q4 below.)
|
||||
- A hint above the timelines: "Suche oder filtere, um andere Verfahren einzublenden."
|
||||
|
||||
With a `?project=` param: filters pre-fill from the Akte, anchor pins to the latest completed deadline.
|
||||
|
||||
With a `?q=` or `?event=` param: filters pre-fill to match, single matched proceeding renders pinned.
|
||||
|
||||
### §2.3 What the search matches
|
||||
|
||||
Free-text search hits the same corpus the existing `/api/tools/fristenrechner/search?kind=events` endpoint covers — procedural_events by name + code + aliases. Spawn-only events stay filtered out (per atlas P0 §2.2). Hits surface in two ways simultaneously:
|
||||
|
||||
- The matched proceeding(s) render expanded with the hit event(s) anchor-pinned.
|
||||
- A small "Treffer: 3 Ereignisse in 2 Verfahren" summary above the timelines.
|
||||
|
||||
If the user types something narrow enough to match a single event, the page auto-pins that event (auto-anchor). If multiple events match, the user picks via a small dropdown under the search input — picking sets the anchor.
|
||||
|
||||
### §2.4 Why pills, not chips-with-sub-modes
|
||||
|
||||
The shipped 4-tab UI tried to express "what kind of question are you asking" via tabs. m's answer #3 collapses that — the find affordance doesn't care which "kind" of question; it cares about the active filter set. A user with a search + a forum + an Akte set gets the right timelines regardless of which tab they "came from". The mental model is: narrow the set; the timelines arrive.
|
||||
|
||||
---
|
||||
|
||||
## §3 Timelines and forks
|
||||
|
||||
Each matched proceeding renders as one card. Inside the card: the proceeding's name + jurisdiction badge in a thin header strip, then the chain.
|
||||
|
||||
### §3.1 The chain
|
||||
|
||||
Vertical, top-to-bottom = chronological. Each node = one procedural_event (the rule that fires it lives inside). Edges = parent_id. Per node:
|
||||
|
||||
- **Bullet style** by priority: solid filled (mandatory), solid outline (recommended), dotted (optional), dashed (conditional-flag-off and hidden).
|
||||
- **Bullet colour**: priority band — black/grey/blue/light depending on the scale we end up picking. Lime accent (`#c6f41c`) reserved for the anchor pin.
|
||||
- **Inline metadata**: name, rule code, computed date, party badge, priority badge. Stripped to one line.
|
||||
- **Court-set events**: render with `[Gericht bestimmt]` in date column.
|
||||
- **Spawn nodes**: terminate the bullet with `▼ Tochterverfahren <code> ▾` — expandable inline; collapsed by default unless the spawn flag is on. §5.
|
||||
|
||||
### §3.2 Forks — every choice point is one
|
||||
|
||||
A "fork" is anywhere the user can flip the proceeding's shape:
|
||||
|
||||
1. **Scenario flags** (`with_ccr`, `with_amend`, `with_cci`) — currently 3, extensible via curie's `scenario_flag_catalog`.
|
||||
2. **Optional rules** (`priority='optional'`) — each is a "do I do this?" pick.
|
||||
3. **Appeal-target picks** — `applies_to_target` array on appeal proceedings (endentscheidung / kostenentscheidung / anordnung / schadensbemessung / bucheinsicht). Per-card chip group at the appeal root.
|
||||
4. **Perspective** — claimant / defendant per proceeding (mostly comes from Akte's `our_side`, picker overrides).
|
||||
5. **Court-set timing choices** — `choices_offered` JSON on `sequencing_rules` (`appellant` / `include_ccr` / `skip` shapes from einstein). Per-card chip set.
|
||||
|
||||
**Where forks render.** Inline, *on the node where the fork's effect begins.* Not in a top-of-page flag strip (m's bug #5 — sequences should be visualised as sequences; flags above the tree decouple cause from effect).
|
||||
|
||||
Concretely: the `with_ccr` fork renders as a checkbox **on the Klageerwiderung node**, because that's where the user decides "we are filing a Widerklage with our KEW". Toggling it lights up the CCR child branches below. Similarly:
|
||||
|
||||
- `with_amend` renders on the KEW node *and* on the Antrag-Patentänderung node it gates.
|
||||
- `with_cci` renders on the Defence-to-Revocation node.
|
||||
- Optional rules render as a checkbox on their own card.
|
||||
- Appeal-target picks render on the appeal root.
|
||||
|
||||
If multiple forks share a node, they cluster as a small "Optionen für dieses Ereignis" mini-strip *below* the node header:
|
||||
|
||||
```
|
||||
▼ ● Klageerwiderung (R.23.1) 2026-04-01 · Bekl · M
|
||||
│ Optionen:
|
||||
│ ☑ Widerklage auf Nichtigkeit
|
||||
│ ☐ Antrag Patentänderung (R.30)
|
||||
│ ☐ Vorläufige Einwendungen einlegen
|
||||
```
|
||||
|
||||
### §3.3 Default rendering ("Gewählt" semantics)
|
||||
|
||||
Each node renders iff:
|
||||
- It's mandatory (priority='mandatory'), OR
|
||||
- It's selected per current scenario state (priority='recommended' unless explicitly deselected; priority='optional' iff explicitly selected; conditional iff flag is on).
|
||||
- Same as atlas P3's "Gewählt" view-mode.
|
||||
|
||||
Conditional rules whose flag is off **do not render at all** by default. The fork checkbox to *turn the flag on* still appears on the gating node — turning it on causes the dependent branch to render.
|
||||
|
||||
This is m's bug #2 fix: no more dump of every-rule including flag-off conditional. The forks themselves are the affordance that brings hidden branches into view.
|
||||
|
||||
### §3.4 Optional reveal — "Alle Optionen"
|
||||
|
||||
A single toggle at the top of each proceeding card (NOT page-wide):
|
||||
|
||||
```
|
||||
[· Gewählt ·] [ Alle Optionen ]
|
||||
```
|
||||
|
||||
"Alle Optionen" renders every rule including flag-off conditionals (greyed with their flag hint) and unselected optionals (dotted with `[Aufnehmen]` chip). Useful when the user wants to see the whole shape. Per-proceeding so a page with 3 proceedings can have one in "Alle Optionen" mode without affecting the others. State persists in `localStorage` per proceeding code.
|
||||
|
||||
### §3.5 Why dropping "Nur Pflicht"
|
||||
|
||||
Atlas P3's three-way toggle had Nur Pflicht / Gewählt / Alle Optionen. With forks made inline + per-node, "Nur Pflicht" loses load-bearing — it was useful when the page had no fork interactivity (you wanted to dial down clutter). Now the user can just leave all forks off and see the mandatory-only chain in Gewählt mode. The two-way Gewählt ↔ Alle Optionen is enough.
|
||||
|
||||
### §3.6 Cross-party rows
|
||||
|
||||
Per atlas §2.4 / m's lock: every rule for the proceeding renders, with rows where the user is *not* the primary_party muted + carrying a "Gegenseitig" badge. Same treatment in this tracker. Not hidden by perspective; just visually deemphasised.
|
||||
|
||||
---
|
||||
|
||||
## §4 Court-set events & date rendering
|
||||
|
||||
`is_court_set=true` rules don't compute a date — the court picks it on the day. Two display options that interact:
|
||||
|
||||
- Render with `[Gericht bestimmt]` in the date column, no date. Standard.
|
||||
- When the user has scheduled the actual date (an `appointments` row on the project or a manual override), the actual date replaces the badge. Akte-only.
|
||||
|
||||
If the date is "vom Gericht" and matters as a trigger for downstream events, downstream events render `[abhängig von Verhandlung]` instead of a date, and recompute live once the court date is known.
|
||||
|
||||
`choices_offered` per-rule (the 3 known shapes today: `appellant`, `include_ccr`, `skip`) — also inline per-node, treated as forks (§3.2 #5).
|
||||
|
||||
---
|
||||
|
||||
## §5 Aux proceedings inline
|
||||
|
||||
Per m's #4 answer: spawned proceedings draw inline as expandable subtrees, not as drillable separate pages.
|
||||
|
||||
### §5.1 Render
|
||||
|
||||
A spawn node renders as a leaf chip terminating the parent's chain segment:
|
||||
|
||||
```
|
||||
●─● Widerklage auf Nichtigkeit ✓
|
||||
└─▼ Tochterverfahren upc.rev.cfi ▾
|
||||
│
|
||||
├─● Antrag Patentänderung (R.50) optional ☐
|
||||
├─● Hauptverhandlung [Gericht]
|
||||
└─● Entscheidung [Gericht]
|
||||
└─▼ Berufungsverfahren upc.apl ▸
|
||||
```
|
||||
|
||||
- Collapsed by default unless the gating fork is on (e.g. `with_ccr` ticked → CCR's spawn into upc.rev.cfi auto-expands).
|
||||
- Expanding writes nothing — pure UI state in `sessionStorage["procedures:expanded_spawns"]`.
|
||||
- The aux subtree renders with the same node vocabulary as the parent; forks inside the aux are independently editable.
|
||||
- Aux subtrees can themselves have aux subtrees (e.g. CCR → Berufung). Depth is bounded by the data — today 2 levels deep at most.
|
||||
|
||||
### §5.2 Cross-references
|
||||
|
||||
When two paths converge on the same aux proceeding (e.g. CCR triggers from a couple of places), the aux renders inline at the first path's spawn point and renders as a back-reference at subsequent spawn points: `▸ (siehe oben: Tochterverfahren upc.rev.cfi)`. Single source of truth in the rendered tree, even when the graph has multiple edges.
|
||||
|
||||
### §5.3 Akte mode
|
||||
|
||||
In Akte mode, if the spawn was actualised (a child project exists linked via `parent_project_id`), the aux subtree shows the child project's badge: `📁 HL-2024-001-CCR · Tochterakte`. Clicking the badge navigates to that child project. The subtree itself still renders inline.
|
||||
|
||||
---
|
||||
|
||||
## §6 Anchor pin & zoom
|
||||
|
||||
m's #1 answer: "zoomability for one event and all events it triggers, from within the full timeline".
|
||||
|
||||
### §6.1 The anchor pin
|
||||
|
||||
Any node can be pinned as the anchor. Pinning sources:
|
||||
- URL `?event=<sequencing_rule_id>` (deep link).
|
||||
- Search box auto-pin when the search narrows to a single hit.
|
||||
- Click-to-pin on any node (small pin icon in the node's metadata row).
|
||||
- Akte landing: auto-pin to latest completed deadline.
|
||||
|
||||
The pinned node renders with a 4px lime-coloured left band + a `━━ DU BIST HIER ━━` divider above its successors. The pin is also reflected in the find-header's result summary: "Anker: Klageerwiderung (HL-2024-001)".
|
||||
|
||||
### §6.2 Zoom mode
|
||||
|
||||
A small `[ 🔍 Fokus ]` chip on the anchored node toggles zoom mode for that anchor. When zoom is on:
|
||||
|
||||
- The anchored node's ancestors collapse to a single breadcrumb at the top of the proceeding card:
|
||||
```
|
||||
upc.inf.cfi ▸ Klageerhebung ▸ ━ Klageerwiderung ━
|
||||
```
|
||||
- The anchored node renders full.
|
||||
- Successors render fully (the forward subtree under the anchor).
|
||||
- Sibling branches at every ancestor depth collapse to a single-line summary card: `… 4 weitere Schritte verborgen — [zeigen]`.
|
||||
|
||||
This is what m means by "zoom into one event from within the timeline" — the *same view*, just with non-relevant siblings collapsed. Toggle off → full timeline restored, anchor still pinned.
|
||||
|
||||
Zoom is page-scoped (one anchor per page, one zoom state). State in URL: `?event=<id>&zoom=1`.
|
||||
|
||||
### §6.5 Multi-proceeding anchor scope (m's Q3 divergence)
|
||||
|
||||
When the page shows >1 matched proceeding *and* an anchor is pinned, the non-anchored proceedings auto-collapse to a header-only one-line card:
|
||||
|
||||
```
|
||||
┌─ upc.inf.cfi · Verletzungsverfahren UPC ─────┐
|
||||
│ … full timeline … │
|
||||
│ ━━ DU BIST HIER: Klageerwiderung ━━ │
|
||||
└──────────────────────────────────────────────┘
|
||||
|
||||
┌─ upc.rev.cfi ▸ ausblenden — [zeigen] ────────┐
|
||||
└──────────────────────────────────────────────┘
|
||||
|
||||
┌─ upc.apl.merits ▸ — [zeigen] ────────────────┐
|
||||
└──────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Clicking a header card's `[zeigen]` link restores that proceeding's full timeline (the header stays as a per-card affordance for re-collapse). The collapsed state persists in `sessionStorage["procedures:collapsed_proceedings"]`. Un-pinning the anchor restores all visible proceedings to full-render automatically.
|
||||
|
||||
The rule applies regardless of how the anchor was pinned (URL, search-auto, click-to-pin, Akte landing). The find-header result count still shows N proceedings matched — header cards are present, just collapsed.
|
||||
|
||||
### §6.3 The "where I came from" question
|
||||
|
||||
m's brief asked for backward-walk visualisation. Without zoom: the chain above the anchor is the backward walk — it's the same tree. With zoom: the breadcrumb at the top of the proceeding card is the backward walk in compact form. No separate concept; backward walk = upward in the tree.
|
||||
|
||||
### §6.4 Akte mode: actuals overlay
|
||||
|
||||
When `?project=<uuid>` is set, each node in the chain queries `paliad.deadlines WHERE project_id = $p AND sequencing_rule_id = $r` and overlays:
|
||||
|
||||
- `status='done'` → ✓ in the node bullet area + actual completed date in the date column. Greyed slightly to read as "past".
|
||||
- `status='open'` and `due_date < today` → ⚠ overdue.
|
||||
- `status='open'` and `due_date >= today` → 📅 actual due date if differs from computed; ◇ marker.
|
||||
- No deadline row → render as template (current behaviour).
|
||||
|
||||
The anchor auto-pins to the latest `status='done'` deadline by default — the natural reading is "we just finished this".
|
||||
|
||||
---
|
||||
|
||||
## §7 What lives where: the find header vs the timelines
|
||||
|
||||
A short table to make the responsibility boundary explicit:
|
||||
|
||||
| Concern | Find header | Timeline body |
|
||||
|---|---|---|
|
||||
| Pick proceeding(s) | Filter pill row | (auto-rendered after) |
|
||||
| Pick anchor | Search-narrow → auto-pin / URL `?event=` | Click pin icon on any node |
|
||||
| Pick perspective | Pill (or auto from Akte) | (read-only — feeds rendering) |
|
||||
| Pick scenario flags | (no) | Inline fork checkboxes on gating nodes |
|
||||
| Pick optional rules | (no) | Inline fork checkboxes on each optional node |
|
||||
| Pick appeal target | (no) | Inline chip group on appeal root |
|
||||
| Pick date | Stichtag input | (read-only — feeds computed dates) |
|
||||
| Toggle Alle Optionen / Gewählt | (no) | Per-proceeding 2-way toggle |
|
||||
| Zoom on anchor | (no) | `[Fokus]` chip on anchored node |
|
||||
| Akte select | Akte picker | (read-only — feeds actuals overlay) |
|
||||
|
||||
Find header = "narrow the set + global context". Timelines = "everything per-event". No drawers, no overlays.
|
||||
|
||||
---
|
||||
|
||||
## §8 Cold open + empty state
|
||||
|
||||
Cold open with no Akte, no URL params (Q4 below): show a curated default of 6 most-common proceedings (`upc.inf.cfi`, `upc.rev.cfi`, `upc.apl.merits`, `de.inf.lg`, `epa.opp.opd`, `dpma.opp.dpma`), each rendered with default Gewählt + no forks selected + no anchor. Hint text above: "Suche oder filtere, um andere Verfahren zu sehen."
|
||||
|
||||
Empty filter result (e.g. user types nonsense): zero timelines render, with a helper card: "Keine Treffer. Filter zurücksetzen ▸"
|
||||
|
||||
---
|
||||
|
||||
## §9 Migration (direct replace per m's Q7)
|
||||
|
||||
4 slices + 1 cleanup, all surface, no DB mig, no `?tracker=1` flag. Each slice ships visibly to users at `/tools/procedures`. T1 must be at least as functional as today's catalog browser — so the find header + multi-proceeding render + inline forks + aux inline all front-load there. T2-T4 layer the remaining behaviour.
|
||||
|
||||
All independent of curie's editorial work — compound rules render inline via parent_id like any other rule; if curie ships a `compound_predecessors uuid[]` column later, those rules can render at multiple positions (one inline per predecessor) without tracker code changes beyond the join.
|
||||
|
||||
| Slice | What ships | Notes |
|
||||
|---|---|---|
|
||||
| **T1 — Tracker shell replaces the catalog page** | `/tools/procedures` now renders: sticky find header (search + Forum/Verfahren/Partei pills + Akte picker + global Stichtag), N-proceeding render (one card per matched proceeding), inline forks (scenario flags + optionals visible as checkboxes on the gating node), aux proceedings inline-expandable at spawn points, cold-open with 6 curated defaults (Q4), default = Gewählt. The 4 entry-mode tabs are deleted in the same PR; URL params `?mode=proceeding\|search\|wizard\|akte` 301-redirect or drop. URL anchor `?event=<rule_id>` scroll-highlights the matching node (no zoom yet). | Replaces catalog UI; users see the new tracker immediately. |
|
||||
| **T2 — Anchor pin + zoom + multi-proceeding scope** | Anchor pin (lime band + DU BIST HIER divider), `[Fokus]` chip on anchored node toggles zoom (§6.2), URL state `?event=…&zoom=1`. Multi-proceeding auto-collapse rule (§6.5) kicks in when an anchor is set. Click-to-pin on any node. | Layered on T1's existing render. |
|
||||
| **T3 — Akte landing + actuals overlay** | `?project=<uuid>` derives anchor from latest `status='done'` deadline (Q5), backward walk overlays `paliad.deadlines` actuals as status badges (§6.4), scenario_flags load from project, fork write-back via existing `PATCH /api/projects/{id}/scenario-flags` + `POST /api/projects/{id}/deadlines/bulk`. | The first slice that exercises the project hookup end-to-end. |
|
||||
| **T4 — Appeal-target + court-set choices + polish** | Wire `applies_to_target` array forks on appeal proceedings, `choices_offered` shapes (`appellant`, `include_ccr`, `skip`), court-set date override from `appointments` table, cross-party muted treatment per §3.6. Per-proceeding "Alle Optionen" toggle (§3.4). | Polish + the edge-case fork shapes. |
|
||||
| **T5 — Cleanup** | Dead-code removal: legacy `procedures.ts` tab toggling, `fristenrechner-mode-a.ts` / `fristenrechner-wizard.ts` / `fristenrechner-result.ts` / `verfahrensablauf.ts` if no longer referenced (verify with grep before deletion). Sidebar/cmd-K unchanged (URL same). | No user-visible change. |
|
||||
|
||||
### §9.1 Constraint: T1 is the new floor
|
||||
|
||||
Because there's no flag, **T1 must not regress** from today's catalog UI in any non-trivial way. The catalog today serves four user workflows:
|
||||
|
||||
1. **Pick a proceeding, see its full Verfahrensablauf** → T1 covers via "Verfahren" pill click → that proceeding renders alone.
|
||||
2. **Search for an event** → T1 covers via search input + auto-pin.
|
||||
3. **Wizard from R1-R5** → T1 covers via Forum/Verfahren/Partei pills + search (the wizard's narrowing is just a sequence of filter applications).
|
||||
4. **Enter via Akte** → T1 covers via the Akte picker; full actuals overlay arrives in T3 (open/done badges may render partial in T1, but the Akte's scenario_flags + proceeding pre-load works).
|
||||
|
||||
If T1 reviewing exposes a regression, T1 holds (the issue blocks merge) — m's PR review gates the slice landing.
|
||||
|
||||
### §9.2 What stays unchanged
|
||||
|
||||
- URL: `/tools/procedures` keeps it.
|
||||
- Sidebar entry "Verfahren & Fristen" keeps it.
|
||||
- cmd-K palette keeps it.
|
||||
- All other tools, calendar, projects, deadlines surfaces — untouched.
|
||||
- Calculator (`pkg/litigationplanner.CalculateRule`) — untouched.
|
||||
|
||||
### §9.3 Out-of-band dependencies
|
||||
|
||||
- The compound-predecessors editorial column is owned by curie's t-paliad-333. Tracker reads whatever lands. If it slips past T4, compound rules render via their primary parent_id only (today's shape) — degraded but still correct on that path. No tracker re-render needed when curie ships.
|
||||
- The Akte actuals overlay (T3) reads `paliad.deadlines.sequencing_rule_id` — column exists, nothing new.
|
||||
|
||||
### §9.4 Test surface per slice
|
||||
|
||||
- **T1**: cold-open 6 curated defaults render; search narrows to single proceeding; pill toggles change render; `?project=` loads Akte filters (no actuals yet); URL deep-link `?event=` highlights matching node; legacy `?mode=` redirects.
|
||||
- **T2**: click-to-pin sets anchor with lime band; `[Fokus]` zoom collapses siblings; un-zoom restores; multi-proceeding auto-collapse when anchor active; URL state survives reload.
|
||||
- **T3**: Akte landing auto-pins latest done deadline; status badges render on each node from `paliad.deadlines`; fork tick writes to `scenario_flags`; "In Akte speichern" persists.
|
||||
- **T4**: appeal-target chips switch the rule set rendered on appeal proceedings; `choices_offered` per-node chip groups visible + functional; "Alle Optionen" reveals hidden conditional rules with greyed state.
|
||||
- **T5**: production deploy unchanged surface; no live regression; deleted files don't break build.
|
||||
|
||||
---
|
||||
|
||||
## §10 Open questions for m
|
||||
|
||||
Seven questions in 2 batches (4 + 3) for `AskUserQuestion`. Tier 1 = how the per-node fork UI feels + how zoom interacts with multi-proceeding pages. Tier 2 = cold-open content + Akte default + Stichtag scope + migration cadence.
|
||||
|
||||
m's picks fold back into §11 below before the "TRACKER DESIGN READY FOR REVIEW" signal.
|
||||
|
||||
### Batch 1 — fork UI + zoom + cross-party
|
||||
- **Q1 (Fork-cluster shape on a node)** — when a node has 2-4 forks (e.g. Klageerwiderung: `with_ccr` + `with_amend` + Vorl. Einwend.) — (a) inline checkbox list below the node header (current sketch), (b) collapsed "Optionen (3) ▾" affordance that expands on hover/click, (c) chip strip on the same line as the node header.
|
||||
- **Q2 (Zoom interaction)** — `[Fokus]` chip on the anchored node — (a) collapses siblings to one-line summaries (current sketch), (b) outright hides siblings, breadcrumb-only, (c) split-view (zoomed pane below original full tree).
|
||||
- **Q3 (Anchor scope on a multi-proceeding page)** — when 3 timelines are visible and the user pins an anchor in one — (a) the other 2 timelines stay expanded normally (no zoom effect on them), (b) the other 2 timelines auto-collapse to header-only ("upc.rev.cfi ▸ ausblenden — [zeigen]"), (c) the other 2 timelines reorder to bottom of page (anchored proceeding floats to top).
|
||||
- **Q4 (Cold-open default content)** — opening `/tools/procedures` with no URL params and no Akte — (a) the 6-curated-default-proceedings sketch (Verletzung UPC + DE LG, Nichtigkeit UPC, Berufung UPC, EPA-Einspruch, DPMA-Einspruch), (b) all ~46 proceedings rendered with all forks off (lots of scrolling), (c) empty state with a "Filter wählen, um Verfahren einzublenden" prompt.
|
||||
|
||||
### Batch 2 — Akte semantics + Stichtag + migration
|
||||
- **Q5 (Akte landing — default anchor)** — `?project=<uuid>` — (a) auto-pin to latest `status='done'` deadline (current sketch), (b) auto-pin to next-open deadline (forward-looking), (c) no auto-pin, just pre-fill filters + actuals overlay, user picks anchor.
|
||||
- **Q6 (Stichtag scope)** — date input in the find header — (a) global, feeds all visible proceedings' computed dates (current sketch — useful for browsing "if today were the trigger"), (b) per-proceeding (each timeline carries its own date input), (c) only valid in single-proceeding mode (hidden when the page shows >1 proceeding).
|
||||
- **Q7 (Migration cadence)** — (a) flag-gated dev under `?tracker=1`, T1-T4 ship, T5 hard-cut (current sketch, cronus precedent), (b) direct replace at T1 (no flag — every slice ships visibly to users), (c) parallel URL `/tools/procedures-v2` until hard-cut.
|
||||
|
||||
---
|
||||
|
||||
## §11 m's decisions (2026-05-27)
|
||||
|
||||
All 7 questions answered via `AskUserQuestion` in 2 batches (4 + 3) at 21:0?. 5 picks on-recommendation, 2 diverged. Decisions below; the underlying question list lives in §10 above as the historical record.
|
||||
|
||||
### Tier 1 — fork UI + zoom + cross-party
|
||||
|
||||
- **Q1 (Fork cluster on a node): Inline checkbox list below node header.** [= REC] **Locks §3.2.** Every fork on a given node renders as a checkbox in an "Optionen:" cluster line below the node header. Always visible, no hover, no extra click. Vertical real estate per node is acceptable because the default `Gewählt` mode keeps the tree compact (most events have zero forks).
|
||||
- **Q2 (Zoom interaction): Collapse siblings to one-line summaries.** [= REC] **Locks §6.2.** `[Fokus]` chip on the anchored node folds sibling branches at each ancestor depth to a `… 4 weitere Schritte verborgen — [zeigen]` line. The anchored node's subtree renders full. Breadcrumb at the top of the proceeding card. Toggle off restores everything.
|
||||
- **Q3 (Multi-proceeding anchor scope): Other timelines auto-collapse to header-only.** [≠ REC; m diverged from "stay expanded"] **Locks new §6.5.** When an anchor is pinned on a multi-proceeding page, the non-anchored proceedings fold to a one-line header card (`upc.rev.cfi ▸ ausblenden — [zeigen]`). Clicking the header line restores that proceeding's full timeline. Rationale (interpreted): with an anchor pinned, the page is *about* that anchor — having other proceedings full-render in parallel competes for attention without earning it. The header card preserves the find-header result count and offers a one-click escape if the user wants to compare.
|
||||
- **Q4 (Cold open content): 6 curated default proceedings.** [= REC] **Locks §8.** No URL params + no Akte → render `upc.inf.cfi`, `upc.rev.cfi`, `upc.apl.merits`, `de.inf.lg`, `epa.opp.opd`, `dpma.opp.dpma` stacked vertically, all forks off, no anchor. Hint: "Suche oder filtere, um andere Verfahren zu sehen."
|
||||
|
||||
### Tier 2 — Akte + Stichtag + migration
|
||||
|
||||
- **Q5 (Akte default anchor): Latest `status='done'` deadline.** [= REC] **Locks §6.4 + §9.** `?project=<uuid>` → derive anchor by `SELECT … FROM paliad.deadlines WHERE project_id=$p AND sequencing_rule_id IS NOT NULL ORDER BY completed_at DESC NULLS LAST LIMIT 1`. Fallback: next open deadline → proceeding root. The backward chain reads as Akte history; the anchor itself is the most recently completed work; forward is upcoming.
|
||||
- **Q6 (Stichtag scope): Global, feeds all visible proceedings.** [= REC] **Locks §2.1 + §7.** One date input in the find header. All visible proceedings compute dates against it. When the user has an Akte loaded, the Stichtag pre-fills from the project's latest trigger date but is overrideable. When the anchor is pinned to a `status='done'` deadline, the date input shows that deadline's `completed_at` but can still be overridden for "what-if" exploration.
|
||||
- **Q7 (Migration cadence): Direct replace at T1 — no flag.** [≠ REC; m diverged from flag-gated dev] **Rewrites §9.** Every slice ships visibly to users at /tools/procedures. T1 must be at minimum equivalent to today's catalog browser (so the slicing has to front-load find header + multi-proceeding render + forks inline + aux inline). The flag-gated dev plan is dropped. cronus's Q11 hard-cut precedent extends here: m would rather ship per-slice visibly than carry a parallel surface during dev. Rationale (interpreted): partial-tracker > no-tracker, and ~50 internal lawyers absorb the per-slice deltas through release comms.
|
||||
|
||||
### §11.1 Changes triggered by m's divergences
|
||||
|
||||
**Q3 divergence — multi-proceeding anchor scope.** New §6.5 added below. The header-card-only render for non-anchored proceedings preserves filter compose (you can still see "upc.rev.cfi matched the filter") while clearing the page's vertical real estate for the anchor's full context.
|
||||
|
||||
**Q7 divergence — direct replace.** §9 rewritten end-to-end. T1 now ships the minimum-viable tracker (find header + multi-proceeding render + forks inline + aux subtrees inline + URL-anchor highlight), replacing the catalog UI at /tools/procedures from the moment it merges. T2-T4 layer zoom, Akte semantics, polish. T5 ("cleanup only") is now just dead-code removal.
|
||||
|
||||
### §11.2 What stays unchanged
|
||||
|
||||
The other 5 picks (Q1, Q2, Q4, Q5, Q6) ratified the inventor proposal. Inline checkbox forks per node, breadcrumb-collapse zoom, 6-curated cold open, latest-done-deadline Akte anchor, global Stichtag — all locked as drafted in §1-§8.
|
||||
|
||||
---
|
||||
|
||||
## §12 Out of scope
|
||||
|
||||
- Calculator changes.
|
||||
- Editorial backfill (curie's t-paliad-333). Compound rules render inline as parent_id-chained rules with curie's annotation; no special tracker treatment.
|
||||
- `/admin/procedural-events`, `/projects/{id}` Verlauf / SmartTimeline.
|
||||
- youpc.org / Outlook / PDF export.
|
||||
- Multi-project anchor comparison.
|
||||
- Free-text scenario flag i18n.
|
||||
|
||||
---
|
||||
|
||||
## §13 Synthesis links
|
||||
|
||||
- **mBrian** (after m's ratification): file as `[synthesis]` linked `triggered_by` t-paliad-337; `related_to` cronus's unified-procedural-events-tool design + atlas's deadline-system-revision + cronus's earlier Fristenrechner overhaul.
|
||||
- **Cross-refs in this repo**: `docs/design-unified-procedural-events-tool-2026-05-27.md` (cronus, U0-U4 shipped today), `docs/design-deadline-system-revision-2026-05-27.md` (atlas Phase 2), `docs/design-fristenrechner-overhaul-2026-05-26.md` (cronus 2026-05-26).
|
||||
- **Gitea**: m/paliad#152 (this design), m/paliad#151 (cronus U0-U4), m/paliad#149 (atlas Phase 2).
|
||||
- **Coder phase** (deferred per inventor SKILL): runs after m ratifies §10 + §11. Slice ordering per §9. NOT atlas (parked at "TRACKER DESIGN READY FOR REVIEW"). Pattern-fluent Sonnet coder picks up T1 first.
|
||||
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.
|
||||
@@ -3,8 +3,7 @@ import { join, relative } from "path";
|
||||
import { renderIndex } from "./src/index";
|
||||
import { renderLogin } from "./src/login";
|
||||
import { renderKostenrechner } from "./src/kostenrechner";
|
||||
import { renderFristenrechner } from "./src/fristenrechner";
|
||||
import { renderVerfahrensablauf } from "./src/verfahrensablauf";
|
||||
import { renderProcedures } from "./src/procedures";
|
||||
import { renderDownloads } from "./src/downloads";
|
||||
import { renderLinks } from "./src/links";
|
||||
import { renderGlossary } from "./src/glossary";
|
||||
@@ -241,8 +240,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/index.ts"),
|
||||
join(import.meta.dir, "src/client/login.ts"),
|
||||
join(import.meta.dir, "src/client/kostenrechner.ts"),
|
||||
join(import.meta.dir, "src/client/fristenrechner.ts"),
|
||||
join(import.meta.dir, "src/client/verfahrensablauf.ts"),
|
||||
join(import.meta.dir, "src/client/procedures.ts"),
|
||||
join(import.meta.dir, "src/client/downloads.ts"),
|
||||
join(import.meta.dir, "src/client/links.ts"),
|
||||
join(import.meta.dir, "src/client/glossary.ts"),
|
||||
@@ -369,8 +367,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "index.html"), renderIndex());
|
||||
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
|
||||
await Bun.write(join(DIST, "kostenrechner.html"), renderKostenrechner());
|
||||
await Bun.write(join(DIST, "fristenrechner.html"), renderFristenrechner());
|
||||
await Bun.write(join(DIST, "verfahrensablauf.html"), renderVerfahrensablauf());
|
||||
await Bun.write(join(DIST, "procedures.html"), renderProcedures());
|
||||
await Bun.write(join(DIST, "downloads.html"), renderDownloads());
|
||||
await Bun.write(join(DIST, "links.html"), renderLinks());
|
||||
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
|
||||
|
||||
@@ -77,9 +77,9 @@ export function renderAdminRulesList(): string {
|
||||
<div className="admin-rules-filter admin-rules-filter-chips">
|
||||
<span className="admin-rules-filter-label" data-i18n="admin.rules.filter.lifecycle">Lifecycle</span>
|
||||
<div className="admin-rules-chips" id="rules-filter-lifecycle">
|
||||
<button type="button" className="admin-rules-chip active" data-state="" data-i18n="admin.rules.filter.lifecycle.any">Alle</button>
|
||||
<button type="button" className="admin-rules-chip" data-state="" data-i18n="admin.rules.filter.lifecycle.any">Alle</button>
|
||||
<button type="button" className="admin-rules-chip" data-state="draft" data-i18n="admin.rules.lifecycle.draft">Draft</button>
|
||||
<button type="button" className="admin-rules-chip" data-state="published" data-i18n="admin.rules.lifecycle.published">Published</button>
|
||||
<button type="button" className="admin-rules-chip active" data-state="published" data-i18n="admin.rules.lifecycle.published">Published</button>
|
||||
<button type="button" className="admin-rules-chip" data-state="archived" data-i18n="admin.rules.lifecycle.archived">Archived</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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]) : "";
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ let triggerEvents: TriggerEvent[] = [];
|
||||
|
||||
let activeProceeding = "";
|
||||
let activeTrigger = "";
|
||||
let activeLifecycle = "";
|
||||
let activeLifecycle = "published";
|
||||
let activeQuery = "";
|
||||
let searchDebounce: number | undefined;
|
||||
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
defaultChecked,
|
||||
groupFollowUps,
|
||||
type FollowUpRule,
|
||||
} from "./fristenrechner-result";
|
||||
|
||||
// Pure helpers exercised here; the DOM-driven render path is covered
|
||||
// by the live page test path (S2 is mount-on-deep-link, S3+S4 add the
|
||||
// entry-mode UIs in later slices).
|
||||
|
||||
function mk(partial: Partial<FollowUpRule>): FollowUpRule {
|
||||
return {
|
||||
rule_id: "r" + Math.random().toString(36).slice(2, 8),
|
||||
event_code: "evt",
|
||||
title_de: "Frist",
|
||||
title_en: "Deadline",
|
||||
priority: "mandatory",
|
||||
is_court_set: false,
|
||||
is_spawn: false,
|
||||
is_bilateral: false,
|
||||
has_condition: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("groupFollowUps — design §4.2 priority+condition buckets", () => {
|
||||
test("groups by priority; conditional takes precedence over priority", () => {
|
||||
const rows = [
|
||||
mk({ priority: "mandatory" }),
|
||||
mk({ priority: "recommended" }),
|
||||
mk({ priority: "optional" }),
|
||||
mk({ priority: "mandatory", has_condition: true }), // → conditional
|
||||
mk({ priority: "optional", has_condition: true }), // → conditional
|
||||
];
|
||||
const g = groupFollowUps(rows);
|
||||
expect(g.mandatory.length).toBe(1);
|
||||
expect(g.recommended.length).toBe(1);
|
||||
expect(g.optional.length).toBe(1);
|
||||
expect(g.conditional.length).toBe(2);
|
||||
});
|
||||
|
||||
test("unknown priority falls through to optional", () => {
|
||||
const g = groupFollowUps([mk({ priority: "informational" })]);
|
||||
expect(g.optional.length).toBe(1);
|
||||
expect(g.mandatory.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultChecked — pre-checks mandatory + recommended, not conditional/court-set", () => {
|
||||
test("mandatory rules pre-checked", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory" }))).toBe(true);
|
||||
});
|
||||
test("recommended rules pre-checked", () => {
|
||||
expect(defaultChecked(mk({ priority: "recommended" }))).toBe(true);
|
||||
});
|
||||
test("optional rules unchecked", () => {
|
||||
expect(defaultChecked(mk({ priority: "optional" }))).toBe(false);
|
||||
});
|
||||
test("conditional rules unchecked", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory", has_condition: true }))).toBe(false);
|
||||
});
|
||||
test("court-set rules unchecked even when mandatory", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory", is_court_set: true }))).toBe(false);
|
||||
});
|
||||
test("spawned rules pre-checked when mandatory", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory", is_spawn: true }))).toBe(true);
|
||||
});
|
||||
test("spawned optional rules unchecked", () => {
|
||||
expect(defaultChecked(mk({ priority: "optional", is_spawn: true }))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,672 +0,0 @@
|
||||
// Fristenrechner overhaul — shared result view (design §4).
|
||||
//
|
||||
// Given a locked trigger event + a trigger date, this module renders
|
||||
// the result surface: a sticky trigger card on top, then four priority
|
||||
// groups (mandatory / recommended / optional / conditional) of follow-up
|
||||
// rules with computed dates, then a write-back footer that calls the
|
||||
// existing POST /api/projects/{id}/deadlines/bulk.
|
||||
//
|
||||
// The two future entry paths (Mode A "Direkt suchen" in S3, Mode B
|
||||
// wizard in S4) both land here once they've identified a trigger
|
||||
// procedural_event. S2 mounts the surface under `?overhaul=1` and is
|
||||
// deep-linkable on its own via `?overhaul=1&event=<code>&trigger_date=…`.
|
||||
|
||||
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
|
||||
import { getLang, t, tDyn } from "./i18n";
|
||||
|
||||
// Wire shape from GET /api/tools/fristenrechner/follow-ups. Mirrors
|
||||
// services.FollowUpsResponse server-side.
|
||||
export interface FollowUpRule {
|
||||
rule_id: string;
|
||||
event_code: string;
|
||||
title_de: string;
|
||||
title_en: string;
|
||||
priority: string;
|
||||
primary_party?: string;
|
||||
duration_value?: number;
|
||||
duration_unit?: string;
|
||||
timing?: string;
|
||||
due_date?: string;
|
||||
original_due_date?: string;
|
||||
was_adjusted?: boolean;
|
||||
is_court_set: boolean;
|
||||
is_spawn: boolean;
|
||||
is_bilateral: boolean;
|
||||
has_condition: boolean;
|
||||
rule_code?: string;
|
||||
legal_source?: string;
|
||||
legal_source_display?: string;
|
||||
legal_source_url?: string;
|
||||
notes_de?: string;
|
||||
notes_en?: string;
|
||||
spawn_label?: string;
|
||||
spawn_proceeding_code?: string;
|
||||
concept_id?: string;
|
||||
}
|
||||
|
||||
export interface FollowUpsResponse {
|
||||
trigger: {
|
||||
id: string;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
event_kind?: string;
|
||||
proceeding_type: {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string;
|
||||
};
|
||||
anchor_rule_id: string;
|
||||
};
|
||||
trigger_date: string;
|
||||
party?: string;
|
||||
follow_ups: FollowUpRule[];
|
||||
}
|
||||
|
||||
// Per-rule UI state — checkbox, optional date override.
|
||||
interface RuleSelection {
|
||||
checked: boolean;
|
||||
override?: string;
|
||||
}
|
||||
|
||||
// Module-local state. Single result view at a time; the surface
|
||||
// re-renders in place when the user changes the trigger date or
|
||||
// re-locks a different event.
|
||||
let currentResponse: FollowUpsResponse | null = null;
|
||||
const selections = new Map<string, RuleSelection>();
|
||||
let currentProjectId: string | null = null;
|
||||
|
||||
// Public API ----------------------------------------------------------
|
||||
|
||||
// isOverhaulMode reports whether the page is in overhaul mode.
|
||||
// After Slice S5 (t-paliad-323), overhaul is the default; the legacy
|
||||
// wizard / row-stack / cascade is only reachable via `?legacy=1` for
|
||||
// a two-week deprecation window. The `?overhaul=1` deep links from
|
||||
// S2-S4 still work — they're now redundant with the default but kept
|
||||
// alive so bookmarks don't 302 / lose state.
|
||||
export function isOverhaulMode(): boolean {
|
||||
return new URLSearchParams(window.location.search).get("legacy") !== "1";
|
||||
}
|
||||
|
||||
// resolveProjectId reads the active Akte from the URL query string.
|
||||
// Returns null when in kontextfrei mode (no project picked).
|
||||
function resolveProjectId(): string | null {
|
||||
const p = new URLSearchParams(window.location.search).get("project");
|
||||
return p && p.length > 0 ? p : null;
|
||||
}
|
||||
|
||||
// MODE_TAB_KEYS — the two entry-mode tabs landed by S3 + S4. S2's deep
|
||||
// link path bypasses these (jumps straight to the result view via
|
||||
// ?event=); the tabs appear when no event is locked yet.
|
||||
export type ModeTab = "search" | "wizard";
|
||||
|
||||
// mountModeShell renders the mode-tab pair under the page header and
|
||||
// hosts whichever mode panel is currently active. Called from the boot
|
||||
// path when no `?event=` is present. S3 wires Mode A; S4 will add
|
||||
// Mode B and the actual tab switching.
|
||||
export async function mountModeShell(activeTab: ModeTab): Promise<void> {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (!root) return;
|
||||
root.hidden = false;
|
||||
// Defer to the per-mode module to render into the root. The tab
|
||||
// strip itself is a small header above the mode panel — for S3 we
|
||||
// render the shell + Mode A in one shot.
|
||||
// S4 will replace this with a real tab switcher.
|
||||
const tabs = `
|
||||
<nav class="fristen-mode-tabs" role="tablist" aria-label="${escAttr(t("deadlines.overhaul.modes.label"))}">
|
||||
<button type="button" class="fristen-mode-tab${activeTab === "search" ? " is-active" : ""}" role="tab"
|
||||
aria-selected="${activeTab === "search"}" data-tab="search">
|
||||
<span class="fristen-mode-tab-icon" aria-hidden="true">⚡</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.
|
||||
export interface MountOptions {
|
||||
// eventRef is the procedural_event code OR its uuid OR the anchor
|
||||
// sequencing_rule id. Resolved server-side; the wire returns the
|
||||
// canonical code so the URL bookmark is stable.
|
||||
eventRef: string;
|
||||
// triggerDate is YYYY-MM-DD. Defaults to today when omitted.
|
||||
triggerDate?: string;
|
||||
// party is "claimant" | "defendant"; mode A may pass "both" or
|
||||
// "court". When omitted, follow-ups are returned without party
|
||||
// narrowing.
|
||||
party?: string;
|
||||
// courtId selects the holiday calendar for the per-rule date
|
||||
// adjustment. Optional.
|
||||
courtId?: string;
|
||||
}
|
||||
|
||||
// mountResultView fetches /follow-ups and renders the result surface
|
||||
// into the host container. Re-callable: replaces previous state.
|
||||
export async function mountResultView(opts: MountOptions): Promise<void> {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (!root) return;
|
||||
root.hidden = false;
|
||||
|
||||
const triggerDate = opts.triggerDate || todayIso();
|
||||
currentProjectId = resolveProjectId();
|
||||
|
||||
// Show a quick "loading…" placeholder so the user sees something
|
||||
// immediately, even on a cold fetch.
|
||||
root.innerHTML = `<div class="fristen-overhaul-loading">${escHtml(t("deadlines.overhaul.loading"))}</div>`;
|
||||
|
||||
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
|
||||
url.searchParams.set("event", opts.eventRef);
|
||||
url.searchParams.set("trigger_date", triggerDate);
|
||||
if (opts.party) url.searchParams.set("party", opts.party);
|
||||
if (opts.courtId) url.searchParams.set("court_id", opts.courtId);
|
||||
|
||||
let data: FollowUpsResponse;
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}) as { error?: string });
|
||||
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(body.error || t("deadlines.overhaul.load_error"))}</div>`;
|
||||
return;
|
||||
}
|
||||
data = (await resp.json()) as FollowUpsResponse;
|
||||
} catch (err) {
|
||||
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(t("deadlines.overhaul.load_error"))}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
currentResponse = data;
|
||||
selections.clear();
|
||||
for (const r of data.follow_ups) {
|
||||
selections.set(r.rule_id, { checked: defaultChecked(r) });
|
||||
}
|
||||
|
||||
renderSurface();
|
||||
// Reflect the canonical event code + trigger date in the URL so the
|
||||
// deep-link survives a reload.
|
||||
syncUrlState(data.trigger.code, data.trigger_date);
|
||||
}
|
||||
|
||||
// Render --------------------------------------------------------------
|
||||
|
||||
function renderSurface(): void {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (!root || !currentResponse) return;
|
||||
|
||||
const lang = getLang();
|
||||
const trig = currentResponse.trigger;
|
||||
const triggerName = lang === "en" ? trig.name_en || trig.name_de : trig.name_de;
|
||||
const ptName = lang === "en" ? trig.proceeding_type.name_en || trig.proceeding_type.name_de : trig.proceeding_type.name_de;
|
||||
const juris = trig.proceeding_type.jurisdiction || "";
|
||||
const kindIcon = eventKindIcon(trig.event_kind);
|
||||
|
||||
const triggerCard = `
|
||||
<section class="fristen-overhaul-trigger" aria-label="${escAttr(t("deadlines.overhaul.trigger.label"))}">
|
||||
<header class="fristen-overhaul-trigger-header">
|
||||
<span class="fristen-overhaul-kind-icon" aria-hidden="true">${kindIcon}</span>
|
||||
<h2 class="fristen-overhaul-trigger-title">${escHtml(triggerName)}</h2>
|
||||
</header>
|
||||
<div class="fristen-overhaul-trigger-meta">
|
||||
<span class="fristen-overhaul-trigger-code">${escHtml(trig.code)}</span>
|
||||
<span class="fristen-overhaul-trigger-pt">${escHtml(ptName)}</span>
|
||||
${juris ? `<span class="fristen-overhaul-trigger-juris">${escHtml(juris)}</span>` : ""}
|
||||
</div>
|
||||
<div class="fristen-overhaul-trigger-date">
|
||||
<label for="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-label">
|
||||
${escHtml(t("deadlines.overhaul.trigger.date"))}
|
||||
</label>
|
||||
<input type="date" id="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-input"
|
||||
value="${escAttr(currentResponse.trigger_date)}" />
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
const groups = groupFollowUps(currentResponse.follow_ups);
|
||||
const groupHtml = renderGroups(groups, lang);
|
||||
|
||||
const nudge = currentProjectId
|
||||
? ""
|
||||
: `<div class="fristen-overhaul-nudge">${escHtml(t("deadlines.overhaul.nudge.no_project"))}</div>`;
|
||||
|
||||
const footer = currentProjectId
|
||||
? renderFooter()
|
||||
: "";
|
||||
|
||||
root.innerHTML = `
|
||||
${triggerCard}
|
||||
${nudge}
|
||||
<section class="fristen-overhaul-groups" aria-label="${escAttr(t("deadlines.overhaul.followups.label"))}">
|
||||
${groupHtml}
|
||||
</section>
|
||||
${footer}
|
||||
<div class="fristen-overhaul-msg" id="fristen-overhaul-msg" role="status" aria-live="polite"></div>
|
||||
`;
|
||||
|
||||
wireSurfaceEvents();
|
||||
}
|
||||
|
||||
export interface GroupedFollowUps {
|
||||
mandatory: FollowUpRule[];
|
||||
recommended: FollowUpRule[];
|
||||
optional: FollowUpRule[];
|
||||
conditional: FollowUpRule[];
|
||||
}
|
||||
|
||||
// groupFollowUps splits the wire list into the four visible groups per
|
||||
// design §4.2. Conditional (sr.condition_expr IS NOT NULL) takes
|
||||
// precedence over the priority bucket so a "nur wenn CCR" mandatory
|
||||
// rule renders under Conditional with the gating language visible.
|
||||
export function groupFollowUps(rows: FollowUpRule[]): GroupedFollowUps {
|
||||
const out: GroupedFollowUps = { mandatory: [], recommended: [], optional: [], conditional: [] };
|
||||
for (const r of rows) {
|
||||
if (r.has_condition) {
|
||||
out.conditional.push(r);
|
||||
continue;
|
||||
}
|
||||
switch (r.priority) {
|
||||
case "mandatory":
|
||||
out.mandatory.push(r);
|
||||
break;
|
||||
case "recommended":
|
||||
out.recommended.push(r);
|
||||
break;
|
||||
case "optional":
|
||||
out.optional.push(r);
|
||||
break;
|
||||
default:
|
||||
// unknown / informational — fold into optional so the row is at
|
||||
// least visible. Future Phase 2 'informational' tier gets a
|
||||
// dedicated bucket once seeded.
|
||||
out.optional.push(r);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderGroups(groups: GroupedFollowUps, lang: "de" | "en"): string {
|
||||
const blocks: string[] = [];
|
||||
if (groups.mandatory.length > 0) {
|
||||
blocks.push(renderGroup("mandatory", t("deadlines.overhaul.group.mandatory"), groups.mandatory, lang));
|
||||
}
|
||||
if (groups.recommended.length > 0) {
|
||||
blocks.push(renderGroup("recommended", t("deadlines.overhaul.group.recommended"), groups.recommended, lang));
|
||||
}
|
||||
if (groups.optional.length > 0) {
|
||||
blocks.push(renderGroup("optional", t("deadlines.overhaul.group.optional"), groups.optional, lang));
|
||||
}
|
||||
if (groups.conditional.length > 0) {
|
||||
blocks.push(renderGroup("conditional", t("deadlines.overhaul.group.conditional"), groups.conditional, lang));
|
||||
}
|
||||
if (blocks.length === 0) {
|
||||
return `<div class="fristen-overhaul-empty">${escHtml(t("deadlines.overhaul.empty"))}</div>`;
|
||||
}
|
||||
return blocks.join("");
|
||||
}
|
||||
|
||||
function renderGroup(slug: string, label: string, rows: FollowUpRule[], lang: "de" | "en"): string {
|
||||
const items = rows.map((r) => renderRule(r, lang)).join("");
|
||||
return `
|
||||
<div class="fristen-overhaul-group fristen-overhaul-group--${escAttr(slug)}">
|
||||
<h3 class="fristen-overhaul-group-title">${escHtml(label)}</h3>
|
||||
<ul class="fristen-overhaul-rule-list">
|
||||
${items}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRule(r: FollowUpRule, lang: "de" | "en"): string {
|
||||
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
|
||||
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
|
||||
const sel = selections.get(r.rule_id);
|
||||
const checked = sel ? sel.checked : defaultChecked(r);
|
||||
const dateOverride = sel?.override;
|
||||
const computedDate = r.due_date || "";
|
||||
const effectiveDate = dateOverride || computedDate;
|
||||
const disabled = r.is_court_set || (r.is_spawn && !r.due_date);
|
||||
|
||||
// Duration phrase: "3 Monate" / "14 Tage" — language-aware.
|
||||
const durationPhrase = formatDurationPhrase(r, lang);
|
||||
const dateCell = r.is_court_set
|
||||
? `<span class="fristen-overhaul-rule-court-set">${escHtml(t("deadlines.court.set"))}</span>`
|
||||
: effectiveDate
|
||||
? `<span class="fristen-overhaul-rule-date" data-rule-id="${escAttr(r.rule_id)}">${escHtml(formatDateForLang(effectiveDate, lang))}</span>`
|
||||
: `<span class="fristen-overhaul-rule-date fristen-overhaul-rule-date--unknown">—</span>`;
|
||||
|
||||
const partyBadge = r.primary_party
|
||||
? `<span class="fristen-overhaul-rule-party fristen-overhaul-rule-party--${escAttr(r.primary_party)}">${escHtml(t(`deadlines.party.${r.primary_party}` as never))}</span>`
|
||||
: "";
|
||||
|
||||
const sourceBadge = r.legal_source_display
|
||||
? r.legal_source_url
|
||||
? `<a class="fristen-overhaul-rule-source" href="${escAttr(r.legal_source_url)}" target="_blank" rel="noreferrer">${escHtml(r.legal_source_display)}</a>`
|
||||
: `<span class="fristen-overhaul-rule-source">${escHtml(r.legal_source_display)}</span>`
|
||||
: r.rule_code
|
||||
? `<span class="fristen-overhaul-rule-source">${escHtml(r.rule_code)}</span>`
|
||||
: "";
|
||||
|
||||
const spawnBadge = r.is_spawn
|
||||
? `<span class="fristen-overhaul-rule-spawn" title="${escAttr(t("deadlines.overhaul.spawn.tooltip"))}">${escHtml(t("deadlines.overhaul.spawn.badge"))}${r.spawn_proceeding_code ? ` · ${escHtml(r.spawn_proceeding_code)}` : ""}</span>`
|
||||
: "";
|
||||
|
||||
const condBadge = r.has_condition
|
||||
? `<span class="fristen-overhaul-rule-cond">${escHtml(t("deadlines.overhaul.condition.badge"))}</span>`
|
||||
: "";
|
||||
|
||||
const notesHtml = notes
|
||||
? `<details class="fristen-overhaul-rule-notes"><summary>${escHtml(t("deadlines.overhaul.notes.summary"))}</summary><p>${escHtml(notes)}</p></details>`
|
||||
: "";
|
||||
|
||||
const editBtn = r.is_court_set || r.is_spawn || !computedDate
|
||||
? ""
|
||||
: `<button type="button" class="fristen-overhaul-rule-edit-date" data-rule-id="${escAttr(r.rule_id)}" title="${escAttr(t("deadlines.overhaul.edit_date.title"))}" aria-label="${escAttr(t("deadlines.overhaul.edit_date.title"))}">${escHtml(t("deadlines.overhaul.edit_date.label"))}</button>`;
|
||||
|
||||
return `
|
||||
<li class="fristen-overhaul-rule${disabled ? " is-disabled" : ""}" data-rule-id="${escAttr(r.rule_id)}">
|
||||
<label class="fristen-overhaul-rule-check">
|
||||
<input type="checkbox" data-rule-id="${escAttr(r.rule_id)}"
|
||||
${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
|
||||
<span class="visually-hidden">${escHtml(t("deadlines.overhaul.select_rule"))}</span>
|
||||
</label>
|
||||
<div class="fristen-overhaul-rule-body">
|
||||
<div class="fristen-overhaul-rule-title-row">
|
||||
<span class="fristen-overhaul-rule-title">${escHtml(title)}</span>
|
||||
${spawnBadge}
|
||||
${condBadge}
|
||||
</div>
|
||||
<div class="fristen-overhaul-rule-meta-row">
|
||||
${durationPhrase ? `<span class="fristen-overhaul-rule-duration">${escHtml(durationPhrase)}</span>` : ""}
|
||||
${partyBadge}
|
||||
${sourceBadge}
|
||||
</div>
|
||||
${notesHtml}
|
||||
</div>
|
||||
<div class="fristen-overhaul-rule-date-cell">
|
||||
${dateCell}
|
||||
${editBtn}
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFooter(): string {
|
||||
const selectedCount = countSelected();
|
||||
return `
|
||||
<footer class="fristen-overhaul-footer" id="fristen-overhaul-footer">
|
||||
<span class="fristen-overhaul-footer-count" id="fristen-overhaul-footer-count">
|
||||
${escHtml(tDyn("deadlines.overhaul.footer.count").replace("{n}", String(selectedCount)))}
|
||||
</span>
|
||||
<button type="button" class="fristen-overhaul-footer-cta btn-primary btn-cta-lime"
|
||||
id="fristen-overhaul-write-back"
|
||||
${selectedCount === 0 ? "disabled" : ""}>
|
||||
${escHtml(t("deadlines.overhaul.footer.cta"))}
|
||||
</button>
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
|
||||
// Event wiring --------------------------------------------------------
|
||||
|
||||
function wireSurfaceEvents(): void {
|
||||
// Trigger-date change → re-fetch with new date.
|
||||
const dateInput = document.getElementById("fristen-overhaul-trigger-date") as HTMLInputElement | null;
|
||||
if (dateInput && currentResponse) {
|
||||
dateInput.addEventListener("change", () => {
|
||||
if (!currentResponse) return;
|
||||
const newDate = dateInput.value;
|
||||
if (!newDate) return;
|
||||
void mountResultView({
|
||||
eventRef: currentResponse.trigger.code,
|
||||
triggerDate: newDate,
|
||||
party: currentResponse.party,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Checkbox toggles → update selections + footer count.
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (root) {
|
||||
root.querySelectorAll<HTMLInputElement>(".fristen-overhaul-rule-check input[type=checkbox]").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const id = cb.dataset.ruleId || "";
|
||||
const sel = selections.get(id) ?? { checked: cb.checked };
|
||||
sel.checked = cb.checked;
|
||||
selections.set(id, sel);
|
||||
refreshFooterCount();
|
||||
});
|
||||
});
|
||||
|
||||
// Per-rule date override.
|
||||
root.querySelectorAll<HTMLButtonElement>(".fristen-overhaul-rule-edit-date").forEach((btn) => {
|
||||
btn.addEventListener("click", () => editRuleDate(btn));
|
||||
});
|
||||
}
|
||||
|
||||
// Write-back CTA.
|
||||
const cta = document.getElementById("fristen-overhaul-write-back");
|
||||
if (cta) cta.addEventListener("click", () => void submitWriteBack());
|
||||
}
|
||||
|
||||
function editRuleDate(btn: HTMLButtonElement): void {
|
||||
const ruleId = btn.dataset.ruleId || "";
|
||||
const rule = currentResponse?.follow_ups.find((r) => r.rule_id === ruleId);
|
||||
if (!rule) return;
|
||||
const sel = selections.get(ruleId) ?? { checked: defaultChecked(rule) };
|
||||
const current = sel.override || rule.due_date || todayIso();
|
||||
|
||||
const dateCell = btn.parentElement;
|
||||
if (!dateCell) return;
|
||||
const dateSpan = dateCell.querySelector<HTMLSpanElement>(".fristen-overhaul-rule-date");
|
||||
if (!dateSpan) return;
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "date";
|
||||
input.value = current;
|
||||
input.className = "fristen-overhaul-rule-date-input";
|
||||
dateSpan.replaceWith(input);
|
||||
btn.disabled = true;
|
||||
input.focus();
|
||||
|
||||
const commit = () => {
|
||||
const newDate = input.value;
|
||||
if (newDate && newDate !== current) {
|
||||
sel.override = newDate;
|
||||
selections.set(ruleId, sel);
|
||||
}
|
||||
renderSurface();
|
||||
};
|
||||
input.addEventListener("blur", commit, { once: true });
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") {
|
||||
e.preventDefault();
|
||||
input.blur();
|
||||
} else if ((e as KeyboardEvent).key === "Escape") {
|
||||
renderSurface();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshFooterCount(): void {
|
||||
const countEl = document.getElementById("fristen-overhaul-footer-count");
|
||||
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
|
||||
const n = countSelected();
|
||||
if (countEl) {
|
||||
countEl.textContent = tDyn("deadlines.overhaul.footer.count").replace("{n}", String(n));
|
||||
}
|
||||
if (cta) cta.disabled = n === 0;
|
||||
}
|
||||
|
||||
function countSelected(): number {
|
||||
let n = 0;
|
||||
if (!currentResponse) return 0;
|
||||
for (const r of currentResponse.follow_ups) {
|
||||
if (r.is_court_set) continue;
|
||||
const sel = selections.get(r.rule_id);
|
||||
if (sel?.checked) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Write-back ----------------------------------------------------------
|
||||
|
||||
async function submitWriteBack(): Promise<void> {
|
||||
if (!currentResponse) return;
|
||||
if (!currentProjectId) return;
|
||||
const msg = document.getElementById("fristen-overhaul-msg");
|
||||
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
|
||||
const lang = getLang();
|
||||
|
||||
const deadlines: Array<Record<string, unknown>> = [];
|
||||
for (const r of currentResponse.follow_ups) {
|
||||
const sel = selections.get(r.rule_id);
|
||||
if (!sel?.checked) continue;
|
||||
if (r.is_court_set) continue;
|
||||
const dueDate = sel.override || r.due_date;
|
||||
if (!dueDate) continue;
|
||||
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
|
||||
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
|
||||
deadlines.push({
|
||||
title,
|
||||
rule_code: r.rule_code || undefined,
|
||||
due_date: dueDate,
|
||||
original_due_date: r.original_due_date || r.due_date || undefined,
|
||||
source: "fristenrechner",
|
||||
rule_id: r.rule_id,
|
||||
notes: notes || undefined,
|
||||
audit_reason: auditReason(),
|
||||
});
|
||||
}
|
||||
|
||||
if (deadlines.length === 0 || !msg || !cta) return;
|
||||
cta.disabled = true;
|
||||
msg.textContent = "";
|
||||
msg.className = "fristen-overhaul-msg";
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(currentProjectId)}/deadlines/bulk`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ deadlines }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = body.error || t("deadlines.save.error");
|
||||
msg.className = "fristen-overhaul-msg form-msg-error";
|
||||
cta.disabled = false;
|
||||
return;
|
||||
}
|
||||
msg.innerHTML = `${escHtml(t("deadlines.save.success"))} <a href="/deadlines?project_id=${encodeURIComponent(currentProjectId)}">${escHtml(t("deadlines.save.success.link"))}</a>`;
|
||||
msg.className = "fristen-overhaul-msg form-msg-ok";
|
||||
setTimeout(() => {
|
||||
if (cta) cta.disabled = false;
|
||||
}, 1500);
|
||||
} catch {
|
||||
msg.textContent = t("deadlines.save.error");
|
||||
msg.className = "fristen-overhaul-msg form-msg-error";
|
||||
cta.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// audit reason per design §11.Q12: "Aus Fristenrechner — Trigger: {name} ({date})".
|
||||
function auditReason(): string {
|
||||
if (!currentResponse) return "";
|
||||
const name = currentResponse.trigger.name_de;
|
||||
const date = currentResponse.trigger_date;
|
||||
return `Aus Fristenrechner — Trigger: ${name} (${date})`;
|
||||
}
|
||||
|
||||
// Helpers -------------------------------------------------------------
|
||||
|
||||
export function defaultChecked(r: FollowUpRule): boolean {
|
||||
if (r.is_court_set) return false;
|
||||
if (r.is_spawn) return r.priority === "mandatory";
|
||||
if (r.has_condition) return false;
|
||||
return r.priority === "mandatory" || r.priority === "recommended";
|
||||
}
|
||||
|
||||
function formatDurationPhrase(r: FollowUpRule, lang: "de" | "en"): string {
|
||||
if (!r.duration_value || !r.duration_unit) return "";
|
||||
const unitDE: Record<string, string> = {
|
||||
days: "Tage",
|
||||
months: "Monate",
|
||||
weeks: "Wochen",
|
||||
years: "Jahre",
|
||||
};
|
||||
const unitEN: Record<string, string> = {
|
||||
days: "days",
|
||||
months: "months",
|
||||
weeks: "weeks",
|
||||
years: "years",
|
||||
};
|
||||
const u = (lang === "en" ? unitEN : unitDE)[r.duration_unit] || r.duration_unit;
|
||||
return `${r.duration_value} ${u}`;
|
||||
}
|
||||
|
||||
function formatDateForLang(iso: string, lang: "de" | "en"): string {
|
||||
// YYYY-MM-DD → DE: DD.MM.YYYY / EN: DD MMM YYYY (short).
|
||||
if (!iso || iso.length < 10) return iso;
|
||||
const [y, m, d] = iso.split("-");
|
||||
if (!y || !m || !d) return iso;
|
||||
if (lang === "en") {
|
||||
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
const idx = parseInt(m, 10) - 1;
|
||||
const mn = idx >= 0 && idx < months.length ? months[idx] : m;
|
||||
return `${parseInt(d, 10)} ${mn} ${y}`;
|
||||
}
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
function eventKindIcon(kind?: string): string {
|
||||
switch (kind) {
|
||||
case "filing": return "📥"; // inbox/letter
|
||||
case "hearing": return "🏛️"; // courthouse
|
||||
case "decision": return "⚖️"; // scales
|
||||
case "order": return "📜"; // page
|
||||
default: return "📅"; // calendar
|
||||
}
|
||||
}
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function syncUrlState(eventCode: string, triggerDate: string): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("overhaul", "1");
|
||||
url.searchParams.set("event", eventCode);
|
||||
url.searchParams.set("trigger_date", triggerDate);
|
||||
history.replaceState(null, "", url.pathname + url.search + url.hash);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,711 +0,0 @@
|
||||
// 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);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -20,8 +20,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// Navigation
|
||||
"nav.home": "Home",
|
||||
"nav.kostenrechner": "Kostenrechner",
|
||||
"nav.fristenrechner": "Fristenrechner",
|
||||
"nav.verfahrensablauf": "Verfahrensablauf",
|
||||
"nav.downloads": "Downloads",
|
||||
"nav.links": "Links",
|
||||
"nav.glossar": "Glossar",
|
||||
@@ -200,10 +198,58 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.heading": "Fristenrechner",
|
||||
"deadlines.subtitle": "Berechnung von Verfahrensfristen f\u00fcr UPC-, deutsche und EPA-Verfahren.",
|
||||
|
||||
// Verfahrensablauf (t-paliad-179 Slice 1)
|
||||
"tools.verfahrensablauf.title": "Verfahrensablauf \u2014 Paliad",
|
||||
"tools.verfahrensablauf.heading": "Verfahrensablauf",
|
||||
"tools.verfahrensablauf.subtitle": "Typischen Verfahrensablauf einsehen \u2014 Verfahrensart w\u00e4hlen, Datum optional setzen.",
|
||||
// Unified procedural-events tool (m/paliad#151)
|
||||
"procedures.title": "Verfahren & Fristen \u2014 Paliad",
|
||||
"procedures.heading": "Verfahren & Fristen",
|
||||
"procedures.subtitle": "Verfahrensablauf, Fristenrechner und gef\u00fchrte Suche in einem Tool.",
|
||||
"procedures.filter.search.placeholder": "Klageerhebung, Hinweisbeschluss, oral hearing\u2026",
|
||||
"procedures.filter.axis.forum": "Forum:",
|
||||
"procedures.filter.axis.proc": "Verfahren:",
|
||||
"procedures.filter.axis.kind": "Ereignisart:",
|
||||
"procedures.filter.axis.party": "Partei:",
|
||||
"procedures.tab.proceeding": "Verfahren w\u00e4hlen",
|
||||
"procedures.tab.search": "Direkt suchen",
|
||||
"procedures.tab.wizard": "Gef\u00fchrt",
|
||||
"procedures.tab.akte": "Aus Akte",
|
||||
"procedures.panel.akte.placeholder": "Akten-Einstieg folgt in einem sp\u00e4teren Slice.",
|
||||
|
||||
// Workflow-tracker shell (m/paliad#152 T1+) \u2014 keys for the new
|
||||
// /tools/procedures shape (find header + per-proceeding cards).
|
||||
"procedures.filter.axis.date": "Stichtag:",
|
||||
"procedures.filter.forum.all": "Alle",
|
||||
"procedures.filter.party.all": "Alle",
|
||||
"procedures.timelines.loading": "Verfahren werden geladen\u2026",
|
||||
"procedures.timelines.empty": "Keine Verfahren passen. Filter zur\u00fccksetzen.",
|
||||
"procedures.timelines.error": "Fehler beim Laden dieses Verfahrens.",
|
||||
"procedures.timelines.options": "Optionen:",
|
||||
"procedures.timelines.court_set": "vom Gericht bestimmt",
|
||||
"procedures.cold_open.hint": "Suchen oder filtern, um andere Verfahren einzublenden.",
|
||||
"procedures.find.summary.empty": "Keine Treffer.",
|
||||
"procedures.find.summary.one": "{n} Verfahren",
|
||||
"procedures.find.summary.many": "{n} Verfahren",
|
||||
"procedures.find.summary.anchor": "Anker: {name}",
|
||||
"procedures.find.summary.akte": "Akte: {name}",
|
||||
"procedures.node.actual.done": "Erledigt",
|
||||
"procedures.node.actual.overdue": "Überfällig",
|
||||
"procedures.node.actual.open": "Offen",
|
||||
"procedures.node.cross": "Gegenseitige Handlung",
|
||||
"procedures.node.cross.short": "Gegen.",
|
||||
"procedures.proceeding.detail.title": "Detailgrad umschalten",
|
||||
"procedures.proceeding.detail.selected": "· Gewählt ·",
|
||||
"procedures.proceeding.detail.all": "Alle Optionen",
|
||||
"procedures.appeal_target.label": "Berufung gegen:",
|
||||
"procedures.node.pin": "An dieses Ereignis anheften",
|
||||
"procedures.node.fokus": "Fokus \u2014 andere Zweige ausblenden",
|
||||
"procedures.node.here": "\u2500\u2500 DU BIST HIER \u2500\u2500",
|
||||
"procedures.zoom.breadcrumb": "Pfad",
|
||||
"procedures.zoom.hidden": "{n} weitere Schritte verborgen \u2014 Fokus aufheben f\u00fcr volle Ansicht",
|
||||
"procedures.proceeding.toggle": "Verfahren ein-/ausblenden",
|
||||
"procedures.proceeding.show": "zeigen",
|
||||
"procedures.proceeding.hide": "ausblenden",
|
||||
"deadlines.flag.amend": "Mit Antrag auf Patent\u00e4nderung",
|
||||
"deadlines.flag.cci": "Mit Verletzungswiderklage",
|
||||
|
||||
"nav.procedures": "Verfahren & Fristen",
|
||||
|
||||
"deadlines.step1": "Verfahrensart w\u00e4hlen",
|
||||
"deadlines.step2": "Ausgangsdatum eingeben",
|
||||
@@ -1023,7 +1069,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",
|
||||
@@ -3208,8 +3263,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// Navigation
|
||||
"nav.home": "Home",
|
||||
"nav.kostenrechner": "Cost Calculator",
|
||||
"nav.fristenrechner": "Deadline Calculator",
|
||||
"nav.verfahrensablauf": "Procedure Roadmap",
|
||||
"nav.downloads": "Downloads",
|
||||
"nav.links": "Links",
|
||||
"nav.glossar": "Glossary",
|
||||
@@ -3386,9 +3439,57 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.subtitle": "Calculate procedural deadlines for UPC, German, and EPA proceedings.",
|
||||
|
||||
// Verfahrensablauf (t-paliad-179 Slice 1)
|
||||
"tools.verfahrensablauf.title": "Procedure Roadmap \u2014 Paliad",
|
||||
"tools.verfahrensablauf.heading": "Procedure Roadmap",
|
||||
"tools.verfahrensablauf.subtitle": "Browse the typical proceeding shape \u2014 pick a proceeding type, optionally set a trigger date.",
|
||||
// Unified procedural-events tool (m/paliad#151)
|
||||
"procedures.title": "Procedures & Deadlines \u2014 Paliad",
|
||||
"procedures.heading": "Procedures & Deadlines",
|
||||
"procedures.subtitle": "Procedure roadmap, deadline calculator, and guided search in one tool.",
|
||||
"procedures.filter.search.placeholder": "Statement of claim, hearing notice, m\u00fcndliche Verhandlung\u2026",
|
||||
"procedures.filter.axis.forum": "Forum:",
|
||||
"procedures.filter.axis.proc": "Proceeding:",
|
||||
"procedures.filter.axis.kind": "Event kind:",
|
||||
"procedures.filter.axis.party": "Party:",
|
||||
"procedures.tab.proceeding": "Pick proceeding",
|
||||
"procedures.tab.search": "Direct search",
|
||||
"procedures.tab.wizard": "Guided",
|
||||
"procedures.tab.akte": "From matter",
|
||||
"procedures.panel.akte.placeholder": "Matter entry ships in a later slice.",
|
||||
|
||||
// Workflow-tracker shell (m/paliad#152 T1+).
|
||||
"procedures.filter.axis.date": "As of:",
|
||||
"procedures.filter.forum.all": "All",
|
||||
"procedures.filter.party.all": "All",
|
||||
"procedures.timelines.loading": "Loading proceedings…",
|
||||
"procedures.timelines.empty": "No proceedings match. Reset filters.",
|
||||
"procedures.timelines.error": "Failed to load this proceeding.",
|
||||
"procedures.timelines.options": "Options:",
|
||||
"procedures.timelines.court_set": "court-set",
|
||||
"procedures.cold_open.hint": "Search or filter to surface other proceedings.",
|
||||
"procedures.find.summary.empty": "No matches.",
|
||||
"procedures.find.summary.one": "{n} proceeding",
|
||||
"procedures.find.summary.many": "{n} proceedings",
|
||||
"procedures.find.summary.anchor": "Anchor: {name}",
|
||||
"procedures.find.summary.akte": "Matter: {name}",
|
||||
"procedures.node.actual.done": "Done",
|
||||
"procedures.node.actual.overdue": "Overdue",
|
||||
"procedures.node.actual.open": "Open",
|
||||
"procedures.node.cross": "Opposing-side action",
|
||||
"procedures.node.cross.short": "Opp.",
|
||||
"procedures.proceeding.detail.title": "Toggle detail level",
|
||||
"procedures.proceeding.detail.selected": "· Selected ·",
|
||||
"procedures.proceeding.detail.all": "All options",
|
||||
"procedures.appeal_target.label": "Appeal target:",
|
||||
"procedures.node.pin": "Pin this event as the anchor",
|
||||
"procedures.node.fokus": "Focus — hide sibling branches",
|
||||
"procedures.node.here": "── YOU ARE HERE ──",
|
||||
"procedures.zoom.breadcrumb": "Path",
|
||||
"procedures.zoom.hidden": "{n} more steps hidden — unfocus to see all",
|
||||
"procedures.proceeding.toggle": "Toggle proceeding",
|
||||
"procedures.proceeding.show": "show",
|
||||
"procedures.proceeding.hide": "hide",
|
||||
"deadlines.flag.amend": "With patent amendment request",
|
||||
"deadlines.flag.cci": "With infringement counterclaim",
|
||||
|
||||
"nav.procedures": "Procedures & Deadlines",
|
||||
|
||||
"deadlines.step1": "Select Proceeding Type",
|
||||
"deadlines.step2": "Enter Trigger Date",
|
||||
@@ -4209,7 +4310,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",
|
||||
|
||||
@@ -109,7 +109,7 @@ export function routeNameFor(pathname: string): string {
|
||||
if (pathname === "/links") return "links";
|
||||
if (pathname === "/downloads") return "downloads";
|
||||
if (pathname === "/checklists") return "checklists";
|
||||
if (pathname.startsWith("/tools/fristenrechner")) return "tools.fristenrechner";
|
||||
if (pathname.startsWith("/tools/procedures")) return "tools.procedures";
|
||||
if (pathname.startsWith("/tools/kostenrechner")) return "tools.kostenrechner";
|
||||
if (pathname.startsWith("/tools/gebuehrentabellen")) return "tools.gebuehrentabellen";
|
||||
if (pathname === "/events") return "events";
|
||||
|
||||
704
frontend/src/client/procedures-tracker.ts
Normal file
704
frontend/src/client/procedures-tracker.ts
Normal file
@@ -0,0 +1,704 @@
|
||||
// procedures-tracker — render module for /tools/procedures (m/paliad#152
|
||||
// T1 + onwards, docs/design-procedures-workflow-tracker-2026-05-27.md).
|
||||
//
|
||||
// Responsibilities:
|
||||
// - Per-proceeding card render: header + chained tree by parent_id +
|
||||
// priority-styled bullets + scenario-flag fork checkboxes.
|
||||
// - Tree layout: children grouped under their parentRuleCode in
|
||||
// deadlines-list order, root rules surface at depth 0.
|
||||
// - Default detail mode = "selected" (mandatory + recommended +
|
||||
// scenario-flag-enabled). Conditional rules whose gate is OFF are
|
||||
// filtered out by the calculator and don't surface; the
|
||||
// corresponding fork checkbox on the gating node reveals them when
|
||||
// toggled ON.
|
||||
//
|
||||
// T1 floor: card-level "Optionen" strip carries the scenario-flag
|
||||
// forks at the top of each proceeding card. Per-node inline placement
|
||||
// (the design's stated final shape — fork checkbox on the actual
|
||||
// gating node) is a T2 refinement; T1 keeps forks discoverable but
|
||||
// scoped per proceeding so they're not the global-page strip m's bug
|
||||
// #5 flagged.
|
||||
|
||||
import { t, tDyn, getLang } from "./i18n";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
type DeadlineResponse,
|
||||
calculateDeadlines,
|
||||
escHtml,
|
||||
formatDate,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
import { filterByDetailMode } from "./verfahrensablauf-detail-mode";
|
||||
|
||||
// ProceedingDef — the catalog of proceedings the find header pills and
|
||||
// the cold-open default surface against. Kept in sync with paliad's
|
||||
// proceeding_types catalog as of 2026-05-27 (matches
|
||||
// VerfahrensablaufBody.tsx's listing).
|
||||
export interface ProceedingDef {
|
||||
code: string;
|
||||
forum: "upc" | "de" | "epa" | "dpma";
|
||||
i18nKey: string;
|
||||
nameDE: string;
|
||||
nameEN: string;
|
||||
}
|
||||
|
||||
export const PROCEEDINGS: ProceedingDef[] = [
|
||||
{ code: "upc.inf.cfi", forum: "upc", i18nKey: "deadlines.upc.inf.cfi", nameDE: "Verletzungsverfahren", nameEN: "Infringement (CFI)" },
|
||||
{ code: "upc.rev.cfi", forum: "upc", i18nKey: "deadlines.upc.rev.cfi", nameDE: "Nichtigkeitsklage", nameEN: "Revocation (CFI)" },
|
||||
{ code: "upc.ccr.cfi", forum: "upc", i18nKey: "deadlines.upc.ccr.cfi", nameDE: "Widerklage auf Nichtigkeit", nameEN: "Counterclaim for revocation" },
|
||||
{ code: "upc.pi.cfi", forum: "upc", i18nKey: "deadlines.upc.pi.cfi", nameDE: "Einstw. Maßnahmen", nameEN: "Provisional measures" },
|
||||
{ code: "upc.apl.unified", forum: "upc", i18nKey: "deadlines.upc.apl.unified", nameDE: "Berufung UPC", nameEN: "Appeal UPC" },
|
||||
{ code: "upc.dmgs.cfi", forum: "upc", i18nKey: "deadlines.upc.dmgs.cfi", nameDE: "Schadensbemessung", nameEN: "Damages" },
|
||||
{ code: "upc.disc.cfi", forum: "upc", i18nKey: "deadlines.upc.disc.cfi", nameDE: "Bucheinsicht", nameEN: "Inspection of accounts" },
|
||||
{ code: "de.inf.lg", forum: "de", i18nKey: "deadlines.de.inf.lg", nameDE: "LG Verletzungsklage", nameEN: "LG infringement (DE 1st inst.)" },
|
||||
{ code: "de.inf.olg", forum: "de", i18nKey: "deadlines.de.inf.olg", nameDE: "OLG Berufung", nameEN: "OLG appeal" },
|
||||
{ code: "de.inf.bgh", forum: "de", i18nKey: "deadlines.de.inf.bgh", nameDE: "BGH Revision / NZB", nameEN: "BGH revision / NZB" },
|
||||
{ code: "de.null.bpatg", forum: "de", i18nKey: "deadlines.de.null.bpatg", nameDE: "BPatG Nichtigkeit", nameEN: "BPatG revocation" },
|
||||
{ code: "de.null.bgh", forum: "de", i18nKey: "deadlines.de.null.bgh", nameDE: "BGH Berufung (Nichtigkeit)", nameEN: "BGH revocation appeal" },
|
||||
{ code: "epa.opp.opd", forum: "epa", i18nKey: "deadlines.epa.opp.opd", nameDE: "Einspruchsverfahren EPA", nameEN: "EPO opposition" },
|
||||
{ code: "epa.opp.boa", forum: "epa", i18nKey: "deadlines.epa.opp.boa", nameDE: "Beschwerdeverfahren EPA", nameEN: "EPO appeal" },
|
||||
{ code: "epa.grant.exa", forum: "epa", i18nKey: "deadlines.epa.grant.exa", nameDE: "EP-Erteilungsverfahren", nameEN: "EP grant" },
|
||||
{ code: "dpma.opp.dpma", forum: "dpma", i18nKey: "deadlines.dpma.opp.dpma", nameDE: "Einspruch DPMA", nameEN: "DPMA opposition" },
|
||||
{ code: "dpma.appeal.bpatg", forum: "dpma", i18nKey: "deadlines.dpma.appeal.bpatg", nameDE: "Beschwerde BPatG (DPMA)", nameEN: "BPatG appeal (DPMA)" },
|
||||
{ code: "dpma.appeal.bgh", forum: "dpma", i18nKey: "deadlines.dpma.appeal.bgh", nameDE: "Rechtsbeschwerde BGH", nameEN: "BGH legal appeal" },
|
||||
];
|
||||
|
||||
// COLD_OPEN_DEFAULTS — design §8 / §11.Q4. When no URL params and no
|
||||
// Akte context, the page renders these 6 proceedings stacked. Hint
|
||||
// text above the timelines invites the user to filter for more.
|
||||
export const COLD_OPEN_DEFAULTS: string[] = [
|
||||
"upc.inf.cfi",
|
||||
"upc.rev.cfi",
|
||||
"upc.apl.unified",
|
||||
"de.inf.lg",
|
||||
"epa.opp.opd",
|
||||
"dpma.opp.dpma",
|
||||
];
|
||||
|
||||
// FORUM_LABEL mirrors the forum-pill label and the proceeding card
|
||||
// header jurisdiction prefix. Same slugs the proceeding-types catalog
|
||||
// carries.
|
||||
const FORUM_LABEL: Record<string, string> = {
|
||||
upc: "UPC",
|
||||
de: "DE",
|
||||
epa: "EPA",
|
||||
dpma: "DPMA",
|
||||
};
|
||||
|
||||
export function lookupProceeding(code: string): ProceedingDef | undefined {
|
||||
return PROCEEDINGS.find((p) => p.code === code);
|
||||
}
|
||||
|
||||
export function proceedingDisplayName(code: string): string {
|
||||
const def = lookupProceeding(code);
|
||||
if (!def) return code;
|
||||
const lang = getLang();
|
||||
return lang === "en" ? def.nameEN : def.nameDE;
|
||||
}
|
||||
|
||||
// ─── condition_expr flag extraction ─────────────────────────────────────────
|
||||
//
|
||||
// Walks the jsonb tree shape documented in pkg/litigationplanner/expr.go:
|
||||
// {"flag": "<name>"} — leaf
|
||||
// {"op": "and|or|not", "args":[…]} — composite
|
||||
// Returns the set of flag names mentioned in the expression. The page
|
||||
// uses this to decide which scenario_flag forks apply to a given
|
||||
// proceeding (the union over all conditional rules' expressions).
|
||||
|
||||
function collectFlagsFromExpr(node: unknown, out: Set<string>): void {
|
||||
if (!node || typeof node !== "object") return;
|
||||
const n = node as Record<string, unknown>;
|
||||
if (typeof n.flag === "string" && n.flag) {
|
||||
out.add(n.flag);
|
||||
return;
|
||||
}
|
||||
if (Array.isArray(n.args)) {
|
||||
for (const arg of n.args) collectFlagsFromExpr(arg, out);
|
||||
}
|
||||
}
|
||||
|
||||
export function gatingFlagsForProceeding(deadlines: CalculatedDeadline[]): string[] {
|
||||
const set = new Set<string>();
|
||||
for (const dl of deadlines) {
|
||||
if (!dl.conditionExpr) continue;
|
||||
collectFlagsFromExpr(dl.conditionExpr, set);
|
||||
}
|
||||
return Array.from(set).sort();
|
||||
}
|
||||
|
||||
// Hard-coded fallback set: when the calc was run without a flag, the
|
||||
// conditional rules gated by that flag are filtered out server-side
|
||||
// (selected mode doesn't surface their condition_expr). The fallback
|
||||
// lists the flags each proceeding *can* gate so the per-card Optionen
|
||||
// strip surfaces them even on first render with the flag off.
|
||||
//
|
||||
// Mirrors mig 084's backfill: each scenario_flag → the proceedings
|
||||
// where it's referenced by at least one rule. Today's catalog: 18
|
||||
// conditional rules across upc.inf.cfi (with_ccr / with_amend) and
|
||||
// upc.rev.cfi (with_amend / with_cci). Keep in sync with
|
||||
// paliad.sequencing_rules.condition_expr.
|
||||
const FALLBACK_FLAGS: Record<string, string[]> = {
|
||||
"upc.inf.cfi": ["with_ccr", "with_amend"],
|
||||
"upc.rev.cfi": ["with_amend", "with_cci"],
|
||||
};
|
||||
|
||||
// Appeal-target slugs the engine accepts for the upc.apl.unified
|
||||
// proceeding (mig 137 / B1). Each chip filters the appeal timeline to
|
||||
// the rule subset whose applies_to_target jsonb contains the slug.
|
||||
// Same vocabulary the VerfahrensablaufBody chip group exposes.
|
||||
export const APPEAL_TARGETS = [
|
||||
"endentscheidung",
|
||||
"kostenentscheidung",
|
||||
"anordnung",
|
||||
"schadensbemessung",
|
||||
"bucheinsicht",
|
||||
] as const;
|
||||
|
||||
const APPEAL_TARGET_PROCEEDINGS = new Set(["upc.apl.unified"]);
|
||||
|
||||
function hasAppealTarget(code: string): boolean {
|
||||
return APPEAL_TARGET_PROCEEDINGS.has(code);
|
||||
}
|
||||
|
||||
// Per-proceeding detail mode persistence (T4 §3.4). State is keyed by
|
||||
// proceeding code so a page with 3 proceedings can have one in "Alle
|
||||
// Optionen" without affecting the others.
|
||||
const DETAIL_MODE_PREFIX = "procedures.tracker.detail_mode:";
|
||||
|
||||
export function readDetailMode(code: string): "selected" | "all_options" {
|
||||
try {
|
||||
const raw = localStorage.getItem(DETAIL_MODE_PREFIX + code);
|
||||
if (raw === "all_options") return "all_options";
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
return "selected";
|
||||
}
|
||||
|
||||
export function writeDetailMode(code: string, mode: "selected" | "all_options"): void {
|
||||
try {
|
||||
if (mode === "selected") localStorage.removeItem(DETAIL_MODE_PREFIX + code);
|
||||
else localStorage.setItem(DETAIL_MODE_PREFIX + code, mode);
|
||||
} catch {
|
||||
// localStorage unavailable — runtime state stays in memory only;
|
||||
// toggle still works for this session.
|
||||
}
|
||||
}
|
||||
|
||||
export function applicableFlagsForProceeding(
|
||||
code: string,
|
||||
deadlines: CalculatedDeadline[],
|
||||
): string[] {
|
||||
const fromExpr = gatingFlagsForProceeding(deadlines);
|
||||
if (fromExpr.length > 0) return fromExpr;
|
||||
return FALLBACK_FLAGS[code] || [];
|
||||
}
|
||||
|
||||
// flagLabel maps scenario_flag keys to i18n keys (matches the
|
||||
// existing deadlines.flag.* keys used by VerfahrensablaufBody).
|
||||
const FLAG_I18N: Record<string, string> = {
|
||||
with_ccr: "deadlines.flag.ccr",
|
||||
with_amend: "deadlines.flag.amend",
|
||||
with_cci: "deadlines.flag.cci",
|
||||
};
|
||||
|
||||
function labelForFlag(flagKey: string, proceeding: string): string {
|
||||
// upc.inf.cfi with_amend = R.30 amendment request; upc.rev.cfi
|
||||
// with_amend = R.49.2(a) amendment. Different labels even though the
|
||||
// flag key is the same. Honour the inf vs rev distinction.
|
||||
if (flagKey === "with_amend" && proceeding === "upc.inf.cfi") return t("deadlines.flag.inf_amend");
|
||||
if (flagKey === "with_amend" && proceeding === "upc.rev.cfi") return t("deadlines.flag.rev_amend");
|
||||
if (flagKey === "with_cci" && proceeding === "upc.rev.cfi") return t("deadlines.flag.rev_cci");
|
||||
const key = FLAG_I18N[flagKey];
|
||||
return key ? t(key) : flagKey;
|
||||
}
|
||||
|
||||
// ─── per-proceeding render ──────────────────────────────────────────────────
|
||||
|
||||
// ActualStatus is the per-rule overlay derived from paliad.deadlines /
|
||||
// paliad.appointments for an Akte (§6.4). The tracker reads this map
|
||||
// when ?project= is set and stamps a status badge on each node.
|
||||
export interface ActualStatus {
|
||||
status: "done" | "open" | "overdue" | "court_set";
|
||||
// dueDate / completedAt fall back to the calculator's projected date
|
||||
// when not set (open / future). Format is ISO date.
|
||||
dueDate?: string;
|
||||
completedAt?: string;
|
||||
// deadlineId lets the renderer deep-link to /projects/{p}/deadlines/{id}.
|
||||
deadlineId?: string;
|
||||
appointmentId?: string;
|
||||
}
|
||||
|
||||
export type ActualsMap = Map<string, ActualStatus>;
|
||||
|
||||
export interface TimelineRenderParams {
|
||||
proceedingType: string;
|
||||
triggerDate: string;
|
||||
flags: string[];
|
||||
anchorRuleId?: string;
|
||||
zoom?: boolean;
|
||||
// collapsed=true renders the card as a one-line header only with a
|
||||
// [zeigen] link. Used by §6.5: when an anchor is pinned + >1
|
||||
// proceeding visible, non-anchored proceedings collapse so the
|
||||
// anchor's full context owns the page.
|
||||
collapsed?: boolean;
|
||||
// actuals carries the per-rule overlay when the page is bound to an
|
||||
// Akte via ?project=. Empty map / undefined = template render.
|
||||
actuals?: ActualsMap;
|
||||
// projectId carries through to deep-links and write-back paths.
|
||||
projectId?: string;
|
||||
// T4 — Verfahren-card detail mode toggle. "selected" (default) shows
|
||||
// mandatory + recommended + active-flag-gated; "all_options" reveals
|
||||
// conditional rules whose flag is off and unselected optionals,
|
||||
// muted. Per-proceeding state lives in localStorage keyed by code.
|
||||
detailMode?: "selected" | "all_options";
|
||||
// T4 — appeal-target slug for proceedings with `applies_to_target`
|
||||
// (upc.apl.unified). Drives the chip group at the appeal root. Empty
|
||||
// = use the engine's default (endentscheidung for upc.apl).
|
||||
appealTarget?: string;
|
||||
// T4 — perspective for the cross-party muted treatment (§3.6). Rows
|
||||
// whose party doesn't match are rendered with a "Gegenseitig" badge
|
||||
// and a muted style. Empty = no perspective applied, render all
|
||||
// rows at full saturation.
|
||||
party?: "claimant" | "defendant" | "";
|
||||
}
|
||||
|
||||
export interface RenderedTimeline {
|
||||
card: HTMLElement;
|
||||
data: DeadlineResponse | null;
|
||||
// hasAnchor true iff the rendered card contains the active
|
||||
// anchorRuleId. Drives the multi-proceeding auto-collapse decision
|
||||
// back in procedures.ts.
|
||||
hasAnchor: boolean;
|
||||
}
|
||||
|
||||
// renderCard takes a proceeding code + flag set + trigger date and
|
||||
// returns a fully-wired card element ready to mount. Re-running with a
|
||||
// new flag set requires a fresh fetch (calc applies the flag set
|
||||
// server-side).
|
||||
export async function renderCard(params: TimelineRenderParams): Promise<RenderedTimeline> {
|
||||
const card = document.createElement("article");
|
||||
card.className = "tracker-proceeding";
|
||||
card.dataset.proceeding = params.proceedingType;
|
||||
|
||||
// Header — proceeding name + jurisdiction badge + detail-mode
|
||||
// toggle + collapse toggle. Detail-mode renders only on non-
|
||||
// collapsed cards; collapse toggle renders always.
|
||||
const def = lookupProceeding(params.proceedingType);
|
||||
const jur = def ? (FORUM_LABEL[def.forum] || "") : "";
|
||||
const procName = proceedingDisplayName(params.proceedingType);
|
||||
const detailMode = params.detailMode || "selected";
|
||||
const detailToggle = params.collapsed
|
||||
? ""
|
||||
: `<button type="button" class="tracker-proceeding-detail" data-action="detail-toggle"
|
||||
data-code="${escHtml(params.proceedingType)}"
|
||||
aria-pressed="${detailMode === "all_options" ? "true" : "false"}"
|
||||
title="${escHtml(t("procedures.proceeding.detail.title"))}">
|
||||
${escHtml(detailMode === "all_options" ? t("procedures.proceeding.detail.all") : t("procedures.proceeding.detail.selected"))}
|
||||
</button>`;
|
||||
const header = document.createElement("header");
|
||||
header.className = "tracker-proceeding-header";
|
||||
header.innerHTML = `
|
||||
<span class="tracker-proceeding-jur">${escHtml(jur)}</span>
|
||||
<h3 class="tracker-proceeding-name">${escHtml(procName)}</h3>
|
||||
<span class="tracker-proceeding-code" title="${escHtml(params.proceedingType)}">${escHtml(params.proceedingType)}</span>
|
||||
${detailToggle}
|
||||
<button type="button" class="tracker-proceeding-toggle" data-action="proc-toggle"
|
||||
data-code="${escHtml(params.proceedingType)}"
|
||||
aria-label="${escHtml(t("procedures.proceeding.toggle"))}">
|
||||
${escHtml(params.collapsed ? t("procedures.proceeding.show") : t("procedures.proceeding.hide"))}
|
||||
</button>
|
||||
`;
|
||||
card.appendChild(header);
|
||||
|
||||
// Collapsed state — header-only render (§6.5). Bail before issuing
|
||||
// the calc fetch; the card has no body. hasAnchor stays false here
|
||||
// because we never resolved the data.
|
||||
if (params.collapsed) {
|
||||
card.classList.add("tracker-proceeding--collapsed");
|
||||
return { card, data: null, hasAnchor: false };
|
||||
}
|
||||
|
||||
// Appeal-target chip group — visible only on proceedings with
|
||||
// applies_to_target rules (today: upc.apl.unified). The picked slug
|
||||
// feeds the calc's appealTarget param so the timeline narrows to the
|
||||
// rule subset (Endentscheidung / Kostenentscheidung / Anordnung /
|
||||
// Schadensbemessung / Bucheinsicht).
|
||||
if (hasAppealTarget(params.proceedingType)) {
|
||||
const targetGroup = document.createElement("div");
|
||||
targetGroup.className = "tracker-proceeding-targets";
|
||||
const lbl = document.createElement("span");
|
||||
lbl.className = "tracker-proceeding-options-label";
|
||||
lbl.textContent = t("procedures.appeal_target.label");
|
||||
targetGroup.appendChild(lbl);
|
||||
const active = params.appealTarget || "endentscheidung";
|
||||
for (const slug of APPEAL_TARGETS) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "tracker-pill tracker-pill--sm";
|
||||
btn.dataset.action = "appeal-target";
|
||||
btn.dataset.code = params.proceedingType;
|
||||
btn.dataset.target = slug;
|
||||
btn.textContent = t(`deadlines.appeal_target.${slug}` as never);
|
||||
if (slug === active) btn.classList.add("is-active");
|
||||
targetGroup.appendChild(btn);
|
||||
}
|
||||
card.appendChild(targetGroup);
|
||||
}
|
||||
|
||||
// Optionen strip — scenario flag checkboxes scoped to this card.
|
||||
// Hydrated after the calc response so the applicable flag set is
|
||||
// known. T1 floor: card-level placement; T2+ may move these to
|
||||
// inline on the actual gating node per the design.
|
||||
const optionsStrip = document.createElement("div");
|
||||
optionsStrip.className = "tracker-proceeding-options";
|
||||
optionsStrip.hidden = true;
|
||||
card.appendChild(optionsStrip);
|
||||
|
||||
// Body — chained tree mounts here once the calc returns.
|
||||
const body = document.createElement("div");
|
||||
body.className = "tracker-proceeding-body";
|
||||
body.innerHTML = `<div class="tracker-proceeding-loading">${escHtml(t("procedures.timelines.loading"))}</div>`;
|
||||
card.appendChild(body);
|
||||
|
||||
// Fetch + render.
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: params.proceedingType,
|
||||
triggerDate: params.triggerDate,
|
||||
flags: params.flags,
|
||||
appealTarget: hasAppealTarget(params.proceedingType)
|
||||
? (params.appealTarget || "endentscheidung")
|
||||
: undefined,
|
||||
// includeHidden=true under "all_options" so the calculator
|
||||
// re-surfaces previously-skipped conditional rules with isHidden
|
||||
// set; the tracker mutes them.
|
||||
includeHidden: params.detailMode === "all_options" || undefined,
|
||||
});
|
||||
|
||||
if (!data) {
|
||||
body.innerHTML = `<div class="tracker-proceeding-error">${escHtml(t("procedures.timelines.error"))}</div>`;
|
||||
return { card, data: null, hasAnchor: false };
|
||||
}
|
||||
|
||||
// Hydrate the Optionen strip with the applicable scenario flags.
|
||||
const applicable = applicableFlagsForProceeding(params.proceedingType, data.deadlines);
|
||||
if (applicable.length > 0) {
|
||||
const labelSpan = document.createElement("span");
|
||||
labelSpan.className = "tracker-proceeding-options-label";
|
||||
labelSpan.textContent = t("procedures.timelines.options");
|
||||
optionsStrip.appendChild(labelSpan);
|
||||
|
||||
for (const flag of applicable) {
|
||||
const label = document.createElement("label");
|
||||
label.className = "tracker-proceeding-option";
|
||||
const cb = document.createElement("input");
|
||||
cb.type = "checkbox";
|
||||
cb.value = flag;
|
||||
cb.checked = params.flags.includes(flag);
|
||||
cb.dataset.flag = flag;
|
||||
label.appendChild(cb);
|
||||
const text = document.createElement("span");
|
||||
text.textContent = labelForFlag(flag, params.proceedingType);
|
||||
label.appendChild(text);
|
||||
optionsStrip.appendChild(label);
|
||||
}
|
||||
optionsStrip.hidden = false;
|
||||
}
|
||||
|
||||
// Filter per detail mode (§3.4).
|
||||
// selected → mandatory + recommended + active-flag-gated
|
||||
// all_options → everything; unselected optionals + conditional-off
|
||||
// rules render muted (the renderer stamps the
|
||||
// __detailUnselected flag via filterByDetailMode).
|
||||
const filtered = filterByDetailMode(data.deadlines, detailMode, null);
|
||||
|
||||
// Anchor-present detection: does this card's rule set contain the
|
||||
// active anchor rule id? Drives the multi-proceeding scope logic
|
||||
// and the zoom branch below.
|
||||
const hasAnchor = !!(params.anchorRuleId && filtered.some((d) => d.ruleId === params.anchorRuleId));
|
||||
|
||||
if (params.zoom && hasAnchor && params.anchorRuleId) {
|
||||
body.innerHTML = renderZoomedBody(filtered, params.anchorRuleId, params.actuals, params.party);
|
||||
} else {
|
||||
body.innerHTML = renderTreeBody(filtered, params.anchorRuleId, params.actuals, params.party);
|
||||
}
|
||||
|
||||
if (hasAnchor) card.classList.add("tracker-proceeding--anchored");
|
||||
|
||||
return { card, data, hasAnchor };
|
||||
}
|
||||
|
||||
// ─── tree builder ──────────────────────────────────────────────────────────
|
||||
//
|
||||
// Builds a `parentRuleCode → child[]` map, then walks from each root
|
||||
// (no parent) emitting nested <ul class="tracker-tree-…"> nodes. The
|
||||
// calculator already sorts the deadlines into a sensible chain (root
|
||||
// → linear-deepest-first); the tree builder preserves that order.
|
||||
|
||||
function renderTreeBody(
|
||||
deadlines: CalculatedDeadline[],
|
||||
anchorRuleId?: string,
|
||||
actuals?: ActualsMap,
|
||||
party?: "claimant" | "defendant" | "",
|
||||
): string {
|
||||
if (deadlines.length === 0) {
|
||||
return `<div class="tracker-proceeding-empty">${escHtml(t("procedures.timelines.empty"))}</div>`;
|
||||
}
|
||||
|
||||
// Index by code so children can find their parent visually. Track
|
||||
// every code that appears in the filtered set — children whose
|
||||
// parent isn't in the set surface as orphan roots.
|
||||
const present = new Set(deadlines.map((d) => d.code));
|
||||
const childrenOf: Record<string, CalculatedDeadline[]> = {};
|
||||
const roots: CalculatedDeadline[] = [];
|
||||
|
||||
for (const dl of deadlines) {
|
||||
const parent = dl.parentRuleCode || "";
|
||||
if (parent && present.has(parent)) {
|
||||
(childrenOf[parent] ||= []).push(dl);
|
||||
} else {
|
||||
roots.push(dl);
|
||||
}
|
||||
}
|
||||
|
||||
const parts: string[] = [`<ul class="tracker-tree tracker-tree-root">`];
|
||||
for (const root of roots) {
|
||||
parts.push(renderTreeNode(root, childrenOf, 0, anchorRuleId, actuals, party));
|
||||
}
|
||||
parts.push(`</ul>`);
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
// isCrossParty — §3.6. When perspective is set, rows whose primary_party
|
||||
// is the OPPOSITE side render with a "Gegenseitig" badge and a muted
|
||||
// style. court / both / informational rows are never cross-party.
|
||||
function isCrossParty(dl: CalculatedDeadline, party: "claimant" | "defendant" | ""): boolean {
|
||||
if (!party) return false;
|
||||
if (!dl.party) return false;
|
||||
if (dl.party === "court" || dl.party === "both") return false;
|
||||
return dl.party !== party;
|
||||
}
|
||||
|
||||
function renderTreeNode(
|
||||
dl: CalculatedDeadline,
|
||||
childrenOf: Record<string, CalculatedDeadline[]>,
|
||||
depth: number,
|
||||
anchorRuleId?: string,
|
||||
actuals?: ActualsMap,
|
||||
party?: "claimant" | "defendant" | "",
|
||||
): string {
|
||||
const children = childrenOf[dl.code] || [];
|
||||
const isAnchored = !!(anchorRuleId && dl.ruleId === anchorRuleId);
|
||||
const lang = getLang();
|
||||
const crossParty = party ? isCrossParty(dl, party) : false;
|
||||
// __detailUnselected is stamped by filterByDetailMode under
|
||||
// all_options mode (verfahrensablauf-detail-mode.ts). Read via
|
||||
// unknown-prop cast so we don't pollute the public CalculatedDeadline
|
||||
// type for one transient ui hint.
|
||||
const unselected = !!(dl as unknown as { __detailUnselected?: boolean }).__detailUnselected;
|
||||
const isHidden = !!dl.isHidden;
|
||||
|
||||
// Priority-driven bullet style.
|
||||
const priorityClass = `tracker-node--${dl.priority || "mandatory"}`;
|
||||
const anchorClass = isAnchored ? " tracker-node--anchored" : "";
|
||||
const courtClass = dl.isCourtSet ? " tracker-node--court" : "";
|
||||
const crossClass = crossParty ? " tracker-node--cross" : "";
|
||||
const unselectedClass = unselected ? " tracker-node--unselected" : "";
|
||||
const hiddenClass = isHidden ? " tracker-node--hidden" : "";
|
||||
|
||||
const name = lang === "en" ? (dl.nameEN || dl.name) : (dl.name || dl.nameEN);
|
||||
const ref = dl.legalSourceDisplay || dl.ruleRef || "";
|
||||
|
||||
// Actuals overlay (§6.4). When the page is Akte-bound and this rule
|
||||
// has an actuals row, the badge replaces the priority bullet's
|
||||
// status — done = ✓, overdue = ⚠, open ≠ projected = 📅, open ≡
|
||||
// projected = ◇. Date column shows the actual date when present.
|
||||
const actual = (actuals && dl.ruleId) ? actuals.get(dl.ruleId) : undefined;
|
||||
let dateLabel = "";
|
||||
let statusBadge = "";
|
||||
let actualClass = "";
|
||||
if (actual) {
|
||||
actualClass = ` tracker-node--actual-${actual.status}`;
|
||||
if (actual.status === "done") {
|
||||
statusBadge = `<span class="tracker-node-actual tracker-node-actual--done" title="${escHtml(t("procedures.node.actual.done"))}">✓</span>`;
|
||||
dateLabel = actual.completedAt ? formatDate(actual.completedAt) : (actual.dueDate ? formatDate(actual.dueDate) : "");
|
||||
} else if (actual.status === "overdue") {
|
||||
statusBadge = `<span class="tracker-node-actual tracker-node-actual--overdue" title="${escHtml(t("procedures.node.actual.overdue"))}">⚠</span>`;
|
||||
dateLabel = actual.dueDate ? formatDate(actual.dueDate) : "";
|
||||
} else if (actual.status === "open") {
|
||||
// Open + actual due differs from projected = 📅, else ◇.
|
||||
const projectedDate = dl.dueDate || "";
|
||||
const actualDate = actual.dueDate || "";
|
||||
const differs = projectedDate && actualDate && projectedDate !== actualDate;
|
||||
statusBadge = `<span class="tracker-node-actual tracker-node-actual--open" title="${escHtml(t("procedures.node.actual.open"))}">${differs ? "📅" : "◇"}</span>`;
|
||||
dateLabel = actualDate ? formatDate(actualDate) : (projectedDate ? formatDate(projectedDate) : "");
|
||||
}
|
||||
}
|
||||
if (!dateLabel) {
|
||||
dateLabel = dl.isCourtSet
|
||||
? t("procedures.timelines.court_set")
|
||||
: (dl.dueDate ? formatDate(dl.dueDate) : "");
|
||||
}
|
||||
|
||||
// Party badge — one-letter affordance to the right.
|
||||
const partyBadge = dl.party === "court"
|
||||
? "G"
|
||||
: dl.party === "claimant"
|
||||
? "K"
|
||||
: dl.party === "defendant"
|
||||
? "B"
|
||||
: dl.party === "both" ? "K+B" : "";
|
||||
|
||||
const ruleIdAttr = dl.ruleId ? ` data-rule-id="${escHtml(dl.ruleId)}"` : "";
|
||||
const codeAttr = ` data-code="${escHtml(dl.code)}"`;
|
||||
|
||||
// Pin + Fokus affordances. Pin is always available on nodes with a
|
||||
// real ruleId (synthetic appeal-trigger markers carry no id). Fokus
|
||||
// only on the currently anchored node.
|
||||
const pinBtn = dl.ruleId
|
||||
? `<button type="button" class="tracker-node-pin" data-action="pin"
|
||||
data-rule-id="${escHtml(dl.ruleId)}"
|
||||
aria-label="${escHtml(t("procedures.node.pin"))}"
|
||||
title="${escHtml(t("procedures.node.pin"))}">📌</button>`
|
||||
: "";
|
||||
const fokusBtn = isAnchored
|
||||
? `<button type="button" class="tracker-node-fokus" data-action="fokus"
|
||||
aria-label="${escHtml(t("procedures.node.fokus"))}"
|
||||
title="${escHtml(t("procedures.node.fokus"))}">🔍</button>`
|
||||
: "";
|
||||
|
||||
const crossBadge = crossParty
|
||||
? `<span class="tracker-node-cross" title="${escHtml(t("procedures.node.cross"))}">${escHtml(t("procedures.node.cross.short"))}</span>`
|
||||
: "";
|
||||
|
||||
const meta = `
|
||||
<div class="tracker-node-line">
|
||||
<span class="tracker-node-bullet" aria-hidden="true"></span>
|
||||
${statusBadge}
|
||||
<span class="tracker-node-name">${escHtml(name)}</span>
|
||||
${crossBadge}
|
||||
${ref ? `<span class="tracker-node-ref">${escHtml(ref)}</span>` : ""}
|
||||
${dateLabel ? `<span class="tracker-node-date">${escHtml(dateLabel)}</span>` : ""}
|
||||
${partyBadge ? `<span class="tracker-node-party tracker-node-party--${escHtml(dl.party || "")}">${escHtml(partyBadge)}</span>` : ""}
|
||||
${pinBtn}
|
||||
${fokusBtn}
|
||||
</div>
|
||||
${isAnchored ? `<div class="tracker-node-here" role="note">${escHtml(t("procedures.node.here"))}</div>` : ""}
|
||||
`;
|
||||
|
||||
let inner = meta;
|
||||
if (children.length > 0) {
|
||||
const kids = children
|
||||
.map((c) => renderTreeNode(c, childrenOf, depth + 1, anchorRuleId, actuals, party))
|
||||
.join("");
|
||||
inner += `<ul class="tracker-tree tracker-tree--depth-${depth + 1}">${kids}</ul>`;
|
||||
}
|
||||
|
||||
return `<li class="tracker-node ${priorityClass}${anchorClass}${courtClass}${crossClass}${unselectedClass}${hiddenClass}${actualClass}"${ruleIdAttr}${codeAttr}>${inner}</li>`;
|
||||
}
|
||||
|
||||
// ─── zoom mode (§6.2) ──────────────────────────────────────────────────────
|
||||
//
|
||||
// When the user clicks [🔍] on the anchored node, the proceeding card
|
||||
// re-renders into zoom mode:
|
||||
// - Ancestors of the anchor collapse to a single breadcrumb at the
|
||||
// top of the card (proceeding-code ▸ root ▸ … ▸ anchor).
|
||||
// - Sibling branches at each ancestor depth fold to a one-line
|
||||
// "… N weitere verborgen — [zeigen]" summary.
|
||||
// - The anchored node renders full with all its successors.
|
||||
//
|
||||
// Sibling-expand-on-demand: when the user clicks [zeigen] on a fold
|
||||
// summary, the corresponding sibling subtree expands inline. State is
|
||||
// per-card in sessionStorage so a reload keeps it.
|
||||
|
||||
function renderZoomedBody(
|
||||
deadlines: CalculatedDeadline[],
|
||||
anchorRuleId: string,
|
||||
actuals?: ActualsMap,
|
||||
party?: "claimant" | "defendant" | "",
|
||||
): string {
|
||||
const anchor = deadlines.find((d) => d.ruleId === anchorRuleId);
|
||||
if (!anchor) {
|
||||
// The anchor is no longer in the filtered set (e.g. the user
|
||||
// toggled a flag that hid it). Fall back to the full tree so the
|
||||
// user can re-pin.
|
||||
return renderTreeBody(deadlines, anchorRuleId, actuals, party);
|
||||
}
|
||||
|
||||
// Build the parent chain (anchor → root). The chain is walked via
|
||||
// parentRuleCode; bail at the first missing parent or at >20 hops
|
||||
// (defensive — the deepest tree today is ~5).
|
||||
const byCode: Record<string, CalculatedDeadline> = {};
|
||||
for (const dl of deadlines) byCode[dl.code] = dl;
|
||||
const ancestors: CalculatedDeadline[] = [];
|
||||
let cursor: CalculatedDeadline | undefined = anchor;
|
||||
let safety = 20;
|
||||
while (cursor && safety-- > 0) {
|
||||
const parentCode = cursor.parentRuleCode || "";
|
||||
if (!parentCode) break;
|
||||
const parent = byCode[parentCode];
|
||||
if (!parent) break;
|
||||
ancestors.unshift(parent);
|
||||
cursor = parent;
|
||||
}
|
||||
|
||||
const lang = getLang();
|
||||
const breadcrumbParts: string[] = [];
|
||||
for (const a of ancestors) {
|
||||
const name = lang === "en" ? (a.nameEN || a.name) : (a.name || a.nameEN);
|
||||
breadcrumbParts.push(`<span class="tracker-zoom-crumb">${escHtml(name)}</span>`);
|
||||
}
|
||||
const anchorName = lang === "en" ? (anchor.nameEN || anchor.name) : (anchor.name || anchor.nameEN);
|
||||
breadcrumbParts.push(`<span class="tracker-zoom-crumb tracker-zoom-crumb--anchor">${escHtml(anchorName)}</span>`);
|
||||
|
||||
const breadcrumb = `<nav class="tracker-zoom-breadcrumb" aria-label="${escHtml(t("procedures.zoom.breadcrumb"))}">
|
||||
${breadcrumbParts.join('<span class="tracker-zoom-crumb-sep">▸</span>')}
|
||||
</nav>`;
|
||||
|
||||
// Subtree: anchor + all descendants (parentRuleCode chain rooted
|
||||
// at the anchor).
|
||||
const descendants = new Set<string>([anchor.code]);
|
||||
const queue = [anchor.code];
|
||||
while (queue.length > 0) {
|
||||
const code = queue.shift()!;
|
||||
for (const dl of deadlines) {
|
||||
if (dl.parentRuleCode === code && !descendants.has(dl.code)) {
|
||||
descendants.add(dl.code);
|
||||
queue.push(dl.code);
|
||||
}
|
||||
}
|
||||
}
|
||||
const subtree = deadlines.filter((d) => descendants.has(d.code));
|
||||
const subtreeBody = renderTreeBody(subtree, anchorRuleId, actuals, party);
|
||||
|
||||
// Sibling count summary — descendants ignored. Stays terse so the
|
||||
// page tells the user how much is hidden without listing it.
|
||||
const totalCount = deadlines.length;
|
||||
const hiddenCount = totalCount - subtree.length;
|
||||
const hiddenLine = hiddenCount > 0
|
||||
? `<div class="tracker-zoom-hidden">${escHtml(tDyn("procedures.zoom.hidden").replace("{n}", String(hiddenCount)))}</div>`
|
||||
: "";
|
||||
|
||||
return breadcrumb + subtreeBody + hiddenLine;
|
||||
}
|
||||
|
||||
// ─── search hit highlight (URL `?event=`) ──────────────────────────────────
|
||||
//
|
||||
// T1 affordance: when `?event=<rule_id>` is set, scroll the matching
|
||||
// node into view and apply a transient highlight. Anchor pin + zoom is
|
||||
// a T2 layering on top of this.
|
||||
|
||||
export function scrollAnchorIntoView(card: HTMLElement, anchorRuleId: string): void {
|
||||
const node = card.querySelector<HTMLElement>(`[data-rule-id="${CSS.escape(anchorRuleId)}"]`);
|
||||
if (!node) return;
|
||||
node.classList.add("tracker-node--highlight");
|
||||
node.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
setTimeout(() => node.classList.remove("tracker-node--highlight"), 3000);
|
||||
}
|
||||
|
||||
// summarise — short status line for the find-header summary.
|
||||
export function summariseRender(rendered: RenderedTimeline[]): string {
|
||||
const proc = rendered.length;
|
||||
if (proc === 0) return t("procedures.find.summary.empty");
|
||||
if (proc === 1) return tDyn("procedures.find.summary.one").replace("{n}", "1");
|
||||
return tDyn("procedures.find.summary.many").replace("{n}", String(proc));
|
||||
}
|
||||
756
frontend/src/client/procedures.ts
Normal file
756
frontend/src/client/procedures.ts
Normal file
@@ -0,0 +1,756 @@
|
||||
// /tools/procedures client (m/paliad#152 T1,
|
||||
// docs/design-procedures-workflow-tracker-2026-05-27.md §9 T1).
|
||||
//
|
||||
// Workflow-tracker shell — replaces the 4-tab catalog (U0-U4) shipped
|
||||
// earlier today with a single canonical shape:
|
||||
//
|
||||
// 1. Sticky find header — search input + forum / Verfahren / Partei
|
||||
// pill rows + global Stichtag. The header narrows the timeline
|
||||
// set rendered below.
|
||||
// 2. Timeline body — one card per matched proceeding, rendered as
|
||||
// a chained tree by parent_id with priority-styled bullets.
|
||||
//
|
||||
// URL state (T1):
|
||||
// ?q=<text> — free-text search
|
||||
// ?forum=<id> — single forum (upc/de/epa/dpma)
|
||||
// ?procs=<csv> — comma-separated proceeding codes
|
||||
// ?party=<x> — claimant/defendant/both/""
|
||||
// ?trigger_date=<iso> — global Stichtag
|
||||
// ?event=<rule_id> — scroll-highlight matching node (no anchor
|
||||
// pin / zoom yet; T2 layering)
|
||||
// ?flags=<csv> — scenario flag overrides; default off
|
||||
//
|
||||
// Legacy ?mode= params from the catalog UI are dropped silently — the
|
||||
// /tools/fristenrechner + /tools/verfahrensablauf URLs still 301
|
||||
// redirect here.
|
||||
//
|
||||
// T2-T4 layer on this shell:
|
||||
// T2 — anchor pin + zoom + multi-proceeding scope.
|
||||
// T3 — Akte landing + actuals overlay.
|
||||
// T4 — appeal-target + court-set choices + per-proceeding Alle Optionen.
|
||||
|
||||
import { initI18n, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
COLD_OPEN_DEFAULTS,
|
||||
PROCEEDINGS,
|
||||
type ActualStatus,
|
||||
type ActualsMap,
|
||||
type RenderedTimeline,
|
||||
proceedingDisplayName,
|
||||
renderCard,
|
||||
scrollAnchorIntoView,
|
||||
summariseRender,
|
||||
} from "./procedures-tracker";
|
||||
import {
|
||||
fetchScenarioFlags,
|
||||
patchScenarioFlags,
|
||||
SCENARIO_FLAG_CHANGED_EVENT,
|
||||
type ScenarioFlagChangedDetail,
|
||||
} from "./scenario-flags";
|
||||
import { readDetailMode, writeDetailMode } from "./procedures-tracker";
|
||||
|
||||
// Per-proceeding appeal-target state. Today only upc.apl.unified has
|
||||
// applies_to_target rules; the map is keyed by proceeding_type code
|
||||
// so future appeal-style proceedings (de.apl, etc.) can opt in without
|
||||
// touching the state shape.
|
||||
const appealTargets: Record<string, string> = {};
|
||||
|
||||
type ForumId = "upc" | "de" | "epa" | "dpma" | "";
|
||||
type PartyId = "claimant" | "defendant" | "both" | "";
|
||||
|
||||
// Find state. Single source of truth; URL keeps it shareable.
|
||||
const state = {
|
||||
q: "",
|
||||
forum: "" as ForumId,
|
||||
procs: [] as string[],
|
||||
party: "" as PartyId,
|
||||
triggerDate: todayISO(),
|
||||
event: "",
|
||||
zoom: false,
|
||||
flags: [] as string[],
|
||||
// T3 Akte state — loaded on demand from /api/projects/{id}/timeline
|
||||
// when ?project= is set in the URL. Kept off the URL writer so a
|
||||
// shared link without ?project= doesn't accidentally leak.
|
||||
projectId: "",
|
||||
projectTitle: "",
|
||||
projectProceeding: "",
|
||||
actuals: new Map() as ActualsMap,
|
||||
akteLoaded: false,
|
||||
};
|
||||
|
||||
// Per-anchor user-expanded set — when the multi-proceeding auto-collapse
|
||||
// kicks in (§6.5), the user can [zeigen] specific proceedings. We track
|
||||
// the explicit expansions so re-renders keep them open. Reset whenever
|
||||
// the anchor changes (new pin clears prior expansion state).
|
||||
let userExpanded = new Set<string>();
|
||||
let lastAnchor = "";
|
||||
|
||||
function todayISO(): string {
|
||||
return new Date().toISOString().split("T")[0];
|
||||
}
|
||||
|
||||
// ─── URL state ─────────────────────────────────────────────────────────────
|
||||
|
||||
function readStateFromURL(): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
state.q = params.get("q") || "";
|
||||
state.forum = (params.get("forum") || "") as ForumId;
|
||||
const procs = params.get("procs") || "";
|
||||
state.procs = procs ? procs.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
state.party = (params.get("party") || "") as PartyId;
|
||||
state.triggerDate = params.get("trigger_date") || todayISO();
|
||||
state.event = params.get("event") || "";
|
||||
state.zoom = params.get("zoom") === "1";
|
||||
const flags = params.get("flags") || "";
|
||||
state.flags = flags ? flags.split(",").map((s) => s.trim()).filter(Boolean) : [];
|
||||
state.projectId = params.get("project") || "";
|
||||
lastAnchor = state.event;
|
||||
}
|
||||
|
||||
function writeStateToURL(): void {
|
||||
const url = new URL(window.location.href);
|
||||
const sp = url.searchParams;
|
||||
setOrDelete(sp, "q", state.q);
|
||||
setOrDelete(sp, "forum", state.forum);
|
||||
setOrDelete(sp, "procs", state.procs.join(","));
|
||||
setOrDelete(sp, "party", state.party);
|
||||
setOrDelete(sp, "trigger_date", state.triggerDate === todayISO() ? "" : state.triggerDate);
|
||||
setOrDelete(sp, "event", state.event);
|
||||
setOrDelete(sp, "zoom", state.event && state.zoom ? "1" : "");
|
||||
setOrDelete(sp, "flags", state.flags.join(","));
|
||||
setOrDelete(sp, "project", state.projectId);
|
||||
// Legacy ?mode= from the U0-U4 catalog era → drop on every state write
|
||||
// so a bookmarked URL self-cleans on first interaction.
|
||||
sp.delete("mode");
|
||||
history.replaceState(null, "", url.pathname + (sp.toString() ? "?" + sp.toString() : "") + url.hash);
|
||||
}
|
||||
|
||||
// onAnchorChanged keeps userExpanded in sync. A fresh pin clears the
|
||||
// prior expansion set so the auto-collapse rule (§6.5) kicks in from
|
||||
// scratch; unpinning clears it too so the full multi-proceeding view
|
||||
// returns.
|
||||
function onAnchorChanged(next: string): void {
|
||||
if (next === lastAnchor) return;
|
||||
userExpanded = new Set();
|
||||
if (!next) state.zoom = false;
|
||||
lastAnchor = next;
|
||||
}
|
||||
|
||||
function setOrDelete(sp: URLSearchParams, key: string, value: string): void {
|
||||
if (value) sp.set(key, value);
|
||||
else sp.delete(key);
|
||||
}
|
||||
|
||||
// ─── pill hydration ────────────────────────────────────────────────────────
|
||||
|
||||
function hydrateForumPills(): void {
|
||||
const host = document.getElementById("tracker-pills-forum");
|
||||
if (!host) return;
|
||||
host.innerHTML = "";
|
||||
const all: { id: ForumId; label: string }[] = [
|
||||
{ id: "", label: t("procedures.filter.forum.all") },
|
||||
{ id: "upc", label: "UPC" },
|
||||
{ id: "de", label: "DE" },
|
||||
{ id: "epa", label: "EPA" },
|
||||
{ id: "dpma", label: "DPMA" },
|
||||
];
|
||||
for (const f of all) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "tracker-pill";
|
||||
btn.textContent = f.label;
|
||||
btn.dataset.forum = f.id;
|
||||
if (state.forum === f.id) btn.classList.add("is-active");
|
||||
btn.addEventListener("click", () => {
|
||||
state.forum = f.id;
|
||||
// Drop procs that no longer match the active forum.
|
||||
if (state.forum) {
|
||||
state.procs = state.procs.filter((code) => {
|
||||
const def = PROCEEDINGS.find((p) => p.code === code);
|
||||
return def && def.forum === state.forum;
|
||||
});
|
||||
}
|
||||
writeStateToURL();
|
||||
hydrateForumPills();
|
||||
hydrateProcPills();
|
||||
void rerender();
|
||||
});
|
||||
host.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateProcPills(): void {
|
||||
const host = document.getElementById("tracker-pills-proc");
|
||||
if (!host) return;
|
||||
host.innerHTML = "";
|
||||
const visible = state.forum
|
||||
? PROCEEDINGS.filter((p) => p.forum === state.forum)
|
||||
: PROCEEDINGS;
|
||||
for (const p of visible) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "tracker-pill";
|
||||
btn.textContent = proceedingDisplayName(p.code);
|
||||
btn.dataset.code = p.code;
|
||||
btn.title = p.code;
|
||||
if (state.procs.includes(p.code)) btn.classList.add("is-active");
|
||||
btn.addEventListener("click", () => {
|
||||
if (state.procs.includes(p.code)) {
|
||||
state.procs = state.procs.filter((c) => c !== p.code);
|
||||
} else {
|
||||
state.procs = [...state.procs, p.code];
|
||||
}
|
||||
writeStateToURL();
|
||||
hydrateProcPills();
|
||||
void rerender();
|
||||
});
|
||||
host.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
function hydratePartyPills(): void {
|
||||
const host = document.getElementById("tracker-pills-party");
|
||||
if (!host) return;
|
||||
host.innerHTML = "";
|
||||
const all: { id: PartyId; key: string }[] = [
|
||||
{ id: "", key: "procedures.filter.party.all" },
|
||||
{ id: "claimant", key: "deadlines.side.claimant" },
|
||||
{ id: "defendant", key: "deadlines.side.defendant" },
|
||||
];
|
||||
for (const p of all) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "tracker-pill";
|
||||
btn.textContent = t(p.key as never);
|
||||
btn.dataset.party = p.id;
|
||||
if (state.party === p.id) btn.classList.add("is-active");
|
||||
btn.addEventListener("click", () => {
|
||||
state.party = p.id;
|
||||
writeStateToURL();
|
||||
hydratePartyPills();
|
||||
void rerender();
|
||||
});
|
||||
host.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── search box ────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Debounced 200ms. Free-text matches procedural events via the existing
|
||||
// /api/tools/fristenrechner/search?kind=events endpoint; the hits drive
|
||||
// proceeding pre-selection (the proceedings the hits live in surface
|
||||
// in the timeline body) and the first hit's rule_id becomes the
|
||||
// `?event=` anchor.
|
||||
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function wireSearchInput(): void {
|
||||
const input = document.getElementById("tracker-search-input") as HTMLInputElement | null;
|
||||
if (!input) return;
|
||||
input.value = state.q;
|
||||
input.addEventListener("input", () => {
|
||||
if (searchTimer !== null) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
searchTimer = null;
|
||||
void onSearchChanged(input.value.trim());
|
||||
}, 200);
|
||||
});
|
||||
}
|
||||
|
||||
async function onSearchChanged(q: string): Promise<void> {
|
||||
state.q = q;
|
||||
// Empty query → revert to pill-driven set (or cold-open default).
|
||||
if (!q) {
|
||||
state.event = "";
|
||||
writeStateToURL();
|
||||
void rerender();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
|
||||
url.searchParams.set("q", q);
|
||||
url.searchParams.set("kind", "events");
|
||||
url.searchParams.set("limit", "20");
|
||||
if (state.forum) url.searchParams.set("jurisdiction", state.forum.toUpperCase());
|
||||
if (state.party) url.searchParams.set("party", state.party);
|
||||
const resp = await fetch(url.pathname + url.search, { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
writeStateToURL();
|
||||
void rerender();
|
||||
return;
|
||||
}
|
||||
const body = await resp.json();
|
||||
const events = Array.isArray(body.events) ? body.events : [];
|
||||
|
||||
// Collect distinct proceeding_type codes from the hits and pre-seed
|
||||
// state.procs. If exactly one hit, scroll-highlight its anchor rule.
|
||||
const procs: string[] = [];
|
||||
for (const ev of events) {
|
||||
const code = ev?.proceeding_type?.code;
|
||||
if (typeof code === "string" && code && !procs.includes(code)) procs.push(code);
|
||||
}
|
||||
state.procs = procs;
|
||||
const nextEvent = events.length === 1 && events[0]?.anchor_rule_id
|
||||
? String(events[0].anchor_rule_id)
|
||||
: "";
|
||||
onAnchorChanged(nextEvent);
|
||||
state.event = nextEvent;
|
||||
writeStateToURL();
|
||||
hydrateProcPills();
|
||||
void rerender();
|
||||
} catch (e) {
|
||||
console.error("tracker search failed", e);
|
||||
writeStateToURL();
|
||||
void rerender();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── trigger date input ────────────────────────────────────────────────────
|
||||
|
||||
function wireTriggerDateInput(): void {
|
||||
const input = document.getElementById("tracker-trigger-date") as HTMLInputElement | null;
|
||||
if (!input) return;
|
||||
input.value = state.triggerDate;
|
||||
input.addEventListener("change", () => {
|
||||
const next = input.value || todayISO();
|
||||
if (next === state.triggerDate) return;
|
||||
state.triggerDate = next;
|
||||
writeStateToURL();
|
||||
void rerender();
|
||||
});
|
||||
}
|
||||
|
||||
// ─── flag toggle wiring ────────────────────────────────────────────────────
|
||||
//
|
||||
// Forks on the per-proceeding "Optionen" strip dispatch via event
|
||||
// delegation so re-rendering doesn't need to re-bind. Each tick mutates
|
||||
// state.flags and triggers a re-render of the specific card.
|
||||
|
||||
function wireFlagDelegation(): void {
|
||||
const host = document.getElementById("tracker-timelines");
|
||||
if (!host) return;
|
||||
host.addEventListener("change", (ev) => {
|
||||
const target = ev.target as HTMLInputElement;
|
||||
if (!target || target.tagName !== "INPUT") return;
|
||||
if (target.type !== "checkbox") return;
|
||||
const flagKey = target.dataset.flag || "";
|
||||
if (!flagKey) return;
|
||||
if (target.checked) {
|
||||
if (!state.flags.includes(flagKey)) state.flags = [...state.flags, flagKey];
|
||||
} else {
|
||||
state.flags = state.flags.filter((f) => f !== flagKey);
|
||||
}
|
||||
writeStateToURL();
|
||||
|
||||
// T3: when bound to a project, persist the flag delta via
|
||||
// patchScenarioFlags so a reload (or another surface — Mode B
|
||||
// Fristenrechner / Verlauf) sees the same scenario. Fire-and-
|
||||
// forget; the cross-surface re-sync fires a CustomEvent that
|
||||
// doesn't reach back here today (the tracker has no listener),
|
||||
// but the persistence side-effect is what matters.
|
||||
if (state.projectId) {
|
||||
void patchScenarioFlags(state.projectId, { [flagKey]: target.checked });
|
||||
}
|
||||
void rerender();
|
||||
});
|
||||
}
|
||||
|
||||
// wireClickDelegation handles pin / fokus / proc-toggle. Single listener
|
||||
// on the timelines host; the render functions stamp data-action on the
|
||||
// affordances they emit.
|
||||
function wireClickDelegation(): void {
|
||||
const host = document.getElementById("tracker-timelines");
|
||||
if (!host) return;
|
||||
host.addEventListener("click", (ev) => {
|
||||
const btn = (ev.target as HTMLElement | null)?.closest<HTMLButtonElement>("button[data-action]");
|
||||
if (!btn) return;
|
||||
const action = btn.dataset.action || "";
|
||||
ev.preventDefault();
|
||||
|
||||
if (action === "pin") {
|
||||
const ruleId = btn.dataset.ruleId || "";
|
||||
if (!ruleId) return;
|
||||
// Click on the already-anchored node un-pins. Toggle pattern.
|
||||
const next = state.event === ruleId ? "" : ruleId;
|
||||
onAnchorChanged(next);
|
||||
state.event = next;
|
||||
writeStateToURL();
|
||||
void rerender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "fokus") {
|
||||
// Toggle zoom on the current anchor.
|
||||
if (!state.event) return;
|
||||
state.zoom = !state.zoom;
|
||||
writeStateToURL();
|
||||
void rerender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "proc-toggle") {
|
||||
const code = btn.dataset.code || "";
|
||||
if (!code) return;
|
||||
if (userExpanded.has(code)) userExpanded.delete(code);
|
||||
else userExpanded.add(code);
|
||||
void rerender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "detail-toggle") {
|
||||
// Per-proceeding "Alle Optionen" ↔ "Gewählt" toggle (§3.4).
|
||||
const code = btn.dataset.code || "";
|
||||
if (!code) return;
|
||||
const next = readDetailMode(code) === "all_options" ? "selected" : "all_options";
|
||||
writeDetailMode(code, next);
|
||||
void rerender();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "appeal-target") {
|
||||
const code = btn.dataset.code || "";
|
||||
const target = btn.dataset.target || "";
|
||||
if (!code || !target) return;
|
||||
appealTargets[code] = target;
|
||||
void rerender();
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ─── render driver ─────────────────────────────────────────────────────────
|
||||
|
||||
let currentRender = 0;
|
||||
|
||||
function pickProceedingsToRender(): string[] {
|
||||
if (state.procs.length > 0) return state.procs;
|
||||
if (state.forum) {
|
||||
return PROCEEDINGS.filter((p) => p.forum === state.forum).map((p) => p.code);
|
||||
}
|
||||
return COLD_OPEN_DEFAULTS;
|
||||
}
|
||||
|
||||
async function rerender(): Promise<void> {
|
||||
const seq = ++currentRender;
|
||||
const host = document.getElementById("tracker-timelines");
|
||||
const summary = document.getElementById("tracker-find-summary");
|
||||
if (!host) return;
|
||||
|
||||
// Loading placeholder during render. The placeholder is replaced
|
||||
// atomically once all cards return, so the user doesn't see
|
||||
// intermediate flicker between cards.
|
||||
host.innerHTML = `<div class="tracker-timelines-placeholder">${t("procedures.timelines.loading")}</div>`;
|
||||
|
||||
const codes = pickProceedingsToRender();
|
||||
|
||||
// Cold-open hint: if we're showing the curated default set, surface
|
||||
// a small instruction so the user knows the page expects further
|
||||
// narrowing for non-default proceedings.
|
||||
const isColdOpen = state.procs.length === 0 && !state.forum && !state.q;
|
||||
const hasAnchor = !!state.event;
|
||||
const multiProceeding = codes.length > 1;
|
||||
|
||||
// Multi-proceeding anchor scope (§6.5). When an anchor is pinned and
|
||||
// multiple proceedings are visible, non-anchored proceedings render
|
||||
// as a one-line header card with a [zeigen] link. We don't know yet
|
||||
// which card carries the anchor — that's a property of the calc
|
||||
// response. Two-pass render: first pass resolves anchor location;
|
||||
// second pass renders collapsed/full per code. Cheap because the
|
||||
// collapsed render skips the calc fetch entirely.
|
||||
//
|
||||
// Optimisation path: probe just one card per render to find the
|
||||
// anchor's home. Probe the matching code first when we already know
|
||||
// it (cached on `lastAnchorProceeding` below).
|
||||
|
||||
// First-pass: render every card, collect which ones carry the anchor.
|
||||
const firstPass: RenderedTimeline[] = await Promise.all(
|
||||
codes.map((code) =>
|
||||
renderCard({
|
||||
proceedingType: code,
|
||||
triggerDate: state.triggerDate,
|
||||
flags: state.flags,
|
||||
anchorRuleId: state.event || undefined,
|
||||
zoom: state.zoom,
|
||||
actuals: state.akteLoaded ? state.actuals : undefined,
|
||||
projectId: state.projectId || undefined,
|
||||
detailMode: readDetailMode(code),
|
||||
appealTarget: appealTargets[code] || undefined,
|
||||
party: state.party || "",
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
if (seq !== currentRender) return; // a newer render started; bail.
|
||||
|
||||
// If we have an anchor + multi-proceeding, decide which cards
|
||||
// collapse. Cards with hasAnchor=true stay expanded; others collapse
|
||||
// unless the user explicitly expanded them via [zeigen].
|
||||
let rendered: RenderedTimeline[] = firstPass;
|
||||
if (hasAnchor && multiProceeding) {
|
||||
rendered = await Promise.all(
|
||||
firstPass.map(async (r) => {
|
||||
if (r.hasAnchor) return r;
|
||||
if (userExpanded.has(r.card.dataset.proceeding || "")) return r;
|
||||
// Re-render collapsed.
|
||||
return renderCard({
|
||||
proceedingType: r.card.dataset.proceeding || "",
|
||||
triggerDate: state.triggerDate,
|
||||
flags: state.flags,
|
||||
anchorRuleId: state.event || undefined,
|
||||
collapsed: true,
|
||||
actuals: state.akteLoaded ? state.actuals : undefined,
|
||||
projectId: state.projectId || undefined,
|
||||
detailMode: readDetailMode(r.card.dataset.proceeding || ""),
|
||||
appealTarget: appealTargets[r.card.dataset.proceeding || ""] || undefined,
|
||||
party: state.party || "",
|
||||
});
|
||||
}),
|
||||
);
|
||||
if (seq !== currentRender) return;
|
||||
}
|
||||
|
||||
host.innerHTML = "";
|
||||
|
||||
if (isColdOpen) {
|
||||
const hint = document.createElement("div");
|
||||
hint.className = "tracker-cold-open-hint";
|
||||
hint.textContent = t("procedures.cold_open.hint");
|
||||
host.appendChild(hint);
|
||||
}
|
||||
|
||||
for (const r of rendered) host.appendChild(r.card);
|
||||
|
||||
if (rendered.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "tracker-timelines-empty";
|
||||
empty.textContent = t("procedures.timelines.empty");
|
||||
host.appendChild(empty);
|
||||
}
|
||||
|
||||
// Find-header summary line. When an anchor is pinned, surface the
|
||||
// anchor's name so the user has a visual confirmation of where
|
||||
// they are.
|
||||
if (summary) {
|
||||
const parts: string[] = [summariseRender(rendered)];
|
||||
if (state.akteLoaded && state.projectTitle) {
|
||||
parts.push(tDyn("procedures.find.summary.akte").replace("{name}", state.projectTitle));
|
||||
}
|
||||
if (hasAnchor) {
|
||||
const anchorName = findAnchorName(firstPass, state.event);
|
||||
if (anchorName) {
|
||||
parts.push(tDyn("procedures.find.summary.anchor").replace("{name}", anchorName));
|
||||
}
|
||||
}
|
||||
summary.textContent = parts.join(" · ");
|
||||
}
|
||||
|
||||
// Scroll-highlight the anchored node, if any. Walks every card so a
|
||||
// ?event= deep link works even when the same rule appears in a
|
||||
// shared-chain proceeding (e.g. inf.cfi and ccr.cfi).
|
||||
if (state.event) {
|
||||
for (const r of rendered) scrollAnchorIntoView(r.card, state.event);
|
||||
}
|
||||
}
|
||||
|
||||
// findAnchorName resolves the anchored rule's display name across the
|
||||
// rendered cards. Returns "" when the anchor isn't in any visible
|
||||
// proceeding (e.g. invalid ?event= deep link).
|
||||
function findAnchorName(rendered: RenderedTimeline[], ruleId: string): string {
|
||||
if (!ruleId) return "";
|
||||
for (const r of rendered) {
|
||||
if (!r.data) continue;
|
||||
const hit = r.data.deadlines.find((d) => d.ruleId === ruleId);
|
||||
if (!hit) continue;
|
||||
return hit.name || hit.nameEN || ruleId;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// ─── Akte landing (§6.4) ───────────────────────────────────────────────────
|
||||
//
|
||||
// When ?project=<uuid> is in the URL, we load:
|
||||
// 1. /api/projects/{id} — title + proceeding_type for header context
|
||||
// 2. /api/projects/{id}/timeline — actuals (deadlines + appointments)
|
||||
// 3. /api/projects/{id}/scenario-flags — seeds state.flags + provides
|
||||
// write-back path
|
||||
//
|
||||
// The actuals overlay maps deadline_rule_id → ActualStatus. The
|
||||
// fristenrechner calc returns ruleId on each TimelineEntry; the
|
||||
// tracker stamps the matching badge on each node.
|
||||
//
|
||||
// On first load, the anchor auto-pins to the latest status='done'
|
||||
// deadline (design Q5). Subsequent renders preserve the user's pin.
|
||||
|
||||
async function loadAkte(projectId: string): Promise<void> {
|
||||
if (!projectId) {
|
||||
state.actuals = new Map();
|
||||
state.akteLoaded = false;
|
||||
state.projectTitle = "";
|
||||
state.projectProceeding = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// 1. Project header / proceeding_type.
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (resp.ok) {
|
||||
const proj = await resp.json();
|
||||
state.projectTitle = String(proj?.title || proj?.name || "");
|
||||
const procCode = String(proj?.proceeding_type?.code || proj?.proceeding_type_code || "");
|
||||
state.projectProceeding = procCode;
|
||||
if (procCode && !state.procs.includes(procCode)) {
|
||||
state.procs = [procCode];
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("project fetch failed", e);
|
||||
}
|
||||
|
||||
// 2. Timeline → actuals map.
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(projectId)}/timeline`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (resp.ok) {
|
||||
const body = await resp.json();
|
||||
const events = Array.isArray(body?.events) ? body.events : Array.isArray(body) ? body : [];
|
||||
state.actuals = buildActualsMap(events);
|
||||
// Auto-pin anchor: latest status='done' (most recent completed
|
||||
// deadline). Only when no anchor is set yet — preserve URL
|
||||
// ?event= for shared links.
|
||||
if (!state.event) {
|
||||
const anchor = pickLatestDoneAnchor(events);
|
||||
if (anchor) {
|
||||
state.event = anchor;
|
||||
lastAnchor = anchor;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("project timeline fetch failed", e);
|
||||
}
|
||||
|
||||
// 3. Scenario flags — seed state.flags + future write-back.
|
||||
try {
|
||||
const view = await fetchScenarioFlags(projectId);
|
||||
if (view && view.flags) {
|
||||
const onFlags: string[] = [];
|
||||
for (const [k, v] of Object.entries(view.flags)) {
|
||||
// Filter to top-level scenario flags (not per-rule deviations).
|
||||
// Per-rule flags are keyed `rule:<uuid>` and not consumed by
|
||||
// the calc's flags[] payload.
|
||||
if (k.startsWith("rule:")) continue;
|
||||
if (v === true) onFlags.push(k);
|
||||
}
|
||||
state.flags = onFlags;
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("scenario-flags fetch failed", e);
|
||||
}
|
||||
|
||||
state.akteLoaded = true;
|
||||
}
|
||||
|
||||
function buildActualsMap(events: unknown[]): ActualsMap {
|
||||
const map: ActualsMap = new Map();
|
||||
for (const ev of events) {
|
||||
const e = ev as Record<string, unknown> | null;
|
||||
if (!e) continue;
|
||||
const ruleId = String(e.deadline_rule_id || "");
|
||||
if (!ruleId) continue;
|
||||
const kind = String(e.kind || "");
|
||||
const status = String(e.status || "");
|
||||
const date = typeof e.date === "string" ? e.date.split("T")[0] : "";
|
||||
|
||||
// Map SmartTimeline statuses to ActualStatus.status.
|
||||
let mapped: ActualStatus["status"];
|
||||
if (status === "done" || kind === "appointment") {
|
||||
mapped = "done";
|
||||
} else if (status === "overdue") {
|
||||
mapped = "overdue";
|
||||
} else if (status === "court_set") {
|
||||
mapped = "court_set";
|
||||
} else if (status === "open") {
|
||||
mapped = "open";
|
||||
} else {
|
||||
// projected / predicted / off_script — don't overlay.
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry: ActualStatus = { status: mapped };
|
||||
if (mapped === "done") entry.completedAt = date || undefined;
|
||||
else entry.dueDate = date || undefined;
|
||||
if (typeof e.deadline_id === "string") entry.deadlineId = e.deadline_id;
|
||||
if (typeof e.appointment_id === "string") entry.appointmentId = e.appointment_id;
|
||||
map.set(ruleId, entry);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function pickLatestDoneAnchor(events: unknown[]): string {
|
||||
let latest = "";
|
||||
let latestDate = "";
|
||||
for (const ev of events) {
|
||||
const e = ev as Record<string, unknown> | null;
|
||||
if (!e) continue;
|
||||
if (e.status !== "done") continue;
|
||||
const ruleId = String(e.deadline_rule_id || "");
|
||||
if (!ruleId) continue;
|
||||
const date = typeof e.date === "string" ? e.date : "";
|
||||
if (!latest || date > latestDate) {
|
||||
latest = ruleId;
|
||||
latestDate = date;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
// ─── boot ──────────────────────────────────────────────────────────────────
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
readStateFromURL();
|
||||
hydrateForumPills();
|
||||
hydrateProcPills();
|
||||
hydratePartyPills();
|
||||
wireSearchInput();
|
||||
wireTriggerDateInput();
|
||||
wireFlagDelegation();
|
||||
wireClickDelegation();
|
||||
|
||||
// T3: when bound to an Akte via ?project=, load actuals + scenario
|
||||
// flags + auto-pin to latest done deadline BEFORE the first render.
|
||||
// Otherwise the first render fires on template data and re-renders
|
||||
// once the Akte resolves — visible flicker on a slow connection.
|
||||
if (state.projectId) {
|
||||
void loadAkte(state.projectId).then(() => {
|
||||
hydrateProcPills();
|
||||
void rerender();
|
||||
});
|
||||
} else {
|
||||
void rerender();
|
||||
}
|
||||
|
||||
// T4: cross-surface scenario-flag re-sync. When another surface
|
||||
// (Mode B Fristenrechner, Verfahrensablauf, /admin) PATCHes the
|
||||
// same project's flags, scenario-flags.ts dispatches this event.
|
||||
// We re-seed state.flags from the detail payload and re-render so
|
||||
// the tracker stays coherent without a fresh GET.
|
||||
document.addEventListener(SCENARIO_FLAG_CHANGED_EVENT, (ev) => {
|
||||
const detail = (ev as CustomEvent<ScenarioFlagChangedDetail>).detail;
|
||||
if (!detail || !state.projectId) return;
|
||||
if (detail.projectId !== state.projectId) return;
|
||||
const onFlags: string[] = [];
|
||||
for (const [k, v] of Object.entries(detail.flags)) {
|
||||
if (k.startsWith("rule:")) continue;
|
||||
if (v === true) onFlags.push(k);
|
||||
}
|
||||
state.flags = onFlags;
|
||||
void rerender();
|
||||
});
|
||||
});
|
||||
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),
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,320 +0,0 @@
|
||||
// Per-event-card choice popover + chip indicator (t-paliad-265 /
|
||||
// m/paliad#96).
|
||||
//
|
||||
// The shared rendering core (verfahrensablauf-core.ts) emits a caret
|
||||
// button on cards that carry a non-empty `choices_offered` declaration
|
||||
// and an inert chip span next to the title. This module:
|
||||
//
|
||||
// 1. Wires a delegated click handler on the result container so the
|
||||
// caret opens a popover with the offered choice-kinds.
|
||||
// 2. Commits the user's pick — either by POSTing to the project-
|
||||
// bound endpoint or by mutating the in-memory state for the
|
||||
// unbound (no-project) case.
|
||||
// 3. Rehydrates the chip on every render + after every commit so the
|
||||
// glanceable indicator matches the active state.
|
||||
//
|
||||
// Two consumer pages — /tools/verfahrensablauf (unbound) and
|
||||
// /tools/fristenrechner (project-bound) — both wire this module
|
||||
// once at boot via attachEventCardChoices().
|
||||
|
||||
import { escAttr, escHtml } from "./verfahrensablauf-core";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export type ChoiceKind = "appellant" | "include_ccr" | "skip";
|
||||
|
||||
export interface EventChoice {
|
||||
submission_code: string;
|
||||
choice_kind: ChoiceKind;
|
||||
choice_value: string;
|
||||
}
|
||||
|
||||
// State surface — the page passes in callbacks that own persistence.
|
||||
// commit / remove must trigger a recalc on the page side (the popover
|
||||
// only owns its own visual state).
|
||||
export interface EventCardChoicesOpts {
|
||||
container: HTMLElement;
|
||||
// Initial state: a list of choices. The page seeds this from the
|
||||
// server response (project-bound) or from URL params (unbound).
|
||||
initial: EventChoice[];
|
||||
// commit gets called for an UPSERT. The page POSTs to the API (or
|
||||
// mutates URL state) AND triggers a recalc.
|
||||
commit: (choice: EventChoice) => Promise<void> | void;
|
||||
// remove gets called when the user resets a choice.
|
||||
remove: (submissionCode: string, kind: ChoiceKind) => Promise<void> | void;
|
||||
}
|
||||
|
||||
// One mutable bag per attach() call. The current implementation is a
|
||||
// single-page singleton — paginated views (admin tables) are not in
|
||||
// scope. Last-write-wins on the in-memory state.
|
||||
interface AttachedState {
|
||||
opts: EventCardChoicesOpts;
|
||||
// active: submission_code → kind → value. Rebuilt from `initial`
|
||||
// on every reseed() call.
|
||||
active: Map<string, Map<ChoiceKind, string>>;
|
||||
popover: HTMLDivElement | null;
|
||||
}
|
||||
|
||||
const states = new WeakMap<HTMLElement, AttachedState>();
|
||||
|
||||
// attachEventCardChoices wires the delegated click + popover lifecycle
|
||||
// to the given container. Call once per page after mount; safe to call
|
||||
// again with a fresh container.
|
||||
export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
|
||||
const state: AttachedState = {
|
||||
opts,
|
||||
active: new Map(),
|
||||
popover: null,
|
||||
};
|
||||
for (const c of opts.initial) {
|
||||
if (!state.active.has(c.submission_code)) {
|
||||
state.active.set(c.submission_code, new Map());
|
||||
}
|
||||
state.active.get(c.submission_code)!.set(c.choice_kind, c.choice_value);
|
||||
}
|
||||
states.set(opts.container, state);
|
||||
|
||||
opts.container.addEventListener("click", (e) => {
|
||||
const targetEl = e.target as HTMLElement | null;
|
||||
const caret = targetEl?.closest<HTMLElement>(".event-card-choices-caret");
|
||||
if (caret) {
|
||||
e.stopPropagation();
|
||||
openPopover(state, caret);
|
||||
return;
|
||||
}
|
||||
// Outside-click closes the popover.
|
||||
if (state.popover && !state.popover.contains(e.target as Node)) {
|
||||
closePopover(state);
|
||||
}
|
||||
});
|
||||
|
||||
// ESC also closes.
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && state.popover) {
|
||||
closePopover(state);
|
||||
}
|
||||
});
|
||||
|
||||
// Repaint chips on every renderResults() call. The page is
|
||||
// responsible for calling reseedChips() after re-render so the chip
|
||||
// dom node (re-created by the renderer) picks the active state up.
|
||||
reseedChips(opts.container);
|
||||
}
|
||||
|
||||
// reseedChips walks every chip span in the container and re-renders
|
||||
// its content from the active state map. Idempotent.
|
||||
export function reseedChips(container: HTMLElement): void {
|
||||
const state = states.get(container);
|
||||
if (!state) return;
|
||||
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
|
||||
const code = chip.dataset.submissionCode || "";
|
||||
const kinds = state.active.get(code);
|
||||
if (!kinds || kinds.size === 0) {
|
||||
chip.innerHTML = "";
|
||||
chip.dataset.empty = "true";
|
||||
return;
|
||||
}
|
||||
chip.dataset.empty = "false";
|
||||
chip.innerHTML = renderChip(kinds);
|
||||
});
|
||||
// Skipped rows fade out via a class on the card-item ancestor.
|
||||
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
|
||||
const code = chip.dataset.submissionCode || "";
|
||||
const skipped = state.active.get(code)?.get("skip") === "true";
|
||||
const itemEl = chip.closest<HTMLElement>(".timeline-item, .fr-col-item");
|
||||
if (itemEl) itemEl.classList.toggle("timeline-item--skipped", skipped);
|
||||
});
|
||||
}
|
||||
|
||||
function renderChip(kinds: Map<ChoiceKind, string>): string {
|
||||
const parts: string[] = [];
|
||||
if (kinds.get("skip") === "true") {
|
||||
parts.push(`<span class="event-card-choices-chip-part event-card-choices-chip-part--skipped">${escHtml(t("choices.skipped.chip"))}</span>`);
|
||||
}
|
||||
const ap = kinds.get("appellant");
|
||||
if (ap && ap !== "" ) {
|
||||
let label = "";
|
||||
switch (ap) {
|
||||
case "claimant": label = t("choices.appellant.claimant"); break;
|
||||
case "defendant": label = t("choices.appellant.defendant"); break;
|
||||
case "both": label = t("choices.appellant.both"); break;
|
||||
case "none": label = t("choices.appellant.none"); break;
|
||||
}
|
||||
if (label) {
|
||||
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.appellant.chip"))} ${escHtml(label)}</span>`);
|
||||
}
|
||||
}
|
||||
if (kinds.get("include_ccr") === "true") {
|
||||
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.include_ccr.chip"))}</span>`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function openPopover(state: AttachedState, caret: HTMLElement): void {
|
||||
closePopover(state);
|
||||
const code = caret.dataset.submissionCode || "";
|
||||
if (!code) return;
|
||||
let offered: Record<string, unknown> = {};
|
||||
try {
|
||||
offered = JSON.parse(caret.dataset.choicesOffered || "{}");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const isHidden = caret.dataset.isHidden === "1";
|
||||
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "event-card-choices-popover";
|
||||
pop.setAttribute("role", "dialog");
|
||||
pop.setAttribute("aria-label", t("choices.caret.title"));
|
||||
|
||||
const blocks: string[] = [];
|
||||
// t-paliad-293: hidden-card prominence. When the user opens the
|
||||
// popover on a re-surfaced hidden card, "Wieder einblenden" is the
|
||||
// most likely intent — surface it as a single high-contrast action
|
||||
// at the top of the popover (rather than burying it under the skip
|
||||
// toggle's reset link). Clicking it clears the `skip` choice, which
|
||||
// is the same wire effect as the legacy inline chip from t-paliad-290.
|
||||
if (isHidden) {
|
||||
blocks.push(renderUnhideBlock());
|
||||
}
|
||||
if (Array.isArray(offered.appellant)) {
|
||||
blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[]));
|
||||
}
|
||||
if (Array.isArray(offered.include_ccr)) {
|
||||
blocks.push(renderToggleBlock(state, code, "include_ccr"));
|
||||
}
|
||||
if (Array.isArray(offered.skip)) {
|
||||
blocks.push(renderToggleBlock(state, code, "skip"));
|
||||
}
|
||||
pop.innerHTML = blocks.join("");
|
||||
|
||||
document.body.appendChild(pop);
|
||||
state.popover = pop;
|
||||
positionPopover(pop, caret);
|
||||
|
||||
pop.addEventListener("click", async (e) => {
|
||||
const btn = (e.target as HTMLElement | null)?.closest<HTMLButtonElement>("button[data-choice-action]");
|
||||
if (!btn) return;
|
||||
e.stopPropagation();
|
||||
const kind = btn.dataset.choiceKind as ChoiceKind | undefined;
|
||||
const value = btn.dataset.choiceValue || "";
|
||||
const action = btn.dataset.choiceAction;
|
||||
if (!kind) return;
|
||||
try {
|
||||
if (action === "set") {
|
||||
await state.opts.commit({ submission_code: code, choice_kind: kind, choice_value: value });
|
||||
if (!state.active.has(code)) state.active.set(code, new Map());
|
||||
state.active.get(code)!.set(kind, value);
|
||||
} else if (action === "clear") {
|
||||
await state.opts.remove(code, kind);
|
||||
state.active.get(code)?.delete(kind);
|
||||
}
|
||||
reseedChips(state.opts.container);
|
||||
closePopover(state);
|
||||
} catch (err) {
|
||||
console.error("event card choice commit failed", err);
|
||||
// Surface a soft inline error inside the popover; do NOT close.
|
||||
const errEl = document.createElement("div");
|
||||
errEl.className = "event-card-choices-error";
|
||||
errEl.textContent = t("choices.commit.error");
|
||||
pop.appendChild(errEl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderAppellantBlock(state: AttachedState, code: string, values: unknown[]): string {
|
||||
const current = state.active.get(code)?.get("appellant") || "";
|
||||
const buttons = values
|
||||
.filter((v): v is string => typeof v === "string")
|
||||
.map((v) => {
|
||||
const labelKey = `choices.appellant.${v}` as const;
|
||||
const isActive = v === current;
|
||||
return `<button type="button"
|
||||
data-choice-action="set"
|
||||
data-choice-kind="appellant"
|
||||
data-choice-value="${escAttr(v)}"
|
||||
class="event-card-choices-option${isActive ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
|
||||
})
|
||||
.join("");
|
||||
const reset = current
|
||||
? `<button type="button" data-choice-action="clear" data-choice-kind="appellant"
|
||||
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
|
||||
: "";
|
||||
return `<div class="event-card-choices-block">
|
||||
<div class="event-card-choices-title">${escHtml(t("choices.appellant.title"))}</div>
|
||||
<div class="event-card-choices-options">${buttons}</div>
|
||||
${reset}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderToggleBlock(state: AttachedState, code: string, kind: "include_ccr" | "skip"): string {
|
||||
const current = state.active.get(code)?.get(kind) || "false";
|
||||
const titleKey = kind === "include_ccr" ? "choices.include_ccr.title" : "choices.skip.title";
|
||||
const trueKey = kind === "include_ccr" ? "choices.include_ccr.true" : "choices.skip.true";
|
||||
const falseKey = kind === "include_ccr" ? "choices.include_ccr.false" : "choices.skip.false";
|
||||
const opt = (v: "true" | "false", labelKey: string) => `<button type="button"
|
||||
data-choice-action="set"
|
||||
data-choice-kind="${kind}"
|
||||
data-choice-value="${v}"
|
||||
class="event-card-choices-option${v === current ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
|
||||
const reset = state.active.get(code)?.has(kind)
|
||||
? `<button type="button" data-choice-action="clear" data-choice-kind="${kind}"
|
||||
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
|
||||
: "";
|
||||
return `<div class="event-card-choices-block">
|
||||
<div class="event-card-choices-title">${escHtml(t(titleKey as any))}</div>
|
||||
<div class="event-card-choices-options">
|
||||
${opt("true", trueKey)}
|
||||
${opt("false", falseKey)}
|
||||
</div>
|
||||
${reset}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderUnhideBlock is the popover's prominent "Wieder einblenden"
|
||||
// action — surfaced only when the caret is opened on a re-surfaced
|
||||
// hidden card (data-is-hidden="1" on the caret). Clicking it dispatches
|
||||
// the same `clear` action as the skip-block reset link below, but
|
||||
// labelled in the user's terms ("restore this card" rather than
|
||||
// "reset skip choice"). Drops out of the popover automatically on
|
||||
// non-hidden cards so the popover stays minimal. (t-paliad-293)
|
||||
function renderUnhideBlock(): string {
|
||||
const label = t("choices.unhide.chip");
|
||||
return `<div class="event-card-choices-block event-card-choices-block--unhide">
|
||||
<button type="button"
|
||||
data-choice-action="clear"
|
||||
data-choice-kind="skip"
|
||||
class="event-card-choices-unhide-btn">${escHtml(label)}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function closePopover(state: AttachedState): void {
|
||||
if (state.popover) {
|
||||
state.popover.remove();
|
||||
state.popover = null;
|
||||
}
|
||||
}
|
||||
|
||||
function positionPopover(pop: HTMLDivElement, caret: HTMLElement): void {
|
||||
const rect = caret.getBoundingClientRect();
|
||||
const scrollY = window.scrollY || document.documentElement.scrollTop;
|
||||
const scrollX = window.scrollX || document.documentElement.scrollLeft;
|
||||
pop.style.position = "absolute";
|
||||
pop.style.top = `${rect.bottom + scrollY + 4}px`;
|
||||
pop.style.left = `${Math.max(8, rect.right + scrollX - 240)}px`;
|
||||
pop.style.zIndex = "1000";
|
||||
}
|
||||
|
||||
// Returns the current in-memory choice list for the given container —
|
||||
// used by the unbound /tools/verfahrensablauf page to keep the URL
|
||||
// param in sync.
|
||||
export function currentChoices(container: HTMLElement): EventChoice[] {
|
||||
const state = states.get(container);
|
||||
if (!state) return [];
|
||||
const out: EventChoice[] = [];
|
||||
state.active.forEach((kinds, code) => {
|
||||
kinds.forEach((value, kind) => {
|
||||
out.push({ submission_code: code, choice_kind: kind, choice_value: value });
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
// Unit tests for the /tools/verfahrensablauf URL + scenario-localStorage
|
||||
// state contract (t-paliad-308 / m/paliad#137). Run with `bun test`.
|
||||
//
|
||||
// The contract:
|
||||
// 1. URL params (proceeding, side, target, trigger_date) define which
|
||||
// timeline kind the user is looking at — paste-able, shareable,
|
||||
// refresh-resistant.
|
||||
// 2. localStorage (paliad.verfahrensablauf.scenario.*) holds the
|
||||
// per-user scenario tweaks (event_choices, court_id, flags,
|
||||
// show_hidden) — these never leak into a shared link.
|
||||
// 3. On hydrate, URL wins. localStorage fills the rest.
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
APPEAL_TARGETS,
|
||||
SCENARIO_KEYS,
|
||||
SCENARIO_PREFIX,
|
||||
URL_KEYS,
|
||||
applyFiltersToSearch,
|
||||
hydrate,
|
||||
makeMemoryStorage,
|
||||
parseAppealTargetFromSearch,
|
||||
parseProceedingFromSearch,
|
||||
parseSideFromSearch,
|
||||
parseTriggerDateFromSearch,
|
||||
readBoolFlag,
|
||||
readCourtId,
|
||||
readEventChoices,
|
||||
readScenario,
|
||||
writeBoolFlag,
|
||||
writeCourtId,
|
||||
writeEventChoices,
|
||||
} from "./verfahrensablauf-state";
|
||||
|
||||
describe("URL parsers — filter chips", () => {
|
||||
test("parseProceedingFromSearch returns empty string when absent", () => {
|
||||
expect(parseProceedingFromSearch("")).toBe("");
|
||||
expect(parseProceedingFromSearch("?side=claimant")).toBe("");
|
||||
});
|
||||
|
||||
test("parseProceedingFromSearch echoes the raw value", () => {
|
||||
expect(parseProceedingFromSearch("?proceeding=upc.inf.cfi")).toBe("upc.inf.cfi");
|
||||
expect(parseProceedingFromSearch("?proceeding=upc.apl.unified&side=claimant")).toBe("upc.apl.unified");
|
||||
});
|
||||
|
||||
test("parseSideFromSearch validates the enum", () => {
|
||||
expect(parseSideFromSearch("?side=claimant")).toBe("claimant");
|
||||
expect(parseSideFromSearch("?side=defendant")).toBe("defendant");
|
||||
expect(parseSideFromSearch("?side=neither")).toBe(null);
|
||||
expect(parseSideFromSearch("")).toBe(null);
|
||||
});
|
||||
|
||||
test("parseAppealTargetFromSearch only accepts canonical slugs", () => {
|
||||
for (const t of APPEAL_TARGETS) {
|
||||
expect(parseAppealTargetFromSearch(`?target=${t}`)).toBe(t);
|
||||
}
|
||||
expect(parseAppealTargetFromSearch("?target=unknown")).toBe("");
|
||||
expect(parseAppealTargetFromSearch("")).toBe("");
|
||||
});
|
||||
|
||||
test("parseTriggerDateFromSearch validates the ISO-date shape", () => {
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-05-26")).toBe("2026-05-26");
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2024-02-29")).toBe("2024-02-29"); // leap year
|
||||
});
|
||||
|
||||
test("parseTriggerDateFromSearch rejects malformed and impossible dates", () => {
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-02-30")).toBe(""); // Feb 30
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-13-01")).toBe(""); // month 13
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=tomorrow")).toBe("");
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-5-26")).toBe(""); // 1-digit month
|
||||
expect(parseTriggerDateFromSearch("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL encoder — applyFiltersToSearch", () => {
|
||||
test("empty filters preserve the existing query string", () => {
|
||||
expect(applyFiltersToSearch("?other=keep", {})).toBe("?other=keep");
|
||||
});
|
||||
|
||||
test("setting a filter writes the canonical key", () => {
|
||||
expect(applyFiltersToSearch("", { proceeding: "upc.inf.cfi" })).toBe("?proceeding=upc.inf.cfi");
|
||||
expect(applyFiltersToSearch("", { side: "claimant" })).toBe("?side=claimant");
|
||||
expect(applyFiltersToSearch("", { target: "endentscheidung" })).toBe("?target=endentscheidung");
|
||||
expect(applyFiltersToSearch("", { triggerDate: "2026-05-26" })).toBe("?trigger_date=2026-05-26");
|
||||
});
|
||||
|
||||
test("setting null / empty / undefined deletes the key", () => {
|
||||
expect(applyFiltersToSearch("?side=claimant", { side: null })).toBe("");
|
||||
expect(applyFiltersToSearch("?proceeding=upc.inf.cfi", { proceeding: "" })).toBe("");
|
||||
expect(applyFiltersToSearch("?target=endentscheidung", { target: "" })).toBe("");
|
||||
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "" })).toBe("");
|
||||
});
|
||||
|
||||
test("invalid trigger_date is deleted (never written as-is)", () => {
|
||||
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "bogus" })).toBe("");
|
||||
});
|
||||
|
||||
test("setting all four filters together emits all four keys", () => {
|
||||
const out = applyFiltersToSearch("", {
|
||||
proceeding: "upc.apl.unified",
|
||||
side: "defendant",
|
||||
target: "endentscheidung",
|
||||
triggerDate: "2026-05-26",
|
||||
});
|
||||
expect(out).toContain("proceeding=upc.apl.unified");
|
||||
expect(out).toContain("side=defendant");
|
||||
expect(out).toContain("target=endentscheidung");
|
||||
expect(out).toContain("trigger_date=2026-05-26");
|
||||
});
|
||||
|
||||
test("other params (project, view) are preserved", () => {
|
||||
const out = applyFiltersToSearch("?project=abc&view=timeline", { side: "claimant" });
|
||||
expect(out).toContain("project=abc");
|
||||
expect(out).toContain("view=timeline");
|
||||
expect(out).toContain("side=claimant");
|
||||
});
|
||||
|
||||
test("absent keys in the filter object don't touch existing URL values", () => {
|
||||
// Only updating side — proceeding should be untouched.
|
||||
const out = applyFiltersToSearch("?proceeding=upc.inf.cfi&side=defendant", { side: "claimant" });
|
||||
expect(out).toContain("proceeding=upc.inf.cfi");
|
||||
expect(out).toContain("side=claimant");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL round-trip — encode then parse yields the same value", () => {
|
||||
test("proceeding", () => {
|
||||
const enc = applyFiltersToSearch("", { proceeding: "upc.inf.cfi" });
|
||||
expect(parseProceedingFromSearch(enc)).toBe("upc.inf.cfi");
|
||||
});
|
||||
|
||||
test("side", () => {
|
||||
const enc = applyFiltersToSearch("", { side: "defendant" });
|
||||
expect(parseSideFromSearch(enc)).toBe("defendant");
|
||||
});
|
||||
|
||||
test("target", () => {
|
||||
const enc = applyFiltersToSearch("", { target: "kostenentscheidung" });
|
||||
expect(parseAppealTargetFromSearch(enc)).toBe("kostenentscheidung");
|
||||
});
|
||||
|
||||
test("trigger_date", () => {
|
||||
const enc = applyFiltersToSearch("", { triggerDate: "2026-05-26" });
|
||||
expect(parseTriggerDateFromSearch(enc)).toBe("2026-05-26");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scenario localStorage helpers", () => {
|
||||
test("SCENARIO_PREFIX is paliad.verfahrensablauf.scenario and all keys live under it", () => {
|
||||
expect(SCENARIO_PREFIX).toBe("paliad.verfahrensablauf.scenario");
|
||||
for (const key of Object.values(SCENARIO_KEYS)) {
|
||||
expect(key.startsWith(SCENARIO_PREFIX + ".")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("readEventChoices returns [] on empty storage", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readEventChoices(s)).toEqual([]);
|
||||
});
|
||||
|
||||
test("writeEventChoices + readEventChoices round-trip", () => {
|
||||
const s = makeMemoryStorage();
|
||||
const choices = [
|
||||
{ submission_code: "upc.inf.cfi.r12", choice_kind: "appellant" as const, choice_value: "claimant" },
|
||||
{ submission_code: "upc.inf.cfi.r30", choice_kind: "include_ccr" as const, choice_value: "1" },
|
||||
];
|
||||
writeEventChoices(s, choices);
|
||||
expect(readEventChoices(s)).toEqual(choices);
|
||||
});
|
||||
|
||||
test("writeEventChoices([]) clears the key (removeItem semantic, not empty string)", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeEventChoices(s, [{ submission_code: "r1", choice_kind: "skip", choice_value: "1" }]);
|
||||
expect(s.getItem(SCENARIO_KEYS.eventChoices)).not.toBe(null);
|
||||
writeEventChoices(s, []);
|
||||
expect(s.getItem(SCENARIO_KEYS.eventChoices)).toBe(null);
|
||||
});
|
||||
|
||||
test("readEventChoices ignores unknown choice_kind values", () => {
|
||||
const s = makeMemoryStorage();
|
||||
s.setItem(SCENARIO_KEYS.eventChoices, "r1:appellant=claimant,r2:bogus=x,r3:skip=1");
|
||||
expect(readEventChoices(s)).toEqual([
|
||||
{ submission_code: "r1", choice_kind: "appellant", choice_value: "claimant" },
|
||||
{ submission_code: "r3", choice_kind: "skip", choice_value: "1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("readCourtId returns '' on empty storage, echoes stored value otherwise", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readCourtId(s)).toBe("");
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
expect(readCourtId(s)).toBe("UPC-LD-MUC");
|
||||
});
|
||||
|
||||
test("writeCourtId('') removes the key", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe("UPC-LD-MUC");
|
||||
writeCourtId(s, "");
|
||||
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe(null);
|
||||
});
|
||||
|
||||
test("readBoolFlag / writeBoolFlag round-trip with removeItem on false", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(true);
|
||||
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe("1");
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, false);
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
|
||||
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe(null);
|
||||
});
|
||||
|
||||
test("readScenario returns all fields defaulted on empty storage", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readScenario(s)).toEqual({
|
||||
eventChoices: [],
|
||||
courtId: "",
|
||||
ccr: false,
|
||||
infAmend: false,
|
||||
revAmend: false,
|
||||
revCci: false,
|
||||
showHidden: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hydration order — URL wins, localStorage fills the rest", () => {
|
||||
test("URL fills filter chips, localStorage fills scenario state", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
writeBoolFlag(s, SCENARIO_KEYS.showHidden, true);
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
|
||||
const out = hydrate(
|
||||
"?proceeding=upc.inf.cfi&side=defendant&target=endentscheidung&trigger_date=2026-05-26",
|
||||
s,
|
||||
);
|
||||
// URL-sourced
|
||||
expect(out.proceeding).toBe("upc.inf.cfi");
|
||||
expect(out.side).toBe("defendant");
|
||||
expect(out.target).toBe("endentscheidung");
|
||||
expect(out.triggerDate).toBe("2026-05-26");
|
||||
// localStorage-sourced
|
||||
expect(out.courtId).toBe("UPC-LD-MUC");
|
||||
expect(out.showHidden).toBe(true);
|
||||
expect(out.ccr).toBe(true);
|
||||
});
|
||||
|
||||
test("absent URL → all filter fields are empty/null, localStorage still hydrates scenario", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
const out = hydrate("", s);
|
||||
expect(out.proceeding).toBe("");
|
||||
expect(out.side).toBe(null);
|
||||
expect(out.target).toBe("");
|
||||
expect(out.triggerDate).toBe("");
|
||||
expect(out.courtId).toBe("UPC-LD-MUC");
|
||||
});
|
||||
|
||||
test("absent localStorage → URL still fills filter chips, scenario defaults", () => {
|
||||
const s = makeMemoryStorage();
|
||||
const out = hydrate(
|
||||
"?proceeding=upc.apl.unified&side=claimant&target=anordnung&trigger_date=2026-07-01",
|
||||
s,
|
||||
);
|
||||
expect(out.proceeding).toBe("upc.apl.unified");
|
||||
expect(out.side).toBe("claimant");
|
||||
expect(out.target).toBe("anordnung");
|
||||
expect(out.triggerDate).toBe("2026-07-01");
|
||||
expect(out.courtId).toBe("");
|
||||
expect(out.eventChoices).toEqual([]);
|
||||
expect(out.showHidden).toBe(false);
|
||||
});
|
||||
|
||||
test("a shared link doesn't leak the recipient's scenario state in", () => {
|
||||
// Two storages: m's (loaded with court + flags) and a recipient's
|
||||
// (empty). The same URL should reproduce filter chips identically
|
||||
// but leave each user's scenario state untouched.
|
||||
const mStorage = makeMemoryStorage();
|
||||
writeCourtId(mStorage, "UPC-LD-MUC");
|
||||
writeBoolFlag(mStorage, SCENARIO_KEYS.ccr, true);
|
||||
const recipientStorage = makeMemoryStorage();
|
||||
|
||||
const sharedURL = "?proceeding=upc.inf.cfi&side=defendant&trigger_date=2026-05-26";
|
||||
|
||||
const mView = hydrate(sharedURL, mStorage);
|
||||
const recipientView = hydrate(sharedURL, recipientStorage);
|
||||
|
||||
// Filter chips identical
|
||||
expect(mView.proceeding).toBe(recipientView.proceeding);
|
||||
expect(mView.side).toBe(recipientView.side);
|
||||
expect(mView.triggerDate).toBe(recipientView.triggerDate);
|
||||
|
||||
// Scenario state diverges — recipient sees defaults
|
||||
expect(mView.courtId).toBe("UPC-LD-MUC");
|
||||
expect(recipientView.courtId).toBe("");
|
||||
expect(mView.ccr).toBe(true);
|
||||
expect(recipientView.ccr).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL key constants match the documented contract", () => {
|
||||
test("URL_KEYS uses the spec'd snake_case names", () => {
|
||||
expect(URL_KEYS.proceeding).toBe("proceeding");
|
||||
expect(URL_KEYS.side).toBe("side");
|
||||
expect(URL_KEYS.target).toBe("target");
|
||||
expect(URL_KEYS.triggerDate).toBe("trigger_date");
|
||||
});
|
||||
});
|
||||
@@ -1,263 +0,0 @@
|
||||
// /tools/verfahrensablauf URL + scenario-localStorage state contract
|
||||
// (t-paliad-308 / m/paliad#137). Splits the page's persisted state into
|
||||
// two namespaces:
|
||||
//
|
||||
// URL params (filter chips — the timeline kind the user is looking
|
||||
// at; paste-able, shareable, refresh-resistant):
|
||||
// proceeding, side, target, trigger_date
|
||||
//
|
||||
// localStorage `paliad.verfahrensablauf.scenario.*` (per-user
|
||||
// scenario inputs — the noisy parts that don't belong in a URL):
|
||||
// event_choices, court_id, ccr, inf_amend, rev_amend, rev_cci,
|
||||
// show_hidden
|
||||
//
|
||||
// Hydration order: URL wins. On page load, URL fills the filter chips;
|
||||
// localStorage fills the rest. Filter-chip changes write to URL only.
|
||||
// Scenario changes write to localStorage only. A shared link from a
|
||||
// colleague reproduces the timeline kind (proceeding + side + target +
|
||||
// trigger_date) but never leaks the recipient's court / flag /
|
||||
// event_choices state in.
|
||||
//
|
||||
// All helpers in this module are pure: they take a search string (or a
|
||||
// StorageLike) and return values, no DOM. The wiring in
|
||||
// ../verfahrensablauf.ts mounts them onto window.location +
|
||||
// window.localStorage at runtime.
|
||||
|
||||
import type { EventChoice, ChoiceKind } from "./event-card-choices";
|
||||
|
||||
// ----- URL params (filter chips) ----------------------------------
|
||||
|
||||
export type Side = "claimant" | "defendant" | null;
|
||||
|
||||
export const APPEAL_TARGETS = [
|
||||
"endentscheidung",
|
||||
"kostenentscheidung",
|
||||
"anordnung",
|
||||
"schadensbemessung",
|
||||
"bucheinsicht",
|
||||
] as const;
|
||||
export type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
|
||||
|
||||
export const URL_KEYS = {
|
||||
proceeding: "proceeding",
|
||||
side: "side",
|
||||
target: "target",
|
||||
triggerDate: "trigger_date",
|
||||
} as const;
|
||||
|
||||
// parseProceedingFromSearch extracts the proceeding code. Returns ""
|
||||
// if absent. No validation against the proceeding registry — that's
|
||||
// the caller's job (an unknown code from a stale link should leave
|
||||
// the first-tile auto-select fallback running).
|
||||
export function parseProceedingFromSearch(search: string): string {
|
||||
const v = new URLSearchParams(search).get(URL_KEYS.proceeding);
|
||||
return v ?? "";
|
||||
}
|
||||
|
||||
export function parseSideFromSearch(search: string): Side {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.side);
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
export function parseAppealTargetFromSearch(search: string): AppealTarget {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.target) || "";
|
||||
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
|
||||
return raw as AppealTarget;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// parseTriggerDateFromSearch validates the ISO-date shape so a
|
||||
// malformed link can't poison the date input. Accepts "YYYY-MM-DD"
|
||||
// only. Round-tripped against Date to reject 2026-02-30 etc.
|
||||
export function parseTriggerDateFromSearch(search: string): string {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.triggerDate) || "";
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return "";
|
||||
const d = new Date(raw + "T00:00:00Z");
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
if (d.toISOString().slice(0, 10) !== raw) return "";
|
||||
return raw;
|
||||
}
|
||||
|
||||
// applyFiltersToSearch produces the canonical query string for the
|
||||
// four URL-owned params. Other params (e.g. ?view=, ?project=) are
|
||||
// preserved verbatim. Empty values are deleted, never written as
|
||||
// empty string, so the URL stays clean on the default.
|
||||
export function applyFiltersToSearch(
|
||||
search: string,
|
||||
filters: { proceeding?: string; side?: Side; target?: AppealTarget; triggerDate?: string },
|
||||
): string {
|
||||
const params = new URLSearchParams(search);
|
||||
if ("proceeding" in filters) {
|
||||
if (filters.proceeding && filters.proceeding !== "") {
|
||||
params.set(URL_KEYS.proceeding, filters.proceeding);
|
||||
} else {
|
||||
params.delete(URL_KEYS.proceeding);
|
||||
}
|
||||
}
|
||||
if ("side" in filters) {
|
||||
if (filters.side === "claimant" || filters.side === "defendant") {
|
||||
params.set(URL_KEYS.side, filters.side);
|
||||
} else {
|
||||
params.delete(URL_KEYS.side);
|
||||
}
|
||||
}
|
||||
if ("target" in filters) {
|
||||
if (filters.target && filters.target !== "") {
|
||||
params.set(URL_KEYS.target, filters.target);
|
||||
} else {
|
||||
params.delete(URL_KEYS.target);
|
||||
}
|
||||
}
|
||||
if ("triggerDate" in filters) {
|
||||
if (filters.triggerDate && /^\d{4}-\d{2}-\d{2}$/.test(filters.triggerDate)) {
|
||||
params.set(URL_KEYS.triggerDate, filters.triggerDate);
|
||||
} else {
|
||||
params.delete(URL_KEYS.triggerDate);
|
||||
}
|
||||
}
|
||||
const s = params.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
// ----- localStorage (scenario state) ------------------------------
|
||||
|
||||
export const SCENARIO_PREFIX = "paliad.verfahrensablauf.scenario";
|
||||
export const SCENARIO_KEYS = {
|
||||
eventChoices: `${SCENARIO_PREFIX}.event_choices`,
|
||||
courtId: `${SCENARIO_PREFIX}.court_id`,
|
||||
ccr: `${SCENARIO_PREFIX}.ccr`,
|
||||
infAmend: `${SCENARIO_PREFIX}.inf_amend`,
|
||||
revAmend: `${SCENARIO_PREFIX}.rev_amend`,
|
||||
revCci: `${SCENARIO_PREFIX}.rev_cci`,
|
||||
showHidden: `${SCENARIO_PREFIX}.show_hidden`,
|
||||
} as const;
|
||||
|
||||
// StorageLike is the tiny subset of the Web Storage API the scenario
|
||||
// helpers actually use. Lets the tests pass a Map-backed fake without
|
||||
// pulling in a full localStorage polyfill.
|
||||
export interface StorageLike {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
// readEventChoices is forgiving: malformed tuples or unknown
|
||||
// choice_kinds are dropped silently. Same shape as the legacy URL
|
||||
// codec (comma-separated `submission_code:kind=value`).
|
||||
export function readEventChoices(storage: StorageLike): EventChoice[] {
|
||||
const raw = storage.getItem(SCENARIO_KEYS.eventChoices);
|
||||
if (!raw) return [];
|
||||
const out: EventChoice[] = [];
|
||||
for (const tuple of raw.split(",")) {
|
||||
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
|
||||
if (!m) continue;
|
||||
const kind = m[2] as ChoiceKind;
|
||||
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
|
||||
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function writeEventChoices(storage: StorageLike, choices: EventChoice[]): void {
|
||||
if (choices.length === 0) {
|
||||
storage.removeItem(SCENARIO_KEYS.eventChoices);
|
||||
return;
|
||||
}
|
||||
const enc = choices
|
||||
.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`)
|
||||
.join(",");
|
||||
storage.setItem(SCENARIO_KEYS.eventChoices, enc);
|
||||
}
|
||||
|
||||
// readCourtId / writeCourtId — empty string == no court picked. The
|
||||
// "" value is stored as a removed key, not an empty string entry, so
|
||||
// reading it back yields null rather than "".
|
||||
export function readCourtId(storage: StorageLike): string {
|
||||
return storage.getItem(SCENARIO_KEYS.courtId) ?? "";
|
||||
}
|
||||
|
||||
export function writeCourtId(storage: StorageLike, courtId: string): void {
|
||||
if (courtId === "") {
|
||||
storage.removeItem(SCENARIO_KEYS.courtId);
|
||||
return;
|
||||
}
|
||||
storage.setItem(SCENARIO_KEYS.courtId, courtId);
|
||||
}
|
||||
|
||||
// Boolean flags — "1" / "0" string encoding, removeItem on default
|
||||
// (false for flags, also false for show_hidden) so the storage stays
|
||||
// uncluttered on a fresh page.
|
||||
export function readBoolFlag(storage: StorageLike, key: string): boolean {
|
||||
return storage.getItem(key) === "1";
|
||||
}
|
||||
|
||||
export function writeBoolFlag(storage: StorageLike, key: string, on: boolean): void {
|
||||
if (on) storage.setItem(key, "1");
|
||||
else storage.removeItem(key);
|
||||
}
|
||||
|
||||
// Read all scenario state in one call — convenience for the page's
|
||||
// load-time hydration. Caller decides whether to apply each field
|
||||
// (e.g. court_id is proceeding-specific; the page may discard the
|
||||
// stored value if the active proceeding doesn't expose a court row).
|
||||
export interface ScenarioState {
|
||||
eventChoices: EventChoice[];
|
||||
courtId: string;
|
||||
ccr: boolean;
|
||||
infAmend: boolean;
|
||||
revAmend: boolean;
|
||||
revCci: boolean;
|
||||
showHidden: boolean;
|
||||
}
|
||||
|
||||
export function readScenario(storage: StorageLike): ScenarioState {
|
||||
return {
|
||||
eventChoices: readEventChoices(storage),
|
||||
courtId: readCourtId(storage),
|
||||
ccr: readBoolFlag(storage, SCENARIO_KEYS.ccr),
|
||||
infAmend: readBoolFlag(storage, SCENARIO_KEYS.infAmend),
|
||||
revAmend: readBoolFlag(storage, SCENARIO_KEYS.revAmend),
|
||||
revCci: readBoolFlag(storage, SCENARIO_KEYS.revCci),
|
||||
showHidden: readBoolFlag(storage, SCENARIO_KEYS.showHidden),
|
||||
};
|
||||
}
|
||||
|
||||
// ----- URL → localStorage hydration order -------------------------
|
||||
|
||||
// The page's load-time contract: read URL filters, then read
|
||||
// scenario state from localStorage. URL wins on conflict — but the
|
||||
// only field that can conflict is none of them today (URL owns
|
||||
// proceeding/side/target/trigger_date; localStorage owns the rest).
|
||||
// The order matters for one edge case: if a future field migrates
|
||||
// from URL → localStorage with overlap, the URL value MUST be honored.
|
||||
|
||||
export interface HydratedState extends ScenarioState {
|
||||
proceeding: string;
|
||||
side: Side;
|
||||
target: AppealTarget;
|
||||
triggerDate: string;
|
||||
}
|
||||
|
||||
export function hydrate(search: string, storage: StorageLike): HydratedState {
|
||||
const scenario = readScenario(storage);
|
||||
return {
|
||||
proceeding: parseProceedingFromSearch(search),
|
||||
side: parseSideFromSearch(search),
|
||||
target: parseAppealTargetFromSearch(search),
|
||||
triggerDate: parseTriggerDateFromSearch(search),
|
||||
...scenario,
|
||||
};
|
||||
}
|
||||
|
||||
// makeMemoryStorage — tiny StorageLike for tests / SSR fallback.
|
||||
// Not used by the runtime page (which mounts real localStorage), but
|
||||
// kept here so test files have one well-known import.
|
||||
export function makeMemoryStorage(): StorageLike {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
||||
setItem: (k, v) => { store.set(k, v); },
|
||||
removeItem: (k) => { store.delete(k); },
|
||||
};
|
||||
}
|
||||
@@ -17,7 +17,7 @@ export function Header({ showLogout }: HeaderProps): string {
|
||||
{showLogout && (
|
||||
<Fragment>
|
||||
<a href="/tools/kostenrechner" className="nav-link" data-i18n="nav.kostenrechner">Kostenrechner</a>
|
||||
<a href="/tools/fristenrechner" className="nav-link" data-i18n="nav.fristenrechner">Fristenrechner</a>
|
||||
<a href="/tools/procedures" className="nav-link" data-i18n="nav.procedures">Verfahren & Fristen</a>
|
||||
<a href="/logout" className="nav-logout" data-i18n="nav.logout">Abmelden</a>
|
||||
</Fragment>
|
||||
)}
|
||||
|
||||
@@ -177,8 +177,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
brief: calculators first, then reference (Checklisten /
|
||||
Gerichte / Glossar), then content (Links / Downloads). */}
|
||||
{group("nav.group.werkzeuge", "Werkzeuge",
|
||||
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
|
||||
navItem("/tools/verfahrensablauf", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
|
||||
navItem("/tools/procedures", ICON_BOOK_OPEN, "nav.procedures", "Verfahren & Fristen", currentPath) +
|
||||
navItem("/submissions", ICON_FILE_TEXT, "nav.submissions", "Schriftsätze", currentPath) +
|
||||
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
|
||||
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
|
||||
|
||||
@@ -1,666 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
interface ProceedingDef {
|
||||
code: string;
|
||||
i18nKey: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function proceedingBtn(p: ProceedingDef): string {
|
||||
return (
|
||||
<button type="button" className="proceeding-btn" data-code={p.code}>
|
||||
<strong data-i18n={p.i18nKey}>{p.name}</strong>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Quick-pick chip definition. Each chip targets ONE deadline_concepts
|
||||
// slug — clicking sets the search query to the concept's name in the
|
||||
// active language so trigram search lands on the right concept card.
|
||||
// Single source of truth for both fork-shortcut and B2-search-bar
|
||||
// chip rows. Dedup invariant: no two chips share a slug. Label flips
|
||||
// per language via the chip wiring in client/fristenrechner.ts.
|
||||
interface QuickChip {
|
||||
slug: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
}
|
||||
|
||||
const QUICK_CHIPS: QuickChip[] = [
|
||||
{ slug: "statement-of-defence", name_de: "Klageerwiderung", name_en: "Statement of Defence" },
|
||||
{ slug: "notice-of-appeal", name_de: "Berufungsschrift", name_en: "Notice of Appeal" },
|
||||
{ slug: "opposition", name_de: "Einspruchsfrist", name_en: "Opposition" },
|
||||
{ slug: "reply-to-defence", name_de: "Replik", name_en: "Reply to Defence" },
|
||||
{ slug: "nichtzulassungsbeschwerde", name_de: "Nichtzulassungsbeschwerde", name_en: "Non-admission Appeal (NZB)" },
|
||||
{ slug: "application-for-determination-of-damages",name_de: "Antrag auf Schadensbemessung", name_en: "Application for Determination of Damages" },
|
||||
{ slug: "wiedereinsetzung", name_de: "Wiedereinsetzung", name_en: "Re-establishment of Rights" },
|
||||
];
|
||||
|
||||
function quickChip(c: QuickChip): string {
|
||||
return (
|
||||
<button type="button" className="fristen-search-chip"
|
||||
data-chip-slug={c.slug}
|
||||
data-chip-name-de={c.name_de}
|
||||
data-chip-name-en={c.name_en}
|
||||
data-q={c.name_de}>
|
||||
{c.name_de}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const UPC_TYPES: ProceedingDef[] = [
|
||||
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
|
||||
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
|
||||
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
|
||||
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Ma\u00dfnahmen" },
|
||||
{ code: "upc.apl.merits", i18nKey: "deadlines.upc.apl.merits", name: "Berufung" },
|
||||
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
|
||||
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
|
||||
{ code: "upc.apl.cost", i18nKey: "deadlines.upc.apl.cost", name: "Berufung Kosten" },
|
||||
{ code: "upc.apl.order", i18nKey: "deadlines.upc.apl.order", name: "Berufung Anordnungen" },
|
||||
];
|
||||
|
||||
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
|
||||
// 2026-05-18 ask. Labels are parallel: <court> (<procedural role>),
|
||||
// so a user scanning the picker sees the instance-and-role at a glance
|
||||
// without one tile reading "Berufung OLG" and another "Nichtigkeits-
|
||||
// verfahren". Sub-group headers convey the type grouping. Combined-
|
||||
// timeline behaviour (LG→OLG→BGH as one calc) is filed as m/paliad#41.
|
||||
const DE_INF_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
|
||||
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
|
||||
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
|
||||
];
|
||||
|
||||
const DE_NULL_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
|
||||
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
|
||||
];
|
||||
|
||||
const EPA_TYPES: ProceedingDef[] = [
|
||||
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
|
||||
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
|
||||
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
|
||||
];
|
||||
|
||||
const DPMA_TYPES: ProceedingDef[] = [
|
||||
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
|
||||
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
|
||||
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
|
||||
];
|
||||
|
||||
export function renderFristenrechner(): string {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="deadlines.title">Fristenrechner — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/tools/fristenrechner" />
|
||||
<BottomNav currentPath="/tools/fristenrechner" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="deadlines.heading">Fristenrechner</h1>
|
||||
<p className="tool-subtitle" data-i18n="deadlines.subtitle">
|
||||
Berechnung von Verfahrensfristen für UPC-, deutsche und EPA-Verfahren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-323 Slice S2 — overhaul result view mount root.
|
||||
Hidden by default; the client module shows this and hides
|
||||
the legacy panels when `?overhaul=1` is present in the
|
||||
URL. Deep-linkable on its own via
|
||||
`?overhaul=1&event=<code>&trigger_date=…`. Mode A (S3)
|
||||
and Mode B wizard (S4) will land users on this surface
|
||||
once they identify a trigger procedural_event. */}
|
||||
<div className="fristen-overhaul-root" id="fristen-overhaul-root" hidden></div>
|
||||
|
||||
{/* m's 2026-05-08 18:08 Determinator redesign — Step 1: pick the
|
||||
Akte (project) that scopes the rest of the flow. Filtered
|
||||
list of visible projects + "Neue Akte anlegen" link +
|
||||
four ad-hoc explore-mode chips for users who just want to
|
||||
look up a rule without saving anywhere. */}
|
||||
<div className="fristen-step1" id="fristen-step1" role="group" aria-label="Akte picker">
|
||||
<h2 className="fristen-step-heading" data-i18n="deadlines.step1.heading">
|
||||
Schritt 1 — Welche Akte?
|
||||
</h2>
|
||||
<div className="fristen-step1-search-row">
|
||||
<svg className="fristen-search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input type="search" id="fristen-akte-search"
|
||||
className="fristen-akte-search" autocomplete="off"
|
||||
data-i18n-placeholder="deadlines.step1.search.placeholder"
|
||||
placeholder="Akte suchen…" />
|
||||
</div>
|
||||
<ul className="fristen-akte-list" id="fristen-akte-list" role="listbox" aria-label="Akten"></ul>
|
||||
|
||||
<div className="fristen-step1-divider">
|
||||
<span data-i18n="deadlines.step1.divider.new">oder eine neue Akte</span>
|
||||
</div>
|
||||
{/* return-bounce: projects-new.ts honours ?return= and
|
||||
redirects back to /tools/fristenrechner?project=<new_uuid>
|
||||
so the new Akte preselects itself in Step 1. */}
|
||||
<a href="/projects/new?return=/tools/fristenrechner" className="fristen-step1-new" id="fristen-step1-new"
|
||||
data-i18n="deadlines.step1.new.cta">
|
||||
+ Neue Akte anlegen
|
||||
</a>
|
||||
|
||||
<div className="fristen-step1-divider">
|
||||
<span data-i18n="deadlines.step1.divider.adhoc">oder ad-hoc, ohne Akte</span>
|
||||
</div>
|
||||
<div className="fristen-adhoc-chips" role="group" aria-label="Ad-hoc proceeding">
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="upc"
|
||||
data-i18n="deadlines.step1.adhoc.upc">
|
||||
UPC proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="de"
|
||||
data-i18n="deadlines.step1.adhoc.de">
|
||||
DE proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="epa"
|
||||
data-i18n="deadlines.step1.adhoc.epa">
|
||||
EPA proceeding
|
||||
</button>
|
||||
<button type="button" className="fristen-adhoc-chip" data-ad-hoc="dpma"
|
||||
data-i18n="deadlines.step1.adhoc.dpma">
|
||||
DPMA proceeding
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 1 collapsed summary, shown after a pick. Mirrors the
|
||||
proceeding-summary collapse pattern from 097e21c. */}
|
||||
<div className="fristen-step1-summary" id="fristen-step1-summary" style="display:none" role="group">
|
||||
<span className="fristen-step1-summary-label" data-i18n="deadlines.step1.selected">Akte:</span>
|
||||
<strong className="fristen-step1-summary-name" id="fristen-step1-summary-name">—</strong>
|
||||
<span className="fristen-step1-summary-meta" id="fristen-step1-summary-meta"></span>
|
||||
<button type="button" className="fristen-step1-summary-reselect" id="fristen-step1-summary-reselect"
|
||||
data-i18n="deadlines.step1.reselect">
|
||||
Andere Akte
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Step 2 — Do / Happened bifurcation. Hidden until Step 1 is
|
||||
satisfied. Click on a card routes to the existing Pathway A
|
||||
(Verfahrensablauf wizard) or Pathway B (cascade) shells —
|
||||
we keep the routing primitive in showPathway()/showBMode(). */}
|
||||
<div className="fristen-step2" id="fristen-step2" hidden>
|
||||
<h2 className="fristen-step-heading" data-i18n="deadlines.step2.heading">
|
||||
Schritt 2 — Was möchten Sie tun?
|
||||
</h2>
|
||||
<div className="fristen-step2-cards">
|
||||
<button type="button" className="fristen-step2-card" data-action="file" id="fristen-step2-file">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">✏️</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.file.title">
|
||||
Etwas einreichen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.file.desc">
|
||||
Outgoing — eine Frist tritt aus eigener Handlung ein.
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-step2-card" data-action="happened" id="fristen-step2-happened">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">📥</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.happened.title">
|
||||
Etwas ist passiert
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.happened.desc">
|
||||
Incoming — ein Ereignis hat eine Frist ausgelöst.
|
||||
</span>
|
||||
</button>
|
||||
{/* t-paliad-179 Slice 1: the third "Verfahrensablauf
|
||||
einsehen" card retired — abstract-browse intent now
|
||||
owns its own route at /tools/verfahrensablauf. */}
|
||||
</div>
|
||||
<div className="fristen-step2-shortcut">
|
||||
<div className="fristen-pathway-fork-shortcut-label" data-i18n="deadlines.pathway.shortcut.label">
|
||||
oder direkt zu einer Frist springen:
|
||||
</div>
|
||||
<div className="fristen-search-chips" id="fristen-fork-chips" role="group" aria-label="Schnellzugriff">
|
||||
{QUICK_CHIPS.map((c) => quickChip(c))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pathway B container — search bar relocates here from the page top.
|
||||
Mode toggle (B1 tree / B2 filter) sits above the panels.
|
||||
Hidden until ?path=b. */}
|
||||
<div className="fristen-pathway-shell" id="fristen-pathway-b" data-path="b" hidden>
|
||||
<button type="button" className="fristen-pathway-back" id="fristen-pathway-b-back">
|
||||
<span aria-hidden="true">←</span>{" "}
|
||||
<span data-i18n="deadlines.pathway.back">zurück zur Auswahl</span>
|
||||
</button>
|
||||
<h2 className="fristen-pathway-heading">
|
||||
<span aria-hidden="true">📅</span>{" "}
|
||||
<span data-i18n="deadlines.pathway.b.title">Frist eintragen aufgrund Ereignis</span>
|
||||
</h2>
|
||||
|
||||
{/* B1 panel — row-stack cascade.
|
||||
`#fristen-row-stack` hosts the perspective / inbox /
|
||||
cascade rows (t-paliad-180 Slice 1; t-paliad-197 Slice 2
|
||||
added project-driven prefills + auto-walk). The
|
||||
stack-header above carries the inline-search trigger
|
||||
(t-paliad-198 Slice 3 — clicking expands
|
||||
`#fristen-row-search-panel` over the row stack instead
|
||||
of routing to the legacy B2 surface) and the reset link.
|
||||
`#fristen-b1-results` is unchanged — it renders concept
|
||||
cards for both cascade-narrowing AND inline-search
|
||||
results, so users see the same card layout regardless
|
||||
of how they reached a deadline rule. */}
|
||||
<div className="fristen-b1-panel" id="fristen-b1-panel" data-mode="tree" hidden>
|
||||
<div className="fristen-row-stack-header" id="fristen-row-stack-header">
|
||||
<button type="button" className="fristen-row-search-link" id="fristen-row-search-link"
|
||||
data-i18n-title="deadlines.row.search.link.title"
|
||||
aria-expanded="false"
|
||||
aria-controls="fristen-row-search-panel"
|
||||
title="Direkt nach einer Frist suchen">
|
||||
<span aria-hidden="true">🔍</span>{" "}
|
||||
<span data-i18n="deadlines.row.search.link">Direkt suchen</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-row-reset-link" id="fristen-row-reset"
|
||||
data-i18n-title="deadlines.row.reset.title"
|
||||
title="Pfad zurücksetzen — alle Cascade-Antworten verwerfen">
|
||||
<span aria-hidden="true">↺</span>{" "}
|
||||
<span data-i18n="deadlines.row.reset">Pfad zurücksetzen</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Inline search overlay (t-paliad-198 Slice 3). Hidden by
|
||||
default; the search icon-button in the stack header
|
||||
toggles it open / closed. While open, the row stack is
|
||||
hidden and the search input drives `#fristen-b1-results`
|
||||
directly — same surface the cascade leaf populates so
|
||||
the user sees one consistent concept-card list. */}
|
||||
<div className="fristen-row-search-panel" id="fristen-row-search-panel" hidden role="search">
|
||||
<button type="button" className="fristen-row-search-panel-back" id="fristen-row-search-panel-back"
|
||||
data-i18n-title="deadlines.row.search.panel.back.title"
|
||||
title="Zurück zum Entscheidungsbaum">
|
||||
<span aria-hidden="true">←</span>{" "}
|
||||
<span data-i18n="deadlines.row.search.panel.back">Zurück zum Entscheidungsbaum</span>
|
||||
</button>
|
||||
<div className="fristen-row-search-panel-input-wrap">
|
||||
<svg className="fristen-row-search-panel-icon" width="18" height="18" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"
|
||||
stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
id="fristen-row-search-panel-input"
|
||||
className="fristen-row-search-panel-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-i18n-placeholder="deadlines.row.search.panel.placeholder"
|
||||
placeholder="Frist suchen…"
|
||||
aria-label="Frist suchen"
|
||||
/>
|
||||
<button type="button" className="fristen-row-search-panel-clear" id="fristen-row-search-panel-clear"
|
||||
data-i18n-title="deadlines.row.search.panel.clear" title="Eingabe leeren" hidden>
|
||||
<span aria-hidden="true">×</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="fristen-row-stack" id="fristen-row-stack" aria-live="polite"></div>
|
||||
<div className="fristen-b1-results" id="fristen-b1-results" aria-live="polite"></div>
|
||||
</div>
|
||||
|
||||
{/* B2 panel — search bar + chips + concept-card results.
|
||||
The search input + chips + results host live here so
|
||||
fristenrechner.ts can drive both Phase D (today) and the
|
||||
B1↔B2 state-share in Phase D (forum filter). */}
|
||||
<div className="fristen-b2-panel" id="fristen-b2-panel" data-mode="filter">
|
||||
<div className="fristen-search">
|
||||
<label htmlFor="fristen-search-input" className="visually-hidden" data-i18n="deadlines.search.label">Frist suchen</label>
|
||||
<div className="fristen-search-row">
|
||||
<svg className="fristen-search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input
|
||||
type="search"
|
||||
id="fristen-search-input"
|
||||
className="fristen-search-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-i18n-placeholder="deadlines.search.placeholder"
|
||||
placeholder="Klageerwiderung, RoP 23, § 82, Wiedereinsetzung…"
|
||||
/>
|
||||
<button type="button" id="fristen-search-clear" className="fristen-search-clear" aria-label="Suche leeren" data-i18n-aria-label="deadlines.search.clear" hidden>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div className="fristen-search-chips" id="fristen-search-chips" role="group" aria-label="Schnellzugriff">
|
||||
<span className="fristen-search-chips-label" data-i18n="deadlines.search.chips.label">Schnellzugriff:</span>
|
||||
{QUICK_CHIPS.map((c) => quickChip(c))}
|
||||
</div>
|
||||
{/* Forum filter row — populated by Phase D. */}
|
||||
<div className="fristen-forum-filter" id="fristen-forum-filter" hidden>
|
||||
<span className="fristen-forum-filter-label" data-i18n="deadlines.filter.forum.label">Gericht / System:</span>
|
||||
<div className="fristen-forum-chips" id="fristen-forum-chips"></div>
|
||||
</div>
|
||||
<div id="fristen-search-results" className="fristen-search-results" aria-live="polite"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Step 3a — outgoing-intent chooser. Reached when the user
|
||||
picks "Etwas einreichen" on Step 2. Three options per
|
||||
m's 2026-05-08 18:09 spec: File (drives the Pathway A
|
||||
wizard), Draft (future drafting surface; v1
|
||||
placeholder), Enter (routes to the existing manual-
|
||||
create form). */}
|
||||
<div className="fristen-pathway-shell" id="fristen-step3a" data-path="outgoing" hidden>
|
||||
<button type="button" className="fristen-pathway-back" id="fristen-step3a-back">
|
||||
<span aria-hidden="true">←</span>{" "}
|
||||
<span data-i18n="deadlines.step3a.back">zurück zur Auswahl</span>
|
||||
</button>
|
||||
<h2 className="fristen-pathway-heading">
|
||||
<span aria-hidden="true">✏️</span>{" "}
|
||||
<span data-i18n="deadlines.step3a.heading">Was möchten Sie einreichen?</span>
|
||||
</h2>
|
||||
<div className="fristen-step2-cards">
|
||||
<button type="button" className="fristen-step2-card" id="fristen-step3a-file" data-action="file">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">📝</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.file.title">
|
||||
Schriftsatz einreichen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.file.desc">
|
||||
Verfahrensablauf laden — Frist berechnen und zur Akte hinzufügen.
|
||||
</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-step2-card fristen-step2-card--soon" id="fristen-step3a-draft" data-action="draft" disabled
|
||||
data-i18n-title="deadlines.step3a.soon">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">🖉</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.draft.title">
|
||||
Schriftsatz entwerfen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.draft.desc">
|
||||
Vorbereitung — später mit Drafting-Surface verknüpft.
|
||||
</span>
|
||||
<span className="fristen-step2-card-soon" data-i18n="deadlines.step3a.soon">kommt bald</span>
|
||||
</button>
|
||||
<button type="button" className="fristen-step2-card" id="fristen-step3a-enter" data-action="enter">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">💾</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step3a.enter.title">
|
||||
Frist manuell erfassen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step3a.enter.desc">
|
||||
Direkt eintragen — bereits bekanntes Datum / bekannter Typ.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pathway A container — wraps the existing wizard.
|
||||
Hidden until ?path=a. */}
|
||||
<div className="fristen-pathway-shell" id="fristen-pathway-a" data-path="a" hidden>
|
||||
<button type="button" className="fristen-pathway-back" id="fristen-pathway-a-back">
|
||||
<span aria-hidden="true">←</span>{" "}
|
||||
<span data-i18n="deadlines.pathway.back">zurück zur Auswahl</span>
|
||||
</button>
|
||||
<h2 className="fristen-pathway-heading">
|
||||
<span aria-hidden="true">📖</span>{" "}
|
||||
<span data-i18n="deadlines.pathway.a.title">Verfahrensablauf informieren</span>
|
||||
</h2>
|
||||
|
||||
{/* v3: legacy mode tabs retired (m's spec lock §10 Q1, 2026-05-05).
|
||||
Pathway A is Verfahrensablauf-only; trigger-event drill-in
|
||||
surfaces via concept-card pills with ?path=a&trigger=N URL,
|
||||
which resurfaces mode-event-panel programmatically below. */}
|
||||
<div className="fristen-wizard mode-panel" id="mode-procedure-panel" data-mode="procedure" role="tabpanel">
|
||||
<div className="wizard-step" id="step-1">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">1</span>
|
||||
<span data-i18n="deadlines.step1">Verfahrensart wählen</span>
|
||||
</h3>
|
||||
|
||||
<div className="proceeding-group" data-forum="upc">
|
||||
<h4 data-i18n="deadlines.upc">UPC</h4>
|
||||
<div className="proceeding-btns">
|
||||
{UPC_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="de">
|
||||
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="epa">
|
||||
<h4 data-i18n="deadlines.epa">EPA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{EPA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="dpma">
|
||||
<h4 data-i18n="deadlines.dpma">DPMA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{DPMA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* m's 2026-05-08 18:26: collapse the proceeding picker once
|
||||
a choice is made; this summary line replaces the four
|
||||
group blocks with a one-line "Selected: X [Reselect]"
|
||||
affordance. JS toggles `.proceeding-summary` visibility
|
||||
in lockstep with `.proceeding-group` blocks. */}
|
||||
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
|
||||
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
|
||||
<strong className="proceeding-summary-name" id="proceeding-summary-name">—</strong>
|
||||
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
|
||||
data-i18n="deadlines.proceeding.reselect">
|
||||
Anderes Verfahren wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-2" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">2</span>
|
||||
<span data-i18n="deadlines.step2">Ausgangsdatum eingeben</span>
|
||||
</h3>
|
||||
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
{/* Read-only caption labelling the value <span>. Not a
|
||||
<label htmlFor> — m/paliad#60: <label for=…> must
|
||||
point at a labelable form control, never a span. */}
|
||||
<span className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</span>
|
||||
<span id="trigger-event" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
|
||||
<input type="date" id="trigger-date" className="date-input" value={today} />
|
||||
</div>
|
||||
<div className="date-field-row" id="court-picker-row" style="display:none">
|
||||
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
|
||||
<select id="court-picker" className="date-input"></select>
|
||||
</div>
|
||||
<div className="date-field-row" id="priority-date-row" style="display:none">
|
||||
<label htmlFor="priority-date" className="date-label" data-i18n="deadlines.priority.date">Prioritätstag (optional):</label>
|
||||
<input type="date" id="priority-date" className="date-input" />
|
||||
</div>
|
||||
<div className="date-field-row" id="ccr-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="ccr-flag" />
|
||||
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row date-field-row--nested" id="inf-amend-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="inf-amend-flag" />
|
||||
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patentänderung (R.30)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row" id="rev-amend-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="rev-amend-flag" />
|
||||
<span data-i18n="deadlines.flag.rev_amend">Mit Antrag auf Patentänderung (R.49.2.a)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row" id="rev-cci-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="rev-cci-flag" />
|
||||
<span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
|
||||
Fristen berechnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-3" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">3</span>
|
||||
<span data-i18n="deadlines.step3">Ergebnis</span>
|
||||
</h3>
|
||||
|
||||
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
|
||||
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="columns" checked />
|
||||
<span data-i18n="deadlines.view.columns">Spalten</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="fristen-notes-show" />
|
||||
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
</div>
|
||||
|
||||
<div className="fristen-result-actions">
|
||||
<button type="button" id="fristen-save-cta" className="btn-primary btn-cta-lime" style="display:none" data-i18n="deadlines.save.cta">
|
||||
Als Frist(en) speichern
|
||||
</button>
|
||||
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
|
||||
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 6 2 18 2 18 9"></polyline>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
|
||||
<rect x="6" y="14" width="12" height="8"></rect>
|
||||
</svg>
|
||||
<span data-i18n="deadlines.print">Drucken</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" id="reset-btn" className="reset-btn" style="display:none" data-i18n="deadlines.reset">
|
||||
← Neu berechnen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="fristen-wizard mode-panel" id="mode-event-panel" data-mode="event" role="tabpanel" hidden>
|
||||
<div className="wizard-step" id="event-step-1">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">1</span>
|
||||
<span data-i18n="deadlines.event.step1">Trigger-Ereignis wählen</span>
|
||||
</h3>
|
||||
<p className="wizard-step-hint" data-i18n="deadlines.event.step1.hint">
|
||||
Welches Ereignis ist eingetreten? (z.B. Klageerhebung, Entscheidung des EPA, Zustellung einer Verfügung)
|
||||
</p>
|
||||
<div className="event-picker-row">
|
||||
<label htmlFor="event-search" className="visually-hidden" data-i18n="deadlines.event.search.label">Trigger-Ereignis suchen</label>
|
||||
<input
|
||||
type="search"
|
||||
id="event-search"
|
||||
className="event-search-input"
|
||||
autocomplete="off"
|
||||
data-i18n-placeholder="deadlines.event.search.placeholder"
|
||||
placeholder="Tippe, um zu suchen…"
|
||||
/>
|
||||
<ul id="event-list" className="event-list" role="listbox" aria-label="Trigger-Ereignisse"></ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="event-step-2" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">2</span>
|
||||
<span data-i18n="deadlines.event.step2">Datum des Ereignisses</span>
|
||||
</h3>
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
<label className="date-label" data-i18n="deadlines.event.selected">Gewähltes Ereignis:</label>
|
||||
<span id="event-selected-name" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="event-date" className="date-label" data-i18n="deadlines.event.date">Eintrittsdatum:</label>
|
||||
<input type="date" id="event-date" className="date-input" value={today} />
|
||||
</div>
|
||||
<button type="button" id="event-calculate-btn" className="calculate-btn" data-i18n="deadlines.event.calculate">
|
||||
Folgefristen berechnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="event-step-3" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">3</span>
|
||||
<span data-i18n="deadlines.event.step3">Folgefristen</span>
|
||||
</h3>
|
||||
<div id="event-results-container"></div>
|
||||
<div className="fristen-result-actions">
|
||||
<button type="button" id="event-print-btn" className="print-btn" style="display:none">
|
||||
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 6 2 18 2 18 9"></polyline>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
|
||||
<rect x="6" y="14" width="12" height="8"></rect>
|
||||
</svg>
|
||||
<span data-i18n="deadlines.print">Drucken</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" id="event-reset-btn" className="reset-btn" style="display:none" data-i18n="deadlines.reset">
|
||||
← Neu berechnen
|
||||
</button>
|
||||
</div>
|
||||
</div>{/* /pathway-a */}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/fristenrechner.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
@@ -1351,6 +1358,8 @@ export type I18nKey =
|
||||
| "deadlines.filter.status"
|
||||
| "deadlines.filter.thisweek"
|
||||
| "deadlines.filter.today"
|
||||
| "deadlines.flag.amend"
|
||||
| "deadlines.flag.cci"
|
||||
| "deadlines.flag.ccr"
|
||||
| "deadlines.flag.inf_amend"
|
||||
| "deadlines.flag.rev_amend"
|
||||
@@ -1378,6 +1387,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"
|
||||
@@ -2062,7 +2073,6 @@ export type I18nKey =
|
||||
| "nav.downloads"
|
||||
| "nav.einstellungen"
|
||||
| "nav.fristen"
|
||||
| "nav.fristenrechner"
|
||||
| "nav.gebuehrentabellen"
|
||||
| "nav.gerichte"
|
||||
| "nav.glossar"
|
||||
@@ -2079,13 +2089,13 @@ export type I18nKey =
|
||||
| "nav.logout"
|
||||
| "nav.neuigkeiten"
|
||||
| "nav.paliadin"
|
||||
| "nav.procedures"
|
||||
| "nav.projekte"
|
||||
| "nav.soon.tooltip"
|
||||
| "nav.submissions"
|
||||
| "nav.team"
|
||||
| "nav.termine"
|
||||
| "nav.user_views.new"
|
||||
| "nav.verfahrensablauf"
|
||||
| "notes.cancel"
|
||||
| "notes.delete"
|
||||
| "notes.delete.confirm"
|
||||
@@ -2195,6 +2205,50 @@ export type I18nKey =
|
||||
| "partner_unit.members_label"
|
||||
| "partner_unit.none"
|
||||
| "partner_unit.subtitle"
|
||||
| "procedures.appeal_target.label"
|
||||
| "procedures.cold_open.hint"
|
||||
| "procedures.filter.axis.date"
|
||||
| "procedures.filter.axis.forum"
|
||||
| "procedures.filter.axis.kind"
|
||||
| "procedures.filter.axis.party"
|
||||
| "procedures.filter.axis.proc"
|
||||
| "procedures.filter.forum.all"
|
||||
| "procedures.filter.party.all"
|
||||
| "procedures.filter.search.placeholder"
|
||||
| "procedures.find.summary.akte"
|
||||
| "procedures.find.summary.anchor"
|
||||
| "procedures.find.summary.empty"
|
||||
| "procedures.find.summary.many"
|
||||
| "procedures.find.summary.one"
|
||||
| "procedures.heading"
|
||||
| "procedures.node.actual.done"
|
||||
| "procedures.node.actual.open"
|
||||
| "procedures.node.actual.overdue"
|
||||
| "procedures.node.cross"
|
||||
| "procedures.node.cross.short"
|
||||
| "procedures.node.fokus"
|
||||
| "procedures.node.here"
|
||||
| "procedures.node.pin"
|
||||
| "procedures.panel.akte.placeholder"
|
||||
| "procedures.proceeding.detail.all"
|
||||
| "procedures.proceeding.detail.selected"
|
||||
| "procedures.proceeding.detail.title"
|
||||
| "procedures.proceeding.hide"
|
||||
| "procedures.proceeding.show"
|
||||
| "procedures.proceeding.toggle"
|
||||
| "procedures.subtitle"
|
||||
| "procedures.tab.akte"
|
||||
| "procedures.tab.proceeding"
|
||||
| "procedures.tab.search"
|
||||
| "procedures.tab.wizard"
|
||||
| "procedures.timelines.court_set"
|
||||
| "procedures.timelines.empty"
|
||||
| "procedures.timelines.error"
|
||||
| "procedures.timelines.loading"
|
||||
| "procedures.timelines.options"
|
||||
| "procedures.title"
|
||||
| "procedures.zoom.breadcrumb"
|
||||
| "procedures.zoom.hidden"
|
||||
| "project.instance_level.appeal"
|
||||
| "project.instance_level.cassation"
|
||||
| "project.instance_level.first"
|
||||
@@ -2794,9 +2848,6 @@ export type I18nKey =
|
||||
| "theme.toggle.cycle.light"
|
||||
| "theme.toggle.dark"
|
||||
| "theme.toggle.light"
|
||||
| "tools.verfahrensablauf.heading"
|
||||
| "tools.verfahrensablauf.subtitle"
|
||||
| "tools.verfahrensablauf.title"
|
||||
| "unit_role.attorney"
|
||||
| "unit_role.lead"
|
||||
| "unit_role.pa"
|
||||
|
||||
@@ -74,7 +74,7 @@ export function renderIndex(): string {
|
||||
<p data-i18n="index.cost.desc">Schätzung der Verfahrenskosten für DE-Gerichte, UPC und EPA-Verfahren. Gerichts- und Anwaltskosten auf einen Blick.</p>
|
||||
</a>
|
||||
|
||||
<a href="/tools/fristenrechner" className="card card-link">
|
||||
<a href="/tools/procedures" className="card card-link">
|
||||
<div className="card-icon" dangerouslySetInnerHTML={{ __html: ICON_CLOCK }} />
|
||||
<h2 data-i18n="index.deadline.title">Fristenrechner</h2>
|
||||
<p data-i18n="index.deadline.desc">Berechnung von Verfahrensfristen für UPC-, deutsche und EPA-Verfahren mit Feiertags-Anpassung.</p>
|
||||
|
||||
134
frontend/src/procedures.tsx
Normal file
134
frontend/src/procedures.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// /tools/procedures — workflow-tracker shell (m/paliad#152 T1,
|
||||
// docs/design-procedures-workflow-tracker-2026-05-27.md §9 T1).
|
||||
//
|
||||
// Single canonical shape:
|
||||
// 1. Sticky find header — search input + forum / Verfahren / Partei
|
||||
// pill rows + global Stichtag. The header narrows the timeline
|
||||
// set rendered below; it is not itself a tab strip.
|
||||
// 2. Timeline body — one card per matched proceeding, rendered as a
|
||||
// chained tree by parent_id with priority-styled bullets. Cold
|
||||
// open renders the 6 curated default proceedings from
|
||||
// design §8 / §11.Q4.
|
||||
//
|
||||
// Each later slice layers on top of this shell:
|
||||
// T2 — anchor pin + zoom + multi-proceeding scope (§6.5).
|
||||
// T3 — Akte landing + actuals overlay.
|
||||
// T4 — appeal-target chip group + court-set choices + per-proceeding
|
||||
// "Alle Optionen" toggle.
|
||||
// T5 — dead-code removal (the old per-tab fristenrechner-mode-*,
|
||||
// fristenrechner-wizard, fristenrechner-result, verfahrensablauf
|
||||
// modules + their CSS once nothing imports them).
|
||||
//
|
||||
// No DB dependency — the page itself is static HTML; data flows over
|
||||
// the existing /api/tools/fristenrechner endpoints. The 4 entry-mode
|
||||
// tabs the catalog (U0-U4) shipped earlier today are deleted in this
|
||||
// PR per m's Q7 divergent pick (direct replace, no flag).
|
||||
|
||||
export function renderProcedures(): string {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="procedures.title">Verfahren & Fristen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-procedures">
|
||||
<Sidebar currentPath="/tools/procedures" />
|
||||
<BottomNav currentPath="/tools/procedures" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="procedures.heading">Verfahren & Fristen</h1>
|
||||
<p className="tool-subtitle" data-i18n="procedures.subtitle">
|
||||
Verfahrensabläufe als Zeitstrahl — suchen, filtern, Verzweigungen wählen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Find affordance (design §2). Sticky header — search,
|
||||
forum + Verfahren + Partei pills, and the global
|
||||
Stichtag (date input). Pills hydrate from
|
||||
procedures-tracker on boot; markup carries the host
|
||||
rows only. */}
|
||||
<section className="tracker-find" aria-label="Filter" id="tracker-find">
|
||||
<div className="tracker-find-search">
|
||||
<svg className="tracker-find-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="tracker-search-input"
|
||||
className="tracker-find-search-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-i18n-placeholder="procedures.filter.search.placeholder"
|
||||
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing…"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="tracker-find-row" data-axis="forum">
|
||||
<span className="tracker-find-axis-label" data-i18n="procedures.filter.axis.forum">Forum:</span>
|
||||
<div className="tracker-find-pills" id="tracker-pills-forum" role="group"></div>
|
||||
</div>
|
||||
|
||||
<div className="tracker-find-row" data-axis="proc">
|
||||
<span className="tracker-find-axis-label" data-i18n="procedures.filter.axis.proc">Verfahren:</span>
|
||||
<div className="tracker-find-pills" id="tracker-pills-proc" role="group"></div>
|
||||
</div>
|
||||
|
||||
<div className="tracker-find-row" data-axis="party">
|
||||
<span className="tracker-find-axis-label" data-i18n="procedures.filter.axis.party">Partei:</span>
|
||||
<div className="tracker-find-pills" id="tracker-pills-party" role="group"></div>
|
||||
</div>
|
||||
|
||||
<div className="tracker-find-row" data-axis="date">
|
||||
<label htmlFor="tracker-trigger-date" className="tracker-find-axis-label"
|
||||
data-i18n="procedures.filter.axis.date">Stichtag:</label>
|
||||
<input
|
||||
type="date"
|
||||
id="tracker-trigger-date"
|
||||
className="tracker-find-date-input"
|
||||
value={today}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="tracker-find-summary" id="tracker-find-summary" aria-live="polite"></div>
|
||||
</section>
|
||||
|
||||
{/* Timeline body — one card per matched proceeding. Cards
|
||||
are appended by procedures-tracker.ts on boot and
|
||||
re-rendered when the find header changes. */}
|
||||
<section className="tracker-timelines" id="tracker-timelines" aria-label="Verfahren">
|
||||
<div className="tracker-timelines-placeholder" id="tracker-timelines-placeholder"
|
||||
data-i18n="procedures.timelines.loading">
|
||||
Verfahren werden geladen…
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/procedures.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -3352,10 +3352,10 @@ input[type="range"]::-moz-range-thumb {
|
||||
.timeline-context-note {
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.7rem 0.9rem;
|
||||
background: rgba(198, 244, 28, 0.10);
|
||||
border-left: 3px solid var(--brand-lime, #c6f41c);
|
||||
background: var(--color-bg-lime-tint);
|
||||
border-left: 3px solid var(--color-accent);
|
||||
border-radius: 4px;
|
||||
color: var(--color-text, #222);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
white-space: pre-line;
|
||||
@@ -3480,8 +3480,8 @@ input[type="range"]::-moz-range-thumb {
|
||||
}
|
||||
|
||||
.timeline-date--overridden {
|
||||
color: var(--color-accent-fg, #4d7c0f);
|
||||
border-bottom: 1px solid var(--color-accent, #c6f41c);
|
||||
color: var(--color-accent-fg);
|
||||
border-bottom: 1px solid var(--color-accent);
|
||||
}
|
||||
|
||||
.frist-date-edit-input {
|
||||
@@ -3489,7 +3489,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border: 1px solid var(--color-accent, #c6f41c);
|
||||
border: 1px solid var(--color-accent);
|
||||
border-radius: 3px;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
@@ -3571,7 +3571,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-left: 0.4rem;
|
||||
border: 1px solid var(--color-border, #d4d4d4);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
@@ -3582,7 +3582,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
|
||||
.event-card-choices-caret:hover,
|
||||
.event-card-choices-caret:focus-visible {
|
||||
background: var(--color-accent-bg, rgba(198, 244, 28, 0.18));
|
||||
background: var(--color-bg-lime-tint);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
@@ -3601,12 +3601,12 @@ input[type="range"]::-moz-range-thumb {
|
||||
font-weight: 500;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 99px;
|
||||
background: var(--color-accent-bg, rgba(198, 244, 28, 0.22));
|
||||
background: var(--color-accent-soft-bg);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.event-card-choices-chip-part--skipped {
|
||||
background: var(--color-bg-soft, #f1f1f1);
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
@@ -3624,7 +3624,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
.timeline-item--hidden .timeline-content,
|
||||
.fr-col-item--hidden {
|
||||
opacity: 0.55;
|
||||
border: 1px dotted var(--color-border, #d4d4d4);
|
||||
border: 1px dotted var(--color-border);
|
||||
border-radius: 6px;
|
||||
padding: 0.3rem 0.5rem;
|
||||
}
|
||||
@@ -3652,8 +3652,8 @@ input[type="range"]::-moz-range-thumb {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-accent, #c6f41c);
|
||||
background: var(--color-accent, #c6f41c);
|
||||
border: 1px solid var(--color-accent);
|
||||
background: var(--color-accent);
|
||||
/* Match the active-option pin (lime fg → midnight text) so the
|
||||
button reads against the lime in both light and dark themes
|
||||
(m/paliad#123). */
|
||||
@@ -3664,7 +3664,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
|
||||
.event-card-choices-unhide-btn:hover,
|
||||
.event-card-choices-unhide-btn:focus-visible {
|
||||
background: var(--color-bg, #fff);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text);
|
||||
outline: none;
|
||||
}
|
||||
@@ -3686,10 +3686,10 @@ input[type="range"]::-moz-range-thumb {
|
||||
resolves. */
|
||||
.timeline-item--conditional .timeline-content,
|
||||
.fr-col-item--conditional {
|
||||
border: 1px dashed var(--color-border, #d4d4d4);
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 4px;
|
||||
padding: 0.35rem 0.55rem;
|
||||
background: var(--color-bg-soft, #fafafa);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.timeline-item--conditional .timeline-name,
|
||||
@@ -3704,12 +3704,75 @@ input[type="range"]::-moz-range-thumb {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* m/paliad#149 Phase 2 P3 — detail-mode unselected card styling +
|
||||
per-rule Aufnehmen / Entfernen chip.
|
||||
|
||||
Unselected cards (priority='optional' or 'recommended' that the user
|
||||
has chosen NOT to include in the active scenario, surfaced only in
|
||||
"Alle Optionen" view-mode) render with a dotted border + muted fill,
|
||||
mirroring the conditional-row treatment. The card stays clickable so
|
||||
the user can still inspect notes / dates, but it visually reads as
|
||||
"not part of this scenario right now".
|
||||
|
||||
The chip itself is intentionally small and unobtrusive — Aufnehmen
|
||||
is a lime-tinted call-to-action; Entfernen is a discreet muted chip. */
|
||||
.timeline-item-header--unselected,
|
||||
.timeline-item--detail-unselected .timeline-content,
|
||||
.fr-col-item--detail-unselected {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.timeline-item--detail-unselected .timeline-content,
|
||||
.fr-col-item--detail-unselected {
|
||||
border: 1px dashed var(--color-border, #d4d4d4);
|
||||
border-radius: 4px;
|
||||
padding: 0.35rem 0.55rem;
|
||||
background: var(--color-bg-soft, #fafafa);
|
||||
}
|
||||
|
||||
.timeline-selection-chip {
|
||||
appearance: none;
|
||||
border: 1px solid var(--color-border, #d4d4d4);
|
||||
background: var(--color-bg, #fff);
|
||||
color: var(--color-text);
|
||||
font-size: 0.72rem;
|
||||
line-height: 1.2;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
margin-left: 0.4rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.timeline-selection-chip:hover {
|
||||
background: var(--color-bg-soft, #f4f4f4);
|
||||
}
|
||||
.timeline-selection-chip--add {
|
||||
/* Aufnehmen — pulls the rule into the active scenario. Uses the
|
||||
Paliad accent (lime) so the call-to-action is visually distinct
|
||||
from the muted card around it. */
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
background: var(--color-accent-soft-bg, #f0fcd1);
|
||||
color: var(--color-accent-soft-fg, #4d6b00);
|
||||
font-weight: 600;
|
||||
}
|
||||
.timeline-selection-chip--add::before {
|
||||
content: "+ ";
|
||||
}
|
||||
.timeline-selection-chip--remove {
|
||||
/* Entfernen — discreet chip on selected optional rows. The user can
|
||||
restore the priority default by clicking; mandatory rules never
|
||||
expose this chip. */
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.timeline-selection-chip--remove::before {
|
||||
content: "× ";
|
||||
}
|
||||
|
||||
|
||||
.event-card-choices-popover {
|
||||
background: var(--color-bg, #fff);
|
||||
border: 1px solid var(--color-border, #d4d4d4);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
|
||||
box-shadow: var(--shadow-md);
|
||||
padding: 0.6rem 0.7rem;
|
||||
min-width: 240px;
|
||||
max-width: 320px;
|
||||
@@ -3718,7 +3781,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
.event-card-choices-block + .event-card-choices-block {
|
||||
margin-top: 0.7rem;
|
||||
padding-top: 0.6rem;
|
||||
border-top: 1px solid var(--color-border-soft, #ececec);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.event-card-choices-title {
|
||||
@@ -3738,20 +3801,20 @@ input[type="range"]::-moz-range-thumb {
|
||||
font-size: 0.78rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--color-border, #d4d4d4);
|
||||
background: var(--color-bg, #fff);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.event-card-choices-option:hover,
|
||||
.event-card-choices-option:focus-visible {
|
||||
background: var(--color-bg-soft, #f1f1f1);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.event-card-choices-option--active {
|
||||
background: var(--color-accent, #c6f41c);
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
/* Foreground stays midnight in both themes — --color-text would flip
|
||||
to cream in dark mode and leave the active "Berufung durch …"
|
||||
chip unreadable on lime (m/paliad#123). */
|
||||
@@ -3777,7 +3840,7 @@ input[type="range"]::-moz-range-thumb {
|
||||
.event-card-choices-error {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.74rem;
|
||||
color: var(--status-red-fg, #b00020);
|
||||
color: var(--status-red-fg);
|
||||
}
|
||||
|
||||
.timeline-rule {
|
||||
@@ -19126,6 +19189,35 @@ dialog.quick-add-sheet::backdrop {
|
||||
color: var(--status-amber-fg-2);
|
||||
}
|
||||
|
||||
/* Cross-party badge — m/paliad#149 Phase 2 S1 (design §2.4). Subtle
|
||||
grey treatment so the row reads as "Gegenseitig, you are seeing this
|
||||
for awareness only" rather than competing with the spawn/condition
|
||||
badges visually. The full row gets is-cross-party muting (below). */
|
||||
.fristen-overhaul-rule-crossparty {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.05rem 0.45rem;
|
||||
border-radius: 0.35rem;
|
||||
background: var(--color-surface-2, var(--color-bg-muted, #eee));
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Cross-party rows render muted to signal "the workflow continues but
|
||||
on the other docket — informational, not actionable for our side". */
|
||||
.fristen-overhaul-rule.is-cross-party {
|
||||
opacity: 0.65;
|
||||
}
|
||||
.fristen-overhaul-rule.is-cross-party .fristen-overhaul-rule-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
.fristen-overhaul-rule.is-cross-party .fristen-overhaul-rule-check input[type="checkbox"] {
|
||||
/* Per design §2.4: cross-party rows are excluded from write-back
|
||||
unconditionally. Disabling the checkbox communicates that the row
|
||||
cannot be added to the Akte even if the user tries. */
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.fristen-overhaul-rule-meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -19719,3 +19811,594 @@ a.fristen-overhaul-rule-source {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================================
|
||||
* Workflow tracker — /tools/procedures (m/paliad#152 T1+).
|
||||
*
|
||||
* Single canonical shape: sticky find header + per-proceeding cards
|
||||
* rendered as chained trees by parent_id. See
|
||||
* docs/design-procedures-workflow-tracker-2026-05-27.md.
|
||||
* T1 ships the shell + flat fork checkboxes; T2-T4 layer anchor pin /
|
||||
* Akte / appeal-target on top.
|
||||
* ========================================================================= */
|
||||
|
||||
.tracker-find {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
background: var(--color-bg);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 0.75rem 0 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tracker-find-search {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tracker-find-search-icon {
|
||||
position: absolute;
|
||||
left: 0.6rem;
|
||||
color: var(--color-text-subtle);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tracker-find-search-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0.75rem 0.5rem 2.2rem;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: 6px;
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.tracker-find-search-input:focus {
|
||||
outline: 2px solid var(--color-accent);
|
||||
outline-offset: -1px;
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.tracker-find-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tracker-find-axis-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
min-width: 5rem;
|
||||
}
|
||||
|
||||
.tracker-find-pills {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tracker-pill {
|
||||
padding: 0.25rem 0.6rem;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s, border-color 0.1s;
|
||||
}
|
||||
|
||||
.tracker-pill:hover {
|
||||
background: var(--color-bg-lime-tint);
|
||||
border-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.tracker-pill.is-active {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-fg);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tracker-find-date-input {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border: 1px solid var(--color-border-strong);
|
||||
border-radius: 4px;
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.tracker-find-summary {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
min-height: 1.2rem;
|
||||
}
|
||||
|
||||
.tracker-timelines {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tracker-timelines-placeholder,
|
||||
.tracker-timelines-empty,
|
||||
.tracker-cold-open-hint {
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
font-size: 0.9rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tracker-cold-open-hint {
|
||||
background: var(--color-bg-lime-tint);
|
||||
border: 1px dashed var(--color-accent);
|
||||
border-radius: 6px;
|
||||
font-style: normal;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* ─── per-proceeding card ───────────────────────────────────────── */
|
||||
|
||||
.tracker-proceeding {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-bg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tracker-proceeding-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
padding: 0.6rem 0.9rem;
|
||||
background: var(--color-bg-subtle);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tracker-proceeding-jur {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.4rem;
|
||||
background: var(--color-text);
|
||||
color: var(--color-bg);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.05em;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.tracker-proceeding-name {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tracker-proceeding-code {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-subtle);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.tracker-proceeding-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.tracker-proceeding-options-label {
|
||||
font-weight: 500;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.tracker-proceeding-option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tracker-proceeding-option input[type="checkbox"] {
|
||||
margin: 0;
|
||||
accent-color: var(--color-accent);
|
||||
}
|
||||
|
||||
.tracker-proceeding-body {
|
||||
padding: 0.75rem 0.9rem;
|
||||
}
|
||||
|
||||
.tracker-proceeding-loading,
|
||||
.tracker-proceeding-empty,
|
||||
.tracker-proceeding-error {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-subtle);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.tracker-proceeding-error {
|
||||
color: var(--color-text);
|
||||
background: rgba(245, 101, 101, 0.08);
|
||||
border-left: 3px solid #ef4444;
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
/* ─── tree nodes ────────────────────────────────────────────────── */
|
||||
|
||||
.tracker-tree {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.tracker-tree-root {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tracker-tree .tracker-tree {
|
||||
padding-left: 1.4rem;
|
||||
position: relative;
|
||||
border-left: 1px dashed var(--color-border-strong);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.tracker-node {
|
||||
position: relative;
|
||||
padding: 0.3rem 0;
|
||||
}
|
||||
|
||||
.tracker-node-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
padding: 0.25rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.3;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.tracker-node:hover > .tracker-node-line {
|
||||
background: var(--color-bg-lime-tint);
|
||||
}
|
||||
|
||||
.tracker-node-bullet {
|
||||
display: inline-block;
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-text);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tracker-node--mandatory > .tracker-node-line .tracker-node-bullet {
|
||||
background: var(--color-text);
|
||||
}
|
||||
|
||||
.tracker-node--recommended > .tracker-node-line .tracker-node-bullet {
|
||||
background: var(--color-text);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tracker-node--optional > .tracker-node-line .tracker-node-bullet {
|
||||
background: transparent;
|
||||
border: 1.5px dotted var(--color-text-muted);
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
}
|
||||
|
||||
.tracker-node--informational > .tracker-node-line .tracker-node-bullet {
|
||||
background: var(--color-text-subtle);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.tracker-node--court > .tracker-node-line .tracker-node-bullet {
|
||||
background: #5b8def;
|
||||
}
|
||||
|
||||
.tracker-node-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.tracker-node-ref {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.tracker-node-date {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.tracker-node-party {
|
||||
display: inline-block;
|
||||
min-width: 1.4rem;
|
||||
text-align: center;
|
||||
padding: 0.05rem 0.35rem;
|
||||
border-radius: 3px;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.tracker-node-party--court {
|
||||
background: rgba(91, 141, 239, 0.12);
|
||||
color: #2c5fb8;
|
||||
border-color: rgba(91, 141, 239, 0.3);
|
||||
}
|
||||
|
||||
.tracker-node-party--claimant {
|
||||
background: rgba(34, 197, 94, 0.10);
|
||||
color: #1f7a4a;
|
||||
border-color: rgba(34, 197, 94, 0.25);
|
||||
}
|
||||
|
||||
.tracker-node-party--defendant {
|
||||
background: rgba(245, 158, 11, 0.10);
|
||||
color: #92500a;
|
||||
border-color: rgba(245, 158, 11, 0.25);
|
||||
}
|
||||
|
||||
.tracker-node--anchored > .tracker-node-line {
|
||||
background: var(--color-bg-lime-tint);
|
||||
border-left: 4px solid var(--color-accent);
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.tracker-node-here {
|
||||
display: block;
|
||||
margin: 0.2rem 0 0.2rem 0.4rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-accent-dark);
|
||||
background: var(--color-accent);
|
||||
border-radius: 3px;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.tracker-node-pin,
|
||||
.tracker-node-fokus {
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.1rem 0.3rem;
|
||||
border-radius: 3px;
|
||||
opacity: 0.35;
|
||||
transition: opacity 0.1s, background 0.1s;
|
||||
}
|
||||
|
||||
.tracker-node:hover > .tracker-node-line .tracker-node-pin,
|
||||
.tracker-node--anchored > .tracker-node-line .tracker-node-pin,
|
||||
.tracker-node-fokus {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.tracker-node-pin:hover,
|
||||
.tracker-node-fokus:hover {
|
||||
background: var(--color-bg-lime-tint);
|
||||
}
|
||||
|
||||
.tracker-proceeding--anchored {
|
||||
border-color: var(--color-accent);
|
||||
box-shadow: 0 0 0 1px var(--color-accent);
|
||||
}
|
||||
|
||||
.tracker-proceeding-toggle {
|
||||
border: 1px solid var(--color-border-strong);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.8rem;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tracker-proceeding-toggle:hover {
|
||||
background: var(--color-bg-lime-tint);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Collapsed proceeding card — header-only render (§6.5). The toggle
|
||||
* shows "zeigen"; clicking expands. Body stays absent. */
|
||||
.tracker-proceeding--collapsed .tracker-proceeding-header {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* Zoom breadcrumb (§6.2). One-line ancestors trail above the focused
|
||||
* subtree. The anchor crumb at the right is highlighted lime so the
|
||||
* user sees their current depth. */
|
||||
.tracker-zoom-breadcrumb {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0.3rem 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
border-bottom: 1px dashed var(--color-border);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.tracker-zoom-crumb {
|
||||
padding: 0.05rem 0.3rem;
|
||||
}
|
||||
|
||||
.tracker-zoom-crumb--anchor {
|
||||
background: var(--color-accent);
|
||||
color: var(--color-accent-fg);
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tracker-zoom-crumb-sep {
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.tracker-zoom-hidden {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
text-align: center;
|
||||
margin-top: 0.5rem;
|
||||
padding-top: 0.3rem;
|
||||
border-top: 1px dashed var(--color-border);
|
||||
}
|
||||
|
||||
/* ─── Akte actuals overlay (§6.4 / T3) ─────────────────────────── */
|
||||
|
||||
.tracker-node-actual {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.2rem;
|
||||
height: 1.2rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 700;
|
||||
border-radius: 3px;
|
||||
padding: 0 0.2rem;
|
||||
}
|
||||
|
||||
.tracker-node-actual--done {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: #1f7a4a;
|
||||
}
|
||||
|
||||
.tracker-node-actual--overdue {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: #b91c1c;
|
||||
}
|
||||
|
||||
.tracker-node-actual--open {
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.tracker-node--actual-done > .tracker-node-line .tracker-node-name {
|
||||
text-decoration: line-through;
|
||||
text-decoration-color: rgba(31, 122, 74, 0.5);
|
||||
text-decoration-thickness: 1.5px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.tracker-node--actual-overdue > .tracker-node-line {
|
||||
background: rgba(239, 68, 68, 0.05);
|
||||
}
|
||||
|
||||
/* ─── T4 polish ────────────────────────────────────────────────── */
|
||||
|
||||
/* Per-proceeding detail-mode toggle in the card header. Compact pill
|
||||
* that flips between "· Gewählt ·" and "Alle Optionen". */
|
||||
.tracker-proceeding-detail {
|
||||
border: 1px solid var(--color-border-strong);
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.75rem;
|
||||
padding: 0.15rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tracker-proceeding-detail[aria-pressed="true"] {
|
||||
background: var(--color-bg-lime-tint);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.tracker-proceeding-detail:hover {
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
/* Appeal-target chip strip on upc.apl.unified. Mirrors the per-card
|
||||
* "Optionen" strip but lives below the header, separated by a thin
|
||||
* border so it reads as a sub-row of the proceeding card. */
|
||||
.tracker-proceeding-targets {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.4rem 0.9rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-bg);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.tracker-pill--sm {
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
/* Cross-party row treatment (§3.6). Rows where the user is not the
|
||||
* primary actor get a "Gegen." badge + muted text. The user can still
|
||||
* read what the opposing side files, but it doesn't compete with
|
||||
* their own actionable items. */
|
||||
.tracker-node-cross {
|
||||
display: inline-block;
|
||||
padding: 0.05rem 0.35rem;
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-subtle);
|
||||
font-size: 0.7rem;
|
||||
border-radius: 3px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tracker-node--cross > .tracker-node-line .tracker-node-name,
|
||||
.tracker-node--cross > .tracker-node-line .tracker-node-date,
|
||||
.tracker-node--cross > .tracker-node-line .tracker-node-ref {
|
||||
color: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
/* Unselected rows in "all_options" view (dotted muted treatment per
|
||||
* design §3.4). Same affordance for hidden-by-user rows. */
|
||||
.tracker-node--unselected > .tracker-node-line,
|
||||
.tracker-node--hidden > .tracker-node-line {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.tracker-node--unselected > .tracker-node-line .tracker-node-name {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tracker-node--highlight > .tracker-node-line {
|
||||
animation: tracker-node-flash 3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes tracker-node-flash {
|
||||
0% { background: var(--color-accent); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.tracker-find-axis-label {
|
||||
min-width: 4rem;
|
||||
}
|
||||
.tracker-node-date {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,378 +0,0 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Slice 1 (t-paliad-179) — the dedicated abstract-browse surface for
|
||||
// procedural shape. Same backend (POST /api/tools/fristenrechner) +
|
||||
// same renderer module (./client/views/verfahrensablauf-core) as
|
||||
// /tools/fristenrechner; this page strips the Step 1 Akte picker /
|
||||
// Step 2 cards / Pathway A wizard / Pathway B cascade / save modal,
|
||||
// leaving just: proceeding-type tile picker + trigger date + court
|
||||
// picker + result panel. Variant chips, lane view and compare arrive in
|
||||
// Slices 2-4.
|
||||
|
||||
interface ProceedingDef {
|
||||
code: string;
|
||||
i18nKey: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function proceedingBtn(p: ProceedingDef): string {
|
||||
return (
|
||||
<button type="button" className="proceeding-btn" data-code={p.code}>
|
||||
<strong data-i18n={p.i18nKey}>{p.name}</strong>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1): the 3 separate Berufung tiles
|
||||
// (upc.apl.merits / upc.apl.cost / upc.apl.order) collapse into ONE
|
||||
// unified "Berufung" tile (upc.apl). After picking it, the user
|
||||
// selects which decision the appeal is directed AT via the
|
||||
// .appeal-target-row chip group below — the engine then filters
|
||||
// rules whose applies_to_target contains the picked slug.
|
||||
const UPC_TYPES: ProceedingDef[] = [
|
||||
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
|
||||
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
|
||||
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
|
||||
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
|
||||
{ code: "upc.apl.unified", i18nKey: "deadlines.upc.apl.unified", name: "Berufung" },
|
||||
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
|
||||
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
|
||||
];
|
||||
|
||||
// DE proceedings split by type (Verletzung / Nichtigkeit) per m's
|
||||
// 2026-05-18 ask. Labels are parallel: <court> (<procedural role>),
|
||||
// so a user scanning the picker sees the instance-and-role at a glance
|
||||
// without one tile reading "Berufung OLG" and another "Nichtigkeits-
|
||||
// verfahren". Sub-group headers convey the type grouping. Combined-
|
||||
// timeline behaviour (LG→OLG→BGH as one calc) is filed as m/paliad#41.
|
||||
const DE_INF_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
|
||||
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
|
||||
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
|
||||
];
|
||||
|
||||
const DE_NULL_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
|
||||
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
|
||||
];
|
||||
|
||||
const EPA_TYPES: ProceedingDef[] = [
|
||||
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
|
||||
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
|
||||
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
|
||||
];
|
||||
|
||||
const DPMA_TYPES: ProceedingDef[] = [
|
||||
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
|
||||
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
|
||||
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
|
||||
];
|
||||
|
||||
export function renderVerfahrensablauf(): string {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="tools.verfahrensablauf.title">Verfahrensablauf — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-verfahrensablauf">
|
||||
<Sidebar currentPath="/tools/verfahrensablauf" />
|
||||
<BottomNav currentPath="/tools/verfahrensablauf" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="tools.verfahrensablauf.heading">Verfahrensablauf</h1>
|
||||
<p className="tool-subtitle" data-i18n="tools.verfahrensablauf.subtitle">
|
||||
Typischen Verfahrensablauf einsehen — Verfahrensart wählen, Datum optional setzen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Verfahrensart picker (single-tile mode — same DOM ids as
|
||||
/tools/fristenrechner so the shared renderer module and
|
||||
court-picker primitives bind without parameterisation). */}
|
||||
<div className="fristen-wizard" id="verfahrensablauf-wizard" data-mode="procedure">
|
||||
<div className="wizard-step" id="step-1">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">1</span>
|
||||
<span data-i18n="deadlines.step1">Verfahrensart wählen</span>
|
||||
</h3>
|
||||
|
||||
<div className="proceeding-group" data-forum="upc">
|
||||
<h4 data-i18n="deadlines.upc">UPC</h4>
|
||||
<div className="proceeding-btns">
|
||||
{UPC_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="de">
|
||||
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="epa">
|
||||
<h4 data-i18n="deadlines.epa">EPA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{EPA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="dpma">
|
||||
<h4 data-i18n="deadlines.dpma">DPMA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{DPMA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
|
||||
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
|
||||
<strong className="proceeding-summary-name" id="proceeding-summary-name">—</strong>
|
||||
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
|
||||
data-i18n="deadlines.proceeding.reselect">
|
||||
Anderes Verfahren wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-2" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">2</span>
|
||||
<span data-i18n="deadlines.step2.perspective">Perspektive und Datum</span>
|
||||
</h3>
|
||||
|
||||
{/* Perspective strip (t-paliad-250 / m/paliad#81, reordered
|
||||
in t-paliad-279 / m/paliad#111). Side defines whose
|
||||
perspective the columns project; appellant collapses
|
||||
party=both rows for role-swap proceedings (Appeal etc.).
|
||||
Moved above .date-input-group because party-side is the
|
||||
most-defining input after proceeding-type — without
|
||||
side, the column labels can't pick "your filings". Both
|
||||
selectors are URL-driven (?side= + ?appellant=) so the
|
||||
perspective survives reload and is shareable.
|
||||
|
||||
When the page is opened with ?project=<id> and that
|
||||
project's our_side is set, side-row renders as a
|
||||
read-only chip with an "Andere Seite wählen" override
|
||||
link — see client/verfahrensablauf.ts. */}
|
||||
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
|
||||
<div className="verfahrensablauf-perspective-row" id="side-row">
|
||||
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
|
||||
<div className="side-radio-cluster" id="side-radio-cluster">
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="claimant" />
|
||||
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="defendant" />
|
||||
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="" checked />
|
||||
<span data-i18n="deadlines.side.undefined">Nicht festgelegt</span>
|
||||
</label>
|
||||
</div>
|
||||
{/* Prompt shown while the user hasn't picked a side
|
||||
(m/paliad#120). Hidden by client when side is
|
||||
claimant or defendant. Both columns still
|
||||
render every rule in this state — picking a
|
||||
side just focuses the user's column. */}
|
||||
<span className="side-hint" id="side-hint"
|
||||
data-i18n="deadlines.side.hint">
|
||||
Wählen Sie eine Seite, um die Spalten zu fokussieren.
|
||||
</span>
|
||||
</div>
|
||||
{/* Auto-fill chip — populated by the client when a
|
||||
?project=<id> URL resolves a project with our_side
|
||||
set. Hidden by default; the radio cluster above is
|
||||
hidden whenever this chip is shown. */}
|
||||
<div className="side-chip" id="side-chip" style="display:none">
|
||||
<span className="side-chip-tag" data-i18n="deadlines.side.from_project">Aus Akte:</span>
|
||||
<strong className="side-chip-value" id="side-chip-value">—</strong>
|
||||
<button type="button" className="side-chip-override" id="side-chip-override"
|
||||
data-i18n="deadlines.side.override">
|
||||
Andere Seite wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Appeal-target chip row (Slice B1 / m/paliad#124 §18.1).
|
||||
Shown only when the unified upc.apl Berufung tile is
|
||||
selected; lets the user narrow the timeline to the
|
||||
rules whose applies_to_target contains the picked
|
||||
decision kind. URL state ?target=<slug>. */}
|
||||
<div className="verfahrensablauf-perspective-row" id="appeal-target-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appeal_target.label">Worauf richtet sich die Berufung?</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appeal target">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="endentscheidung" checked />
|
||||
<span data-i18n="deadlines.appeal_target.endentscheidung">Endentscheidung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="kostenentscheidung" />
|
||||
<span data-i18n="deadlines.appeal_target.kostenentscheidung">Kostenentscheidung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="anordnung" />
|
||||
<span data-i18n="deadlines.appeal_target.anordnung">Anordnung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="schadensbemessung" />
|
||||
<span data-i18n="deadlines.appeal_target.schadensbemessung">Schadensbemessung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="bucheinsicht" />
|
||||
<span data-i18n="deadlines.appeal_target.bucheinsicht">Bucheinsicht</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/* Show-hidden toggle (t-paliad-290 / m/paliad#122).
|
||||
Re-surfaces optional cards the user has previously
|
||||
marked "Überspringen" via the per-card popover.
|
||||
The row hides itself when the projection has no
|
||||
hidden cards (handled in client/verfahrensablauf.ts).
|
||||
Default OFF; URL state ?show_hidden=1. */}
|
||||
<div className="verfahrensablauf-perspective-row" id="show-hidden-row" style="display:none">
|
||||
<label className="fristen-view-option">
|
||||
<input type="checkbox" id="show-hidden-toggle" />
|
||||
<span data-i18n="choices.show_hidden.label">Ausgeblendete anzeigen</span>
|
||||
</label>
|
||||
<span className="show-hidden-count" id="show-hidden-count" aria-live="polite"> </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual divider — keeps the perspective block (most-
|
||||
defining inputs after proceeding-type) optically
|
||||
separate from the date / court / flag knobs below. */}
|
||||
<div className="verfahrensablauf-step2-divider" aria-hidden="true"></div>
|
||||
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
{/* Read-only caption labelling the value <span>. Not a
|
||||
<label htmlFor> — m/paliad#60: <label for=…> must
|
||||
point at a labelable form control, never a span. */}
|
||||
<span className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</span>
|
||||
<span id="trigger-event" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
|
||||
<input type="date" id="trigger-date" className="date-input" value={today} />
|
||||
</div>
|
||||
<div className="date-field-row" id="court-picker-row" style="display:none">
|
||||
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
|
||||
<select id="court-picker" className="date-input"></select>
|
||||
</div>
|
||||
{/* Proceeding-specific flag rows — mirror /tools/fristenrechner
|
||||
so an abstract-browse user can model the same variants
|
||||
(CCR, Patentänderung, Verletzungswiderklage,
|
||||
Vorab-Einrede). Show/hide driven by selectedType in
|
||||
the client. */}
|
||||
<div className="date-field-row" id="ccr-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="ccr-flag" />
|
||||
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row date-field-row--nested" id="inf-amend-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="inf-amend-flag" />
|
||||
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patentänderung (R.30)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row" id="rev-amend-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="rev-amend-flag" />
|
||||
<span data-i18n="deadlines.flag.rev_amend">Mit Antrag auf Patentänderung (R.49.2.a)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row" id="rev-cci-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="rev-cci-flag" />
|
||||
<span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
|
||||
Fristen berechnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-3" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">3</span>
|
||||
<span data-i18n="deadlines.step3">Ergebnis</span>
|
||||
</h3>
|
||||
|
||||
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
|
||||
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="columns" checked />
|
||||
<span data-i18n="deadlines.view.columns">Spalten</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="fristen-notes-show" />
|
||||
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
|
||||
</label>
|
||||
{/* Durations toggle (m/paliad#133, t-paliad-302).
|
||||
Default off — hover-tooltips on date spans are
|
||||
the always-on path. */}
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="verfahrensablauf-durations-show" />
|
||||
<span data-i18n="deadlines.durations.show">Dauern anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
</div>
|
||||
|
||||
<div className="fristen-result-actions">
|
||||
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
|
||||
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 6 2 18 2 18 9"></polyline>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
|
||||
<rect x="6" y="14" width="12" height="8"></rect>
|
||||
</svg>
|
||||
<span data-i18n="deadlines.print">Drucken</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/verfahrensablauf.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
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;
|
||||
@@ -12,28 +12,41 @@ import (
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// Fristenrechner page handler: serves the static HTML. No DB dependency.
|
||||
//
|
||||
// Back-compat: the pre-split sidebar entry for "Verfahrensablauf" pointed at
|
||||
// /tools/fristenrechner?path=a. After the t-paliad-179 split, that landing is
|
||||
// owned by /tools/verfahrensablauf. A naked ?path=a (no Akte context — i.e.
|
||||
// no ?project=) is the bookmarked-legacy-entry case → 302 to the new route.
|
||||
// ?project=<uuid>&path=a is the Akte-mode internal wizard pathway and stays
|
||||
// on /tools/fristenrechner so the wizard state survives a refresh.
|
||||
func handleFristenrechnerPage(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
if q.Get("path") == "a" && q.Get("project") == "" {
|
||||
http.Redirect(w, r, "/tools/verfahrensablauf", http.StatusFound)
|
||||
return
|
||||
// U4 (m/paliad#151) — legacy /tools/fristenrechner and
|
||||
// /tools/verfahrensablauf folded into /tools/procedures via hard 301
|
||||
// redirects. Per m's Q11 divergence in the design (no 2-week dual-ship
|
||||
// window), bookmarks resolve via Location preservation of query params;
|
||||
// no `?legacy=1` escape, no in-product affordance points back at the
|
||||
// retired URLs after the merge.
|
||||
|
||||
func redirectToProcedures(w http.ResponseWriter, r *http.Request) {
|
||||
loc := "/tools/procedures"
|
||||
if raw := r.URL.RawQuery; raw != "" {
|
||||
loc += "?" + raw
|
||||
}
|
||||
http.ServeFile(w, r, "dist/fristenrechner.html")
|
||||
http.Redirect(w, r, loc, http.StatusMovedPermanently)
|
||||
}
|
||||
|
||||
// Verfahrensablauf page handler (t-paliad-179 Slice 1): the dedicated
|
||||
// abstract-browse surface for procedural shape. No DB dependency — the page
|
||||
// shell is static HTML; the calculator API still drives the timeline render.
|
||||
// handleFristenrechnerPage — kept as a registration name for the legacy
|
||||
// URL so bookmarks (and the existing Sidebar history a former user may
|
||||
// have cached) keep resolving. 301s to /tools/procedures.
|
||||
func handleFristenrechnerPage(w http.ResponseWriter, r *http.Request) {
|
||||
redirectToProcedures(w, r)
|
||||
}
|
||||
|
||||
// handleVerfahrensablaufPage — symmetrical 301 to /tools/procedures.
|
||||
func handleVerfahrensablaufPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/verfahrensablauf.html")
|
||||
redirectToProcedures(w, r)
|
||||
}
|
||||
|
||||
// Unified procedural-events tool page (m/paliad#151, design
|
||||
// docs/design-unified-procedural-events-tool-2026-05-27.md). Consolidates
|
||||
// Fristenrechner Mode A + Mode B + result + Verfahrensablauf into a
|
||||
// single surface at /tools/procedures. No DB dependency — the page
|
||||
// itself is static HTML; per-tab data flows over the existing
|
||||
// /api/tools/fristenrechner/* endpoints.
|
||||
func handleProceduresPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/procedures.html")
|
||||
}
|
||||
|
||||
// POST /api/tools/fristenrechner — calculate the UI timeline for a proceeding.
|
||||
@@ -253,7 +266,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).",
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -299,6 +305,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/tools/kostenrechner", handleKostenrechnerAPI)
|
||||
protected.HandleFunc("GET /tools/fristenrechner", handleFristenrechnerPage)
|
||||
protected.HandleFunc("GET /tools/verfahrensablauf", handleVerfahrensablaufPage)
|
||||
protected.HandleFunc("GET /tools/procedures", handleProceduresPage)
|
||||
protected.HandleFunc("POST /api/tools/fristenrechner", handleFristenrechnerAPI)
|
||||
protected.HandleFunc("POST /api/tools/fristenrechner/calculate-rule", handleFristenrechnerCalculateRule)
|
||||
protected.HandleFunc("GET /api/tools/proceeding-types", handleProceedingTypes)
|
||||
@@ -382,6 +389,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)
|
||||
}
|
||||
@@ -6,78 +6,54 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// /tools/fristenrechner?path=a was the pre-split sidebar entry for the
|
||||
// "Verfahrensablauf" surface. After t-paliad-179 Slice 1 that intent
|
||||
// owns its own /tools/verfahrensablauf route — so a naked ?path=a hit
|
||||
// must 302 to the new URL to preserve bookmarked legacy links.
|
||||
//
|
||||
// The Akte-mode internal wizard pathway (?project=<uuid>&path=a) is
|
||||
// NOT a top-level entry — it's wizard state set by client-side
|
||||
// history.replaceState. That URL must keep serving the fristenrechner
|
||||
// shell so a mid-wizard refresh doesn't bounce away.
|
||||
func TestHandleFristenrechnerPage_LegacyPathARedirect(t *testing.T) {
|
||||
// U4 (m/paliad#151) — both legacy URLs hard-cut to /tools/procedures
|
||||
// with HTTP 301. Query strings carry through so bookmarks like
|
||||
// /tools/fristenrechner?event=upc.inf.cfi.soc&trigger_date=2026-04-01
|
||||
// resolve to /tools/procedures?event=…&trigger_date=… without losing
|
||||
// the user's intent.
|
||||
func TestLegacyToolsPagesRedirect(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
wantStatus int
|
||||
wantLoc string
|
||||
name string
|
||||
path string
|
||||
handler func(http.ResponseWriter, *http.Request)
|
||||
wantLoc string
|
||||
}{
|
||||
{
|
||||
name: "naked path=a → redirect",
|
||||
path: "/tools/fristenrechner?path=a",
|
||||
wantStatus: http.StatusFound,
|
||||
wantLoc: "/tools/verfahrensablauf",
|
||||
name: "fristenrechner naked",
|
||||
path: "/tools/fristenrechner",
|
||||
handler: handleFristenrechnerPage,
|
||||
wantLoc: "/tools/procedures",
|
||||
},
|
||||
{
|
||||
name: "path=a with project= → no redirect (Akte-mode wizard)",
|
||||
path: "/tools/fristenrechner?project=abc-123&path=a",
|
||||
wantStatus: http.StatusOK,
|
||||
name: "fristenrechner with query",
|
||||
path: "/tools/fristenrechner?event=upc.inf.cfi.soc&trigger_date=2026-04-01",
|
||||
handler: handleFristenrechnerPage,
|
||||
wantLoc: "/tools/procedures?event=upc.inf.cfi.soc&trigger_date=2026-04-01",
|
||||
},
|
||||
{
|
||||
name: "no path param → no redirect",
|
||||
path: "/tools/fristenrechner",
|
||||
wantStatus: http.StatusOK,
|
||||
name: "verfahrensablauf naked",
|
||||
path: "/tools/verfahrensablauf",
|
||||
handler: handleVerfahrensablaufPage,
|
||||
wantLoc: "/tools/procedures",
|
||||
},
|
||||
{
|
||||
name: "path=b → no redirect (Pathway B stays)",
|
||||
path: "/tools/fristenrechner?path=b",
|
||||
wantStatus: http.StatusOK,
|
||||
name: "verfahrensablauf with proceeding",
|
||||
path: "/tools/verfahrensablauf?proceeding=upc.inf.cfi&side=claimant",
|
||||
handler: handleVerfahrensablaufPage,
|
||||
wantLoc: "/tools/procedures?proceeding=upc.inf.cfi&side=claimant",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleFristenrechnerPage(w, req)
|
||||
if w.Code != tc.wantStatus {
|
||||
// http.ServeFile may write 404 if dist/fristenrechner.html
|
||||
// is missing under `go test` (CI runs without a frontend
|
||||
// build). We only care that we did NOT redirect in those
|
||||
// cases — collapse 200 and 404 into "not a redirect".
|
||||
if tc.wantStatus == http.StatusOK && w.Code != http.StatusFound {
|
||||
return
|
||||
}
|
||||
t.Fatalf("status = %d, want %d", w.Code, tc.wantStatus)
|
||||
tc.handler(w, req)
|
||||
if w.Code != http.StatusMovedPermanently {
|
||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusMovedPermanently)
|
||||
}
|
||||
if tc.wantLoc != "" {
|
||||
if got := w.Header().Get("Location"); got != tc.wantLoc {
|
||||
t.Fatalf("Location = %q, want %q", got, tc.wantLoc)
|
||||
}
|
||||
if got := w.Header().Get("Location"); got != tc.wantLoc {
|
||||
t.Fatalf("Location = %q, want %q", got, tc.wantLoc)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The new /tools/verfahrensablauf route registers as a 1-liner page
|
||||
// handler that ServeFiles dist/verfahrensablauf.html. We assert the
|
||||
// handler does NOT redirect — if the dist artefact is missing under
|
||||
// `go test`, ServeFile may return 404, but it must never return a 3xx.
|
||||
func TestHandleVerfahrensablaufPage_NoRedirect(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/tools/verfahrensablauf", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleVerfahrensablaufPage(w, req)
|
||||
if w.Code >= 300 && w.Code < 400 {
|
||||
t.Fatalf("verfahrensablauf must not redirect; got %d → %s",
|
||||
w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
@@ -853,19 +853,24 @@ func buildPill(p pillRow) Pill {
|
||||
}
|
||||
|
||||
func pillDrillURL(p pillRow) string {
|
||||
// m/paliad#151 U4 — drill-in URLs target /tools/procedures, the
|
||||
// unified successor to /tools/fristenrechner and
|
||||
// /tools/verfahrensablauf. The legacy URLs still 301-redirect, so
|
||||
// any cached snapshot keeps working, but new searches land on the
|
||||
// new page directly.
|
||||
switch p.Kind {
|
||||
case "rule":
|
||||
if p.ProceedingCode.Valid && p.RuleLocalCode != "" {
|
||||
return "/tools/fristenrechner?proc=" + p.ProceedingCode.String + "&focus=" + p.RuleLocalCode
|
||||
return "/tools/procedures?proc=" + p.ProceedingCode.String + "&focus=" + p.RuleLocalCode
|
||||
}
|
||||
return "/tools/fristenrechner"
|
||||
return "/tools/procedures"
|
||||
case "trigger":
|
||||
if p.TriggerEventID.Valid {
|
||||
return fmt.Sprintf("/tools/fristenrechner?mode=event&triggerId=%d", p.TriggerEventID.Int64)
|
||||
return fmt.Sprintf("/tools/procedures?mode=event&triggerId=%d", p.TriggerEventID.Int64)
|
||||
}
|
||||
return "/tools/fristenrechner?mode=event"
|
||||
return "/tools/procedures?mode=event"
|
||||
}
|
||||
return "/tools/fristenrechner"
|
||||
return "/tools/procedures"
|
||||
}
|
||||
|
||||
// pillSortKey orders pills inside a card. Rule pills before triggers;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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