Compare commits
31 Commits
mai/noethe
...
mai/lorenz
| Author | SHA1 | Date | |
|---|---|---|---|
| 32a620b788 | |||
| 9d73b91e05 | |||
| b966d7c8cd | |||
| 755a1042ff | |||
| c7fa0d6542 | |||
| 1f8230b264 | |||
| bd8ec42b80 | |||
| ec0ec32271 | |||
| 251f5a250f | |||
| 58a1abc6d8 | |||
| 7159443dcb | |||
| 119b06dcff | |||
| 1c915639b9 | |||
| 83a3d27fe0 | |||
| 79f6be3fc9 | |||
| b455df265e | |||
| 7d9935de60 | |||
| e9bcf3a7b6 | |||
| 1ad78918bc | |||
| 5e1f1fecf6 | |||
| 731e762919 | |||
| 581fbe7d92 | |||
| 8f5b83ec93 | |||
| 7c4bc39115 | |||
| adf377c2ca | |||
| 3ba5727deb | |||
| d8f7745f86 | |||
| 98a51faa66 | |||
| b24063bee1 | |||
| d1314a46f9 | |||
| 968b0bc2da |
799
docs/audit-fristen-logic-2026-05-13.md
Normal file
799
docs/audit-fristen-logic-2026-05-13.md
Normal file
@@ -0,0 +1,799 @@
|
||||
# Audit — Fristen logic (rules, triggers, conditionals)
|
||||
|
||||
**Author:** pauli (inventor)
|
||||
**Date:** 2026-05-13
|
||||
**Task:** t-paliad-157 (reactivated 2026-05-13 21:23 with broader scope)
|
||||
**Phase:** 1 of 3 — Audit. Phase 2 = iterative refinement against m. Phase 3 = ship.
|
||||
**Branch:** `mai/pauli/fristen-logic-audit` (fresh from `origin/main` @ `7d9935d`).
|
||||
**Status:** AUDIT READY FOR REVIEW — m gates the audit → Phase 2 transition.
|
||||
|
||||
m's framing (paliad/head 11:46):
|
||||
|
||||
> the main roadmap thing now is "Fristen". We need the full "Fristen logic" and I am happy to work together with an AI to further design it. Most of it should be "straightforward" as specific events trigger specific deadlines, sometimes multiple and sometimes conditional. It is all in the Rules so we should be able to manage.
|
||||
|
||||
The audit answers: **is m's mental model already encodable in the existing data model, and where are the gaps?**
|
||||
|
||||
Short answer: the rule corpus is substantially richer than the brief implied, **three parallel deadline-generation systems coexist** (with overlapping responsibilities), and the main friction is *managing* rules (SQL-only today) rather than the expressive grammar of the rules themselves.
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live (live DB + live code, not migration files)
|
||||
|
||||
Live state queried via `mcp__supabase__execute_sql` against the `paliad` schema on the youpc Supabase Postgres. Code reads against `mai/pauli/fristen-logic-audit` baseline (origin/main @ `7d9935d`).
|
||||
|
||||
### 0.1 Rule corpus is ~5× richer than the brief implied
|
||||
|
||||
| Table | Rows | Note |
|
||||
|---|---|---|
|
||||
| `paliad.proceeding_types` | **27** | 20 `category='fristenrechner'` + 7 `category='litigation'`. All 27 carry rules. |
|
||||
| `paliad.deadline_rules` | **172** | 132 against fristenrechner codes + 40 against litigation codes. |
|
||||
| `paliad.deadline_concepts` | **56** | The "noun" layer (Klageerwiderung, Berufungsschrift, …) above rules. |
|
||||
| `paliad.event_category_concepts` | **153** | Cascade-leaf → concept junction (with optional `proceeding_type_code` for context-conditional outcomes). |
|
||||
| `paliad.deadline_concept_event_types` | **32** | Concept → event_type default suggestion (per jurisdiction). |
|
||||
| `paliad.trigger_events` | **110** | youpc.org legacy import. Used by the "Was kommt nach…" mode. |
|
||||
| `paliad.event_deadlines` | **77** | trigger_event → deadline_row, with `combine_op` ∈ {min,max} for composite leads. |
|
||||
| `paliad.event_types` | **40+** | Concrete event types (upc_oral_hearing, upc_statement_of_defence, …). |
|
||||
| `paliad.event_categories` | **125** (103 leaves) | Cascade taxonomy. Already audited in t-paliad-166. |
|
||||
| `paliad.courts` | **41** | Forum picker for holiday-calendar regime resolution. |
|
||||
| `paliad.holidays` | **55** | Seed of public holidays + court closures. |
|
||||
| `paliad.deadlines` (live) | **26** | Persisted deadline instances. **Only 1 has `rule_id`.** |
|
||||
| `paliad.project_events` | **89** | Audit-log entries. |
|
||||
| `paliad.events` | **does not exist** | The brief mentioned `paliad.events`; the actual audit table is `paliad.project_events`. |
|
||||
|
||||
### 0.2 Three parallel deadline-generation systems exist today
|
||||
|
||||
| Pipeline | Data source | Calculator | Wire surface |
|
||||
|---|---|---|---|
|
||||
| **A — Proceeding-driven** | `paliad.deadline_rules` (172 rows) | `FristenrechnerService.Calculate(proceedingCode, triggerDate, opts)` (`internal/services/fristenrechner.go:139`) | POST `/api/tools/fristenrechner` (Pathway A wizard, Pathway B cascade, SmartTimeline projection via `ProjectionService.computeProjections`). |
|
||||
| **B — Single-rule (subset of A)** | Same table | `FristenrechnerService.CalculateRule` (around line 480) | POST `/api/tools/fristenrechner/calculate-rule` (Pathway B cascade card-click → inline calc). |
|
||||
| **C — Event-driven (youpc legacy)** | `paliad.trigger_events` + `paliad.event_deadlines` (separate tables) | `EventDeadlineService.Calculate(triggerEventID, triggerDate, courtID)` (`internal/services/event_deadline_service.go:92`) | POST `/api/tools/event-deadlines` (Pathway A wizard's "Was kommt nach…" trigger panel, `frontend/src/client/fristenrechner.ts:833`). |
|
||||
|
||||
Pipelines A and C have **disjoint data**, **disjoint capability sets**, and **overlapping intent**. See §2 for the full picture; §6.1 calls out the redundancy.
|
||||
|
||||
### 0.3 m's "it is all in the Rules so we should be able to manage" — the false premise
|
||||
|
||||
The rule corpus IS in one table (`paliad.deadline_rules`) — 172 rows, 32 columns, expressive. **But there is no application-level rule-management surface.** Every rule edit today is a SQL migration: `internal/db/migrations/{009,012,028,029,031,040,043,044,050,068,…}_*.up.sql`. The Calculate engine reads what's in the table, but the table is seeded by developers, not by m or any user.
|
||||
|
||||
m's "we should be able to manage" reads as a call for a first-class rule-editor in the app (see §8). That's the biggest unfilled deliverable in his framing.
|
||||
|
||||
### 0.4 Production data is sparse — demand-side largely empty
|
||||
|
||||
- **11/11 live projects have NULL `proceeding_type_id`** (per kelvin's t-paliad-178 §0 audit; re-confirmed). The projection pipeline (`projection_service.computeProjections:813`) early-returns when this is NULL, so the SmartTimeline forecast doesn't fire for any production project today.
|
||||
- **Only 1/26 live deadlines has `rule_id` populated.** The rule → deadline linkage is barely exercised. Most deadlines were created manually (free-text title + due_date) before the rule-anchored flow existed.
|
||||
- **89 project_events**: structural milestones + audit-log entries. No tight coupling to rule_ids today.
|
||||
- **trigger_events / event_deadlines** carry 110+77 youpc-legacy rows. Whether they are exercised in production needs Pathway-A "Was kommt nach…" telemetry; out of audit scope.
|
||||
|
||||
### 0.5 Anchor files
|
||||
|
||||
Backend services that consume / produce rules:
|
||||
- `internal/services/fristenrechner.go` — Pipeline A + B. The main calculator. **735 LoC.**
|
||||
- `internal/services/deadline_calculator.go` — pure date-math used by Pipeline C.
|
||||
- `internal/services/deadline_rule_service.go` — CRUD-ish read API. `List`, `GetRuleTree`, `Get`. Hydrates `ConceptDefaultEventTypeID` from `deadline_concept_event_types` for the create-form's Typ chip.
|
||||
- `internal/services/event_deadline_service.go` — Pipeline C. **~300 LoC.**
|
||||
- `internal/services/deadline_service.go` — persistence of `paliad.deadlines` instances.
|
||||
- `internal/services/event_category_service.go` — cascade leaf → concept resolution (t-paliad-133).
|
||||
- `internal/services/projection_service.go` — SmartTimeline (consumes Pipeline A).
|
||||
- `internal/services/holidays.go` + `courts.go` — non-working-day adjustment.
|
||||
|
||||
Handlers:
|
||||
- `internal/handlers/fristenrechner.go` — wires Pipelines A/B/C to HTTP routes.
|
||||
- `internal/handlers/deadlines.go` — paliad.deadlines persistence.
|
||||
- `internal/handlers/deadline_rules_db.go` — admin-style rule list endpoint (read-only).
|
||||
|
||||
Key migration history (rule corpus evolution):
|
||||
- **009** (342 LoC) — initial seed (Tier 1, hand-coded).
|
||||
- **012** (230 LoC) — Fristenrechner seed extension.
|
||||
- **028** (353 LoC) — youpc.org rules import (Pipeline C tables).
|
||||
- **029** (128 LoC) — Tier 1 rule fixes.
|
||||
- **031** (193 LoC) — Tier 2 ports (more proceedings).
|
||||
- **037 + 038** — concept layer addition.
|
||||
- **040** (449 LoC) — concept seed + backfill.
|
||||
- **043** (348 LoC) — DE_INF_OLG / DE_INF_BGH split (instance dimension).
|
||||
- **044** (280 LoC) — DPMA proceedings.
|
||||
- **048 + 049** — event_categories taxonomy (cascade).
|
||||
- **050** — `is_bilateral` backfill (4 rules).
|
||||
- **052** — Determinator ROP coverage audit fixes.
|
||||
- **068** — `is_optional` column.
|
||||
- **073 + 074** — `deadline_concept_event_types` (concept → event_type config layer).
|
||||
|
||||
Net rule-related migrations: **>20 files, >3000 LoC of SQL.** The rule corpus has accreted across many small migrations; no single canonical seed.
|
||||
|
||||
If anything in this audit conflicts with the live state, the live state wins.
|
||||
|
||||
---
|
||||
|
||||
## 1. The rule shape today — `paliad.deadline_rules` column-by-column
|
||||
|
||||
**32 columns.** Most are used; a few are vestigial. Every column verified against live row distribution.
|
||||
|
||||
### 1.1 Identity + relations
|
||||
|
||||
| Column | Type | Nullable | Role |
|
||||
|---|---|---|---|
|
||||
| `id` | uuid PK | NO | Primary key. Referenced by `paliad.deadlines.rule_id`, `paliad.deadline_rules.parent_id` (self-FK), `paliad.deadline_rules.condition_rule_id` (self-FK; unused — see §1.6). |
|
||||
| `proceeding_type_id` | int FK → `proceeding_types.id` | YES | Almost always set; NULL would mean a cross-proceeding rule but **no live rule is NULL** today. |
|
||||
| `parent_id` | uuid self-FK | YES | Rule depends on parent's calculated date as anchor. **108 / 172 rules have parent_id set** (= 63%). Forms a forest, one tree per proceeding. |
|
||||
| `concept_id` | uuid FK → `deadline_concepts.id` | YES | Links the rule to a concept (cross-proceeding noun). **171 / 172 rules linked** (= 99.4%); the one un-linked rule is a stray. |
|
||||
|
||||
### 1.2 Identity strings + labels
|
||||
|
||||
| Column | Note |
|
||||
|---|---|
|
||||
| `code` | Rule-local code (e.g. `inf.sod`, `ccr.amend`). Used by `AnchorOverrides` map keys (rule_code → date). Mostly unique within a proceeding. |
|
||||
| `name` (NOT NULL) | DE display name. |
|
||||
| `name_en` (NOT NULL, default `''`) | EN display name. Empty for some older rules; UI falls back to `name`. |
|
||||
| `description` | Optional long-form. Sparse. |
|
||||
| `rule_code` | The *legal-citation* rule code (e.g. `RoP.23.1`, `§276(1) ZPO`). The UI shows this as the `RuleRef`. NOT the rule's identity — `code` is. |
|
||||
| `legal_source` | Structured citation (e.g. `UPC.RoP.23.1`). Added by mig 038 + 040. **171/172 rules have it.** |
|
||||
| `deadline_notes` / `deadline_notes_en` | Free-text legal-context notes shown in the UI. |
|
||||
| `spawn_label` | Used with `is_spawn=true`: human label for "spawned rule" pattern. |
|
||||
|
||||
### 1.3 The math: anchor + offset + adjustment
|
||||
|
||||
| Column | Note |
|
||||
|---|---|
|
||||
| `duration_value` (NOT NULL, default 0) | Integer offset. `0` = court-set / root anchor / filed-with-parent (see §4). |
|
||||
| `duration_unit` (NOT NULL, default `months`) | Live values: `days`, `months`, `weeks`. **No `working_days`** in live data (`EventDeadlineService` supports it; `deadline_rules` does not). |
|
||||
| `timing` (default `after`) | Live value: **only `after`** in every row. `before` semantic is theoretically there but unused by Pipeline A. (Pipeline C honours `before` via `applyDuration`.) |
|
||||
| `anchor_alt` | Single live value: `priority_date`. Used by exactly **one rule**: `EP_GRANT.ep_grant.publish` (Art. 93 EPÜ, 18mo from priority). Otherwise NULL → use parent's date / triggerDate. |
|
||||
| `alt_duration_value` / `alt_duration_unit` / `alt_rule_code` | Swap-on-flag: when condition_flag is satisfied, the rule renders against the alt values instead of base. Used by UPC_INF `inf.reply` and `inf.rejoin` for the with_ccr swap (RoP.029.a / RoP.029.d). |
|
||||
|
||||
### 1.4 Conditional gating
|
||||
|
||||
| Column | Note |
|
||||
|---|---|
|
||||
| `condition_flag` | `text[]` array. **4 distinct value-sets live**: `[with_amend]`, `[with_cci]`, `[with_ccr]`, `[with_ccr, with_amend]`. Only on UPC_INF + UPC_REV (the 2 richest proceedings). Semantics: rule renders iff **every** element of the array is in caller's `Flags` set. AND semantics; **no OR/NOT today**. |
|
||||
| `condition_rule_id` | uuid self-FK to another rule. **0 / 172 rows populated**. Dead column. Was intended as "rule X applies only if rule Y was triggered" but never wired up. |
|
||||
|
||||
### 1.5 Party + bilateral
|
||||
|
||||
| Column | Note |
|
||||
|---|---|
|
||||
| `primary_party` | Live values: `claimant`, `defendant`, `both`, `court`. Drives the timeline column / row color. NULL allowed. |
|
||||
| `is_bilateral` (NOT NULL, default false) | When `primary_party='both'`, this column tells the renderer whether to **mirror the rule into both party columns** in the timeline (true), or **resolve to one side via perspective + appeal_filed_by** (false). Backfilled by mig 050 — only 4 rules carry `true`: DE_NULL r79, DE_NULL r116, EPA_OPP r79, EPA_APP r116. |
|
||||
|
||||
### 1.6 Flags + lifecycle
|
||||
|
||||
| Column | Note |
|
||||
|---|---|
|
||||
| `is_mandatory` (NOT NULL, default true) | "User must address this." Surfaces in UI badge. |
|
||||
| `is_optional` (NOT NULL, default false) | Added by mig 068. **Distinct from is_mandatory** — semantics today: "the save-modal pre-unchecks these rows; the timeline still renders them." Live: e.g. UPC_INF `inf.cost_app` (RoP.151 Antrag auf Kostenentscheidung) — visible-but-defaulted-off. Naming is confusing (is_mandatory=true + is_optional=true would be self-contradictory); see §6.3. |
|
||||
| `is_spawn` (NOT NULL, default false) | Marks the rule as a "spawn" — emitted when its parent decision fires, but the spawn itself starts a NEW timeline branch (e.g. Appeal off Decision). Used by 8 live rules: APP/AMD/CCR cross-proceeding spawns. **Spawn execution is half-wired**: `projection_service.go:896-901` notes "Cross-proceeding spawn — the calculator can return rules from another proceeding type (Appeal off Decision). We don't have that rule in our map; skip the dependency annotation but still surface the row." — i.e. the row appears in the response but the dependency-annotation graph breaks. |
|
||||
| `is_active` (NOT NULL, default true) | Soft-delete. All 172 live rules have `is_active=true`; soft-delete unused so far. |
|
||||
| `sequence_order` (NOT NULL, default 0) | Calculator walks rules in this order. Must be consistent with topological order on `parent_id` (parents before children). |
|
||||
| `created_at` / `updated_at` | timestamps. |
|
||||
| `event_type` (text, nullable) | One of `decision`, `filing`, `hearing`, `order`. **A category, NOT an FK** to `paliad.event_types`. Distinct from concept-level event_type linkage in §3. |
|
||||
|
||||
### 1.7 Vestigial / under-used
|
||||
|
||||
- `condition_rule_id` — 0 rows populated. Dead column.
|
||||
- `description` — sparse, used as fallback notes.
|
||||
- `is_mandatory` vs `is_optional` — overlapping semantics that need a clean re-think (§6.3).
|
||||
|
||||
---
|
||||
|
||||
## 2. Trigger model today — events to deadlines
|
||||
|
||||
There are **three parallel paths** from a user-observable event to a calculated deadline list. Understanding the redundancy is the most important takeaway of this audit.
|
||||
|
||||
### 2.1 Path A — Proceeding-driven (the main spine)
|
||||
|
||||
Caller: `/tools/fristenrechner` Pathway A (wizard), Pathway B B1 leaf click + B2 search, `ProjectionService.computeProjections` (SmartTimeline).
|
||||
|
||||
Flow:
|
||||
1. User (or projection) picks a **proceeding_code** (e.g. `UPC_INF`) and a **trigger_date**.
|
||||
2. `FristenrechnerService.Calculate(proceedingCode, triggerDate, opts)` runs.
|
||||
3. Calculator loads `deadline_rules WHERE proceeding_type_id = $pt AND is_active`.
|
||||
4. Walks rules in `sequence_order`. For each:
|
||||
- Apply `condition_flag` gate (suppress if flags missing AND alt_duration_value is NULL; otherwise swap to alt_*).
|
||||
- Resolve anchor: `anchor_alt='priority_date'` → use priorityDate; else `parent_id` → parent's computed date; else triggerDate.
|
||||
- Apply `AnchorOverrides[rule_code]` if user set an override.
|
||||
- 4-bucket court-set classification (§4).
|
||||
- Calculate offset, apply holiday/weekend adjustment via `HolidayService`, store in `computed[code]` map.
|
||||
5. Returns `UIResponse{Deadlines: []UIDeadline}` — the full timeline.
|
||||
|
||||
Strengths:
|
||||
- Rich (condition flags, parent chains, anchor_alt, override map, court-set semantics).
|
||||
- Single source of truth for /tools/fristenrechner + SmartTimeline.
|
||||
- Backed by 172 rules across 27 proceedings.
|
||||
|
||||
Weaknesses:
|
||||
- Returns the **whole proceeding** every call. No "give me only the rules triggered by event X" mode.
|
||||
- Cross-proceeding spawn (is_spawn rules) is half-wired (§1.6).
|
||||
- `condition_flag` is AND-only; no OR, NOT, or compound expression.
|
||||
|
||||
### 2.2 Path B — Single-rule (subset of A)
|
||||
|
||||
Caller: Pathway B cascade-card click → inline calc panel.
|
||||
|
||||
Flow:
|
||||
1. User clicks a concept card; system picks the rule_id linked to that concept (via `event_category_concepts → deadline_rules`).
|
||||
2. POST `/api/tools/fristenrechner/calculate-rule` with `{rule_id, trigger_date, flags?}`.
|
||||
3. `FristenrechnerService.CalculateRule` walks the rule's parent chain only (no siblings), returns one `RuleCalculation`.
|
||||
|
||||
Strengths:
|
||||
- Lightweight (no full-proceeding compute).
|
||||
- Lets the cascade UI surface "click → see this rule's date" without rebuilding the whole timeline.
|
||||
|
||||
Weaknesses:
|
||||
- Doesn't include side-effects (sibling rules in the proceeding that the user might also care about).
|
||||
- Shares the same expressiveness limits as Path A.
|
||||
|
||||
### 2.3 Path C — Event-driven (youpc legacy, redundant)
|
||||
|
||||
Caller: Pathway A wizard's "Was kommt nach…" tab; `frontend/src/client/fristenrechner.ts:833` calls POST `/api/tools/event-deadlines`.
|
||||
|
||||
Flow:
|
||||
1. User picks a **trigger_event** (e.g. "Klageerhebung UPC", "Berufungsschrift OLG", from a 110-row picker list).
|
||||
2. POST `/api/tools/event-deadlines` with `{triggerEventID, triggerDate, courtID}`.
|
||||
3. `EventDeadlineService.Calculate` loads `paliad.event_deadlines WHERE trigger_event_id = $te`.
|
||||
4. For each row: apply `duration_value × duration_unit (+ timing: before/after)`. Supports `working_days` unit (Path A doesn't). Handles `alt_duration_value × combine_op (min/max)` composite leads.
|
||||
5. Returns flat list of computed deadlines + rule_codes.
|
||||
|
||||
Strengths:
|
||||
- Has the `before` timing semantic (Path A doesn't use it).
|
||||
- Has `working_days` unit (Path A doesn't have it).
|
||||
- Has `combine_op` (min/max) for composite duration math (Path A doesn't).
|
||||
- Trigger-event picker is more discoverable than "pick a proceeding": user says "Klageerhebung happened on date X, what comes after?" without first navigating to the proceeding tree.
|
||||
|
||||
Weaknesses:
|
||||
- **Disjoint corpus.** The 77 `event_deadlines` rows do NOT join to `paliad.deadline_rules`. Changing a rule in Path A doesn't update Path C.
|
||||
- **No parent_id chains.** Each event_deadline is a single-leg calc off the trigger date. No multi-stage timelines.
|
||||
- **No condition flags.** No with_ccr / with_amend gating.
|
||||
- **No SmartTimeline integration.** ProjectionService only knows Path A.
|
||||
- **Origin:** youpc.org ported (mig 028). Implicitly "legacy", but actively wired.
|
||||
|
||||
### 2.4 The concept layer (orthogonal to all three paths)
|
||||
|
||||
`paliad.deadline_concepts` (56 rows) is the **noun layer** that lets the cascade + search talk about "Klageerwiderung" without knowing which of the 9 jurisdiction-specific Klageerwiderung rules it means. Every rule has `concept_id` (171/172); every cascade leaf has zero or more `event_category_concepts` rows linking to concepts (153 rows, 100 distinct leaves of 103 → 97% coverage).
|
||||
|
||||
`paliad.deadline_concept_event_types` (32 rows, added mig 073/074) maps `(concept_id, jurisdiction) → event_type_id` so when the user creates a Deadline via the form by picking a Regel, the system can pre-fill the Typ chip with the canonical event_type. This is a **CONFIG layer, not a trigger model** — it doesn't say "when event X fires, these deadlines spawn." See §6.4.
|
||||
|
||||
### 2.5 Multi-deadline triggers
|
||||
|
||||
m's "specific events trigger specific deadlines, sometimes multiple" is implemented via **`parent_id` chains in Path A**. One root event (e.g. UPC_INF `inf.soc` = Klageerhebung) triggers a tree of dependent rules. Today the deepest live chain is **3 levels**:
|
||||
|
||||
```text
|
||||
inf.soc (root, anchor)
|
||||
├─ inf.sod (3mo after, Klageerwiderung)
|
||||
│ ├─ inf.def_to_ccr ([with_ccr], 2mo after sod, Erwiderung auf CCR)
|
||||
│ │ └─ inf.reply_def_ccr ([with_ccr], 2mo after, Replik auf Erwid CCR)
|
||||
│ │ └─ inf.rejoin_reply_ccr ([with_ccr], 1mo after, Duplik)
|
||||
│ ├─ inf.app_to_amend ([with_ccr,with_amend], 2mo after sod, Antrag Patentänderung)
|
||||
│ │ ├─ inf.def_to_amend ([with_ccr,with_amend], 2mo after, Erwiderung)
|
||||
│ │ └─ inf.reply_def_amd ([with_ccr,with_amend], 1mo after Reply, Replik Amend)
|
||||
│ ├─ inf.reply (with_ccr → 2mo after sod RoP.029.a; without_ccr → swap to alt)
|
||||
│ └─ inf.rejoin (with_ccr → 1mo after reply RoP.029.d)
|
||||
└─ inf.interim (court-set, Zwischenverfahren)
|
||||
└─ inf.oral (court-set, Mündliche Verhandlung)
|
||||
└─ inf.decision (court-set, Entscheidung)
|
||||
└─ inf.cost_app (1mo after decision, is_optional, Antrag Kostenentscheidung)
|
||||
```
|
||||
|
||||
15 rules, 4 condition-flag-gated, 4 court-set placeholders (inf.interim / inf.oral / inf.decision are 0-duration court-set; inf.soc is 0-duration root), 1 optional. The structural fidelity is high.
|
||||
|
||||
### 2.6 Conditional triggers — the AND-only ceiling
|
||||
|
||||
`condition_flag` is `text[]` with **AND-of-array** semantic. To render the rule, every flag in the array must be in the caller's `Flags` set.
|
||||
|
||||
Live flag space: `{with_amend, with_ccr, with_cci}` — three flags, four combinations used. The empty array is the unconditional default.
|
||||
|
||||
This is enough to express:
|
||||
- "with counterclaim for revocation" (with_ccr alone)
|
||||
- "with counterclaim for revocation AND with amendment" (with_ccr + with_amend)
|
||||
- "with counterclaim for infringement" (with_cci alone)
|
||||
|
||||
But not:
|
||||
- "with_ccr OR with_cci" — would need OR, today not supported. (Live workaround: duplicate rules with each gate.)
|
||||
- "NOT with_ccr" — also not supported.
|
||||
- Compound: "with_ccr AND NOT expedited".
|
||||
|
||||
§6 flags this as a real coverage gap.
|
||||
|
||||
---
|
||||
|
||||
## 3. The 27 proceeding types — what's covered, what's a stub
|
||||
|
||||
### 3.1 Inventory
|
||||
|
||||
| Category | Code | Jurisdiction | Rule count | Notes |
|
||||
|---|---|---|---|---|
|
||||
| **fristenrechner** | DE_INF | DE | 9 | Verletzungsverfahren LG. |
|
||||
| | DE_INF_OLG | DE | 7 | Berufung OLG. |
|
||||
| | DE_INF_BGH | DE | 8 | Revision / NZB BGH. |
|
||||
| | DE_NULL | DE | 10 | Nichtigkeit BPatG. |
|
||||
| | DE_NULL_BGH | DE | 6 | Berufung BGH (Nichtigkeit). |
|
||||
| | DPMA_OPP | DPMA | 4 | DPMA Einspruch. |
|
||||
| | DPMA_BPATG_BESCHWERDE | DPMA | 5 | BPatG-Beschwerde nach DPMA. |
|
||||
| | DPMA_BGH_RB | DPMA | 4 | Rechtsbeschwerde BGH. |
|
||||
| | EPA_OPP | EPA | 8 | EPA Einspruch. |
|
||||
| | EPA_APP | EPA | 8 | EPA Beschwerde. |
|
||||
| | EP_GRANT | EPA | 7 | EP-Erteilung. One rule uses `anchor_alt='priority_date'`. |
|
||||
| | UPC_INF | UPC | **15** | Verletzung. Richest corpus. |
|
||||
| | UPC_REV | UPC | **15** | Nichtigkeit. Richest. |
|
||||
| | UPC_APP | UPC | 7 | Berufung UPC. |
|
||||
| | UPC_APP_ORDERS | UPC | 5 | Berufung gegen Anordnungen. |
|
||||
| | UPC_COST_APPEAL | UPC | 2 | Kostenberufung. |
|
||||
| | UPC_DAMAGES | UPC | 4 | Schadensbemessung. |
|
||||
| | UPC_DISCOVERY | UPC | 4 | Bucheinsicht. |
|
||||
| | UPC_PI | UPC | 4 | Einstweilige Maßnahmen. |
|
||||
| **litigation** | INF | UPC | 8 | Infringement. |
|
||||
| | REV | UPC | 7 | Revocation. |
|
||||
| | CCR | UPC | 7 | Counterclaim for Revocation. |
|
||||
| | APM | UPC | 4 | Provisional Measures. |
|
||||
| | APP | UPC | 8 | Appeal. |
|
||||
| | AMD | UPC | 2 | Application to Amend. |
|
||||
| | ZPO_CIVIL | DE | 4 | ZPO Civil. |
|
||||
|
||||
Total: **172 rules across 27 proceeding types** (132 fristenrechner + 40 litigation).
|
||||
|
||||
### 3.2 Litigation vs Fristenrechner — the dual-corpus problem
|
||||
|
||||
The **same conceptual proceeding** (e.g. UPC Infringement) appears twice in `paliad.proceeding_types`:
|
||||
|
||||
- `INF` (category=`litigation`) — 8 rules, generic UPC labels (Statement of Claim, Statement of Defence, Reply, Rejoinder, Oral Hearing, Interim Conference, Decision, Preliminary Objection).
|
||||
- `UPC_INF` (category=`fristenrechner`) — 15 rules, German labels + condition_flag variants.
|
||||
|
||||
The brief calls this out as "two parallel vocabularies." Live confirms:
|
||||
|
||||
- `paliad.projects.proceeding_type_id` accepts BOTH categories (no CHECK constraint enforces one or the other). Today all 11 projects are NULL anyway.
|
||||
- `FristenrechnerService.Calculate(proceedingCode, …)` is **category-agnostic** — pass it `INF` or `UPC_INF`, you get back the respective corpus's timeline. No category guard.
|
||||
- The Pathway-A wizard surfaces ONLY `category='fristenrechner'` codes (`internal/services/fristenrechner.go:735`: `WHERE category = 'fristenrechner' AND is_active = true`). So users can't pick `INF` from the wizard.
|
||||
- `ProjectionService.computeProjections` resolves `proj.ProceedingTypeID → code` and calls Calculate with whatever code is on the project. So a project with `INF` would render the 8-rule litigation timeline; a project with `UPC_INF` would render the 15-rule fristenrechner timeline.
|
||||
|
||||
**This is a latent footgun.** Whichever code lands on a project first dictates which corpus drives its SmartTimeline. The two corpuses disagree on:
|
||||
- Rule count (8 vs 15).
|
||||
- Granularity (litigation has 1 ccr.defence row; fristenrechner has 7 with_ccr/with_amend gated rows).
|
||||
- Language (litigation labels are English; fristenrechner German).
|
||||
|
||||
No code path treats this divergence intentionally. The likely intent at seed-time was:
|
||||
- `litigation` codes = "the project model's coarse type enum" (Mandant-level taxonomy).
|
||||
- `fristenrechner` codes = "the calculator's fine-grained variants".
|
||||
|
||||
But the actual schema doesn't enforce that contract. **Flagged as §6.2.**
|
||||
|
||||
### 3.3 Coverage observations
|
||||
|
||||
- **UPC corpus dominates fristenrechner.** 9 of the 20 fristenrechner codes are UPC (66 rules); 5 are DE (40); 3 are DPMA (13); 3 are EPA (23). Bias matches HLC's mandate mix.
|
||||
- **DE_INF_OLG, DE_INF_BGH, DE_NULL_BGH** were split out late (mig 043). The instance dimension (LG / OLG / BGH) is NOT on `paliad.projects`, so you can't currently derive whether a DE project is at first instance, OLG, or BGH from the project model. This blocks fine-grained Akte → proceeding-code mapping (cross-referenced in t-paliad-166 §4.2).
|
||||
- **EP_GRANT** is the only proceeding that uses `anchor_alt`. Other priority-date-anchored rules don't exist (yet).
|
||||
- **UPC_REV.with_cci** — the [with_cci] flag is used for "revocation action with counterclaim for infringement" — i.e. when the defendant in a revocation files a CCI. Only UPC_REV uses with_cci today (4 rules).
|
||||
|
||||
### 3.4 Concept linkage gaps
|
||||
|
||||
9 of 56 deadline_concepts have `rule_count = 0` — i.e. cascade-reachable concepts that produce zero calculated deadlines:
|
||||
|
||||
| Concept slug | Why it's empty |
|
||||
|---|---|
|
||||
| `counterclaim-for-revocation` | The CCR flow is modelled inside UPC_INF via `[with_ccr]` flag-gated rules, not as a separate concept-linked rule. |
|
||||
| `schriftsatznachreichung` | ZPO §296a "Schriftsatznachreichung" — cross-cutting concept, no rule encoding yet. |
|
||||
| `versaeumnisurteil-einspruch` | ZPO §339 "Einspruch gegen Versäumnisurteil" — no rule. |
|
||||
| `weiterbehandlung` | EPA Art. 121 EPÜ / R.135 — no rule. |
|
||||
| `wiedereinsetzung` | Re-establishment of rights — cross-cutting; no rule. |
|
||||
| `notice-of-defence-intention` | DE ZPO Verteidigungsanzeige — only ZPO_CIVIL has it; not linked. |
|
||||
| Plus 3 more sparse concepts. | |
|
||||
|
||||
For each, the cascade can route the user to the concept card, but the card has no rule pills underneath. This is a real coverage gap surfaced as §6.
|
||||
|
||||
---
|
||||
|
||||
## 4. Anchor semantics — the 4-bucket model
|
||||
|
||||
Encoded in `fristenrechner.go:272-369`. For each rule with `duration_value = 0`:
|
||||
|
||||
| Bucket | parent_id | court-determined? | Behaviour |
|
||||
|---|---|---|---|
|
||||
| **1. Root anchor** | NULL | no | Due date = trigger date. `IsRootEvent=true`. The proceeding's "day zero" (e.g. SoC filing). |
|
||||
| **2. Court-set absolute** | NULL | yes | Due date empty; UI shows "wird vom Gericht bestimmt". `IsCourtSet=true, IsCourtSetIndirect=false`. Used for top-level hearings / decisions that don't follow from another rule. |
|
||||
| **3. Court-set chained** | set | yes | Due date empty (court determines); ancestor anchor. `IsCourtSet=true`. Used for derivative court actions. |
|
||||
| **4. Filed-with-parent** | set | no | Inherits parent's calculated date. Used for "X is bundled into Y" (e.g. UPC_REV.rev.app_to_amend, rev.cc_inf — included in the Defence to revocation). |
|
||||
|
||||
For rules with `duration_value > 0`:
|
||||
|
||||
- **Override wins.** `AnchorOverrides[rule_code]` provided by user → use it; mark `IsOverridden=true`.
|
||||
- **Parent court-set + no override** → mark `IsCourtSet=true, IsCourtSetIndirect=true`. The rule isn't directly court-determined, but its anchor (the court-set parent) hasn't been bound yet. UI shows "unbestimmt".
|
||||
- **Otherwise:** baseDate = (anchor_alt=priority_date → priorityDate) || (parent_id → computed[parent.code]) || triggerDate. Add `duration_value × duration_unit`. Apply holiday adjustment. Done.
|
||||
|
||||
**Court-set detection** (`isCourtDeterminedRule` in calculator) keys on:
|
||||
- `primary_party='court'`, OR
|
||||
- `event_type ∈ {'hearing','decision','order'}`, OR
|
||||
- Heuristic name match (legacy from migration 028).
|
||||
|
||||
This is brittle — the boolean is computed from columns that aren't strictly designed for it. §6.5 suggests promoting a real `is_court_set` column.
|
||||
|
||||
### 4.1 `AnchorOverrides` — the override map
|
||||
|
||||
The override surface is the bridge between "calculated forecast" and "real ground truth." Two consumers:
|
||||
- **SmartTimeline (`ProjectionService.collectActualsForOverrides`)** — bind a real `paliad.deadlines` row's date back into the calculator: if a saved deadline has `rule_id=X` and `completed_at='2026-04-10'`, the next projection uses 2026-04-10 as the anchor for any rule whose parent is X.
|
||||
- **Pathway A wizard "Anchor edits"** — the user can override a per-rule date inline in the timeline (paliad-088 era feature). Applies to court-set rules where the user finally knows the decision date.
|
||||
|
||||
The override map propagates **downstream**: child rules see the override as their parent's date.
|
||||
|
||||
This is a strong, well-implemented mechanism. No gap.
|
||||
|
||||
---
|
||||
|
||||
## 5. Adjustment semantics — weekends, holidays, court calendars
|
||||
|
||||
### 5.1 `HolidayService.AdjustForNonWorkingDaysWithReason(endDate, country, regime)`
|
||||
|
||||
Called after every offset computation. Returns `(adjusted, _, wasAdjusted, reason)`.
|
||||
|
||||
- If endDate is a weekend → roll to next Monday. Reason: `kind=weekend, original_weekday`.
|
||||
- If endDate is a public holiday (region match in `paliad.holidays`) → roll to next business day. Reason: `kind=public_holiday, holidays=[…]`.
|
||||
- If endDate is inside a court vacation (regime-specific date range) → roll to first non-vacation business day. Reason: `kind=vacation, vacation_name, vacation_start, vacation_end`.
|
||||
|
||||
Live `paliad.holidays`: **55 rows**, mix of public holidays and vacation periods. `region` axis covers DE federal + state-specific + UPC court-specific.
|
||||
|
||||
### 5.2 `CourtService.CountryRegime(courtID, defaultCountry, defaultRegime)`
|
||||
|
||||
`paliad.courts` (41 rows) carries `country` and `regime` per court. Defaults via jurisdiction:
|
||||
- UPC-flavoured proceedings → DE+UPC (UPC München is the default venue).
|
||||
- DE proceedings → DE.
|
||||
- EPA / DPMA → DE.
|
||||
|
||||
Live regimes inferred from queries: DE state codes (BY, BW, …), UPC court-specific tags. No formal CHECK constraint listing valid regimes.
|
||||
|
||||
### 5.3 Working-day arithmetic — split between calculators
|
||||
|
||||
Pipeline C (`EventDeadlineService.addWorkingDays`) supports `duration_unit='working_days'`: step forward N business days, skipping weekends + holidays.
|
||||
|
||||
Pipeline A (`FristenrechnerService`) does NOT support working_days; only calendar days/weeks/months. Adjustment is post-hoc (compute the calendar date, then roll forward if it lands on a non-business day).
|
||||
|
||||
**The two calculators are not equivalent.** Some real-world deadlines are "10 working days after Z" — those can only be expressed in Pipeline C today. Cross-references §6.6.
|
||||
|
||||
---
|
||||
|
||||
## 6. Coverage gaps (the heart of the audit)
|
||||
|
||||
What m's mental model wants ("specific events trigger specific deadlines, sometimes multiple, sometimes conditional") that the data model cannot express today.
|
||||
|
||||
### 6.1 Two trigger systems — Pipeline A vs Pipeline C
|
||||
|
||||
**Symptom.** Two disjoint data corpuses (`deadline_rules` 172 vs `trigger_events`+`event_deadlines` 110+77) with overlapping intent. A change to a rule in Pipeline A doesn't propagate to Pipeline C. The user-facing "Was kommt nach…" tab (Pipeline C) renders different numbers than the wizard timeline (Pipeline A) for nominally-similar trigger events.
|
||||
|
||||
**Impact.** Pipeline C has capabilities Pipeline A lacks (`before` timing, `working_days` unit, `combine_op` min/max) — but no parent chains, no condition_flag, no court-set semantic. Choosing the "right" pipeline today means picking which subset of capabilities the user actually needs for that case.
|
||||
|
||||
**Root cause.** Pipeline C is a youpc.org port (mig 028). Pipeline A is paliad-native (mig 009 → 050 evolution). They were never reconciled.
|
||||
|
||||
### 6.2 Litigation vs fristenrechner corpus drift
|
||||
|
||||
**Symptom.** `paliad.projects.proceeding_type_id` accepts both `litigation` and `fristenrechner` codes. The same conceptual proceeding has rule corpuses of different size, granularity, and language depending on which category the project lands on.
|
||||
|
||||
**Impact.** SmartTimeline forecast for a project depends on which code is chosen at project-create time. Two HLC partners filing identical UPC infringement cases could see different timelines if one picked `INF` and the other `UPC_INF`.
|
||||
|
||||
**Root cause.** No CHECK constraint, no documentation, no UI guard. Likely intent: `litigation` for project-model coarse classification, `fristenrechner` for fine-grained calculator — but the contract was never formalised.
|
||||
|
||||
### 6.3 `is_mandatory` vs `is_optional` semantic overlap
|
||||
|
||||
**Symptom.** Two boolean columns with overlapping meaning. Current usage:
|
||||
- `is_mandatory=true, is_optional=false` — default (most rules).
|
||||
- `is_mandatory=true, is_optional=true` — surfaces in timeline but pre-unchecked in save-modal (only UPC_INF.inf.cost_app + a few others).
|
||||
- `is_mandatory=false` — unclear semantics today; sparsely used.
|
||||
|
||||
**Impact.** Confusing for both developers and future rule authors. A rule with `is_mandatory=false, is_optional=true` (legal "may file but not required") versus `is_mandatory=true, is_optional=true` (legal "should file but isn't a hard deadline") versus `is_mandatory=true, is_optional=false` (legal "must file") — the four-way matrix isn't well-defined.
|
||||
|
||||
**Root cause.** `is_optional` was added late (mig 068) as a UX hack ("pre-uncheck in save modal") rather than a semantic axis.
|
||||
|
||||
### 6.4 `deadline_concept_event_types` is a config layer, not a trigger model
|
||||
|
||||
**Symptom.** The table maps `(concept, jurisdiction) → event_type` for the create-form's chip suggestion. It DOES NOT support "when an event of type X fires, spawn deadlines for these rules."
|
||||
|
||||
**Impact.** m's "specific events trigger specific deadlines" implies a directional pipeline: user logs an event → system computes the deadlines that flow from it. That pipeline today exists only via:
|
||||
- Pipeline A's full-proceeding compute (heavy: gives everything, not just X's children).
|
||||
- Pipeline C's trigger_event picker (decoupled corpus).
|
||||
|
||||
There's no event_type-keyed entry point into Pipeline A. The cascade gets close — leaf → concept → rules — but stops at "show the cards"; firing the rules requires the user to manually click a card → calculate-rule.
|
||||
|
||||
**Root cause.** Pipeline A was designed proceeding-first (mig 009, 2024). The event-first paradigm came later via concepts (mig 037+) but never produced a dedicated trigger endpoint.
|
||||
|
||||
### 6.5 Court-set detection is heuristic
|
||||
|
||||
**Symptom.** `isCourtDeterminedRule()` decides court-set status from `primary_party='court' OR event_type IN ('hearing','decision','order') OR name-heuristic`. No dedicated boolean column.
|
||||
|
||||
**Impact.** False positives possible if a rule names "decision" but isn't court-set (e.g. "preliminary decision to amend"). False negatives possible if a court-set rule isn't tagged with one of these signals.
|
||||
|
||||
**Root cause.** Court-set semantic was never formalised as a first-class column. Inferred at runtime.
|
||||
|
||||
### 6.6 Pipeline A lacks `before`, `working_days`, `combine_op`
|
||||
|
||||
**Symptom.** Specific gaps in expressive power:
|
||||
- `before` timing: useful for "must be filed Y days BEFORE oral hearing." Pipeline C honours `timing='before'`; Pipeline A only renders `timing='after'` rules.
|
||||
- `working_days` unit: useful for procedural deadlines like UPC R.220.3 ("3 working days from notification"). Pipeline C supports it; Pipeline A doesn't.
|
||||
- `combine_op` (min/max): useful for "earlier of X or Y" (compound deadlines, e.g. EPC R.36 — "shortest of priority date+24mo or filing date+18mo"). Pipeline C supports it; Pipeline A doesn't.
|
||||
|
||||
**Impact.** Some legal deadlines can only be expressed in Pipeline C, fragmenting the rule corpus.
|
||||
|
||||
**Root cause.** Pipeline A grew from a "tree of forward offsets" model; backward / composite deadlines weren't anticipated.
|
||||
|
||||
### 6.7 Condition-flag grammar is AND-only
|
||||
|
||||
**Symptom.** `condition_flag` is `text[]` with AND semantics. No OR, no NOT, no nested expression.
|
||||
|
||||
**Impact.** Real legal scenarios that need OR (e.g. "rule X applies if CCR OR CCI is filed") get encoded as **two duplicate rules** today — one for each branch. Painful to maintain; easy to drift.
|
||||
|
||||
**Root cause.** The flag axis was designed for the small set of UPC variant flags (`with_ccr`, `with_amend`, `with_cci`); compound expressions weren't anticipated.
|
||||
|
||||
### 6.8 Cross-proceeding spawn is half-wired
|
||||
|
||||
**Symptom.** `is_spawn=true` rules exist (8 live), intended to express "when X happens in proceeding A, also trigger Y in proceeding B." The calculator code at `projection_service.go:896-901` explicitly notes: "Cross-proceeding spawn … We don't have that rule in our map; skip the dependency annotation but still surface the row."
|
||||
|
||||
**Impact.** A UPC_INF decision firing an APP proceeding (cross-proceeding) renders the spawned row, but the dependency-graph annotation breaks. SmartTimeline can't fully chain across proceedings.
|
||||
|
||||
**Root cause.** Cross-proceeding spawn was a late addition; the calculator's `ruleByID` map is per-proceeding, so it can't resolve spawns from other proceedings. Needs either a global rule index or a smarter resolver.
|
||||
|
||||
### 6.9 Nine orphan concepts with `rule_count=0`
|
||||
|
||||
Per §3.4: `counterclaim-for-revocation`, `schriftsatznachreichung`, `versaeumnisurteil-einspruch`, `weiterbehandlung`, `wiedereinsetzung`, `notice-of-defence-intention`, plus 3 more.
|
||||
|
||||
**Impact.** Cascade leaves can reach these concepts, but the user sees an empty result card. UX feels broken even though it's an unrelated coverage gap (no rules seeded yet).
|
||||
|
||||
**Root cause.** Cascade taxonomy was seeded ahead of the rule corpus for some concepts. The seed work never caught up.
|
||||
|
||||
### 6.10 No way to express "X is conditional on Y having fired"
|
||||
|
||||
**Symptom.** `condition_rule_id` exists as a column but is 0% populated. Was intended for "rule X applies only if rule Y was previously triggered" but never wired.
|
||||
|
||||
**Impact.** Today's flag mechanism (condition_flag) gates on **caller-supplied flags** (e.g. user toggles "with_ccr" in the UI). It doesn't gate on **runtime rule firing**. So you can't express "if the defendant filed Preliminary Objection (rule X), then rule Y is suspended for 2mo."
|
||||
|
||||
**Root cause.** Column added speculatively; never wired into the calculator.
|
||||
|
||||
### 6.11 The instance dimension (LG/OLG/BGH) isn't on `paliad.projects`
|
||||
|
||||
**Symptom.** The proceeding_types `DE_INF_OLG` / `DE_INF_BGH` exist, but a project can't carry "I'm at first instance" / "I'm on appeal at OLG" as data. The user has to manually pick a different `proceeding_type_id` if the case moves up the instances.
|
||||
|
||||
**Impact.** SmartTimeline forecast can't auto-advance from DE_INF → DE_INF_OLG when a Berufungsschrift fires on the actuals side.
|
||||
|
||||
**Root cause.** Project model treats proceeding-type as a static attribute, not a state machine.
|
||||
|
||||
### 6.12 No rule audit log
|
||||
|
||||
**Symptom.** Rules are modified by SQL migrations only. There's no `paliad.deadline_rule_audit` table tracking "rule X changed from 3mo to 2mo on 2026-04-15 by m, because Y." Migrations are technically the audit trail, but they aren't queryable in-app.
|
||||
|
||||
**Impact.** Rule-management UX (§8) needs an answer for "who changed this rule and why." Without an audit trail, rule-editing in-app is a step backward in compliance.
|
||||
|
||||
**Root cause.** Never needed before, because rules were never user-editable.
|
||||
|
||||
### 6.13 Zero deadline → rule linkage in live data
|
||||
|
||||
**Symptom.** Only **1 of 26** live deadlines has `rule_id` populated.
|
||||
|
||||
**Impact.** SmartTimeline's "anchor real deadlines into projection" feature (Pipeline A's strongest UX) is unusable on existing data. New deadlines saved via the wizard *do* get rule_id; legacy deadlines don't.
|
||||
|
||||
**Root cause.** Schema migrated incrementally; backfill never happened.
|
||||
|
||||
---
|
||||
|
||||
## 7. Extension proposals (one concrete change per §6 gap)
|
||||
|
||||
Each gap from §6 gets a concrete schema / service change, costed (migration + service + UI ripples).
|
||||
|
||||
### 7.1 Reconcile Pipelines A and C
|
||||
|
||||
**Proposal.** Migrate `paliad.event_deadlines` into `paliad.deadline_rules` with a new column `trigger_event_id` (nullable FK to `paliad.trigger_events`). A rule with `trigger_event_id NOT NULL` is event-triggered (Pipeline C semantics); with NULL it stays proceeding-triggered (Pipeline A).
|
||||
|
||||
Add the Pipeline-C-only columns to `deadline_rules`:
|
||||
- `timing` already exists; backfill non-NULL `before` values.
|
||||
- `combine_op` ∈ `{min, max, NULL}` — new column.
|
||||
- `working_days` as a valid `duration_unit` value — already a string column, no schema change.
|
||||
|
||||
Then deprecate Pipelines C, redirecting `/api/tools/event-deadlines` to the unified calculator.
|
||||
|
||||
**Cost.**
|
||||
- Migration: 1 file, ~120 LoC SQL (column adds + data move + idx).
|
||||
- Service: `FristenrechnerService.Calculate` extends to honour `timing='before'`, `working_days`, `combine_op`. ~80 LoC Go.
|
||||
- Service: `EventDeadlineService` either deletes (clean) or proxies to FristenrechnerService (transitional).
|
||||
- Handler: `/api/tools/event-deadlines` either deletes or 302s.
|
||||
- Frontend: `client/fristenrechner.ts:833` — the "Was kommt nach…" tab can call the unified endpoint.
|
||||
- Tests: a fresh table-driven test fixture covers the union behaviour.
|
||||
|
||||
**Ripple.** No data loss; trigger_event_id is additive. Frontend mostly transparent.
|
||||
|
||||
### 7.2 Formalise litigation vs fristenrechner contract
|
||||
|
||||
**Proposal.** Two options:
|
||||
- **(a) Hard-split.** Add `CHECK constraint` to `paliad.projects.proceeding_type_id`: only `category='litigation'` codes allowed. Migrate the 8-rule litigation corpus to be the canonical "project-level proceeding type". Move the fine-grained `category='fristenrechner'` rules under each litigation code via a new `variant` column.
|
||||
- **(b) Soft-merge.** Drop the `category` discriminator entirely. Every proceeding_type carries its full rule corpus. The dual-corpus today (8-rule INF + 15-rule UPC_INF) merges into ONE 15-rule UPC_INF, with the project model referencing only the rich variant.
|
||||
|
||||
**Cost.** (a) is invasive — migration to move 40 litigation-corpus rules under the fristenrechner codes; (b) is less invasive but means projects switch to picking `UPC_INF` instead of `INF`.
|
||||
|
||||
**Recommendation.** **(b)**. The dual-corpus is legacy from a project-model + calculator-model that grew separately. One canonical proceeding_type per case is cleaner.
|
||||
|
||||
**Ripple.** Project-create form picker changes from "INF / REV / CCR / APM / APP / AMD / ZPO_CIVIL" to the full 20-code fristenrechner picker (or a curated subset). t-paliad-166's mapping helper becomes unnecessary.
|
||||
|
||||
### 7.3 Clean up `is_mandatory` vs `is_optional`
|
||||
|
||||
**Proposal.** Replace both with a single `deadline_kind` enum:
|
||||
- `mandatory` — must be addressed.
|
||||
- `recommended` — should be addressed (pre-checked in save-modal but not required).
|
||||
- `optional` — may be addressed (pre-unchecked in save-modal).
|
||||
- `informational` — never saves as a deadline, surfaces as info.
|
||||
|
||||
Backfill: `is_mandatory=true, is_optional=false → mandatory`; `is_mandatory=true, is_optional=true → optional`; `is_mandatory=false → recommended`.
|
||||
|
||||
**Cost.** Migration ~30 LoC SQL. Service: `UIDeadline` exposes `Kind` instead of `IsMandatory`+`IsOptional`. Frontend: badge logic + save-modal pre-check.
|
||||
|
||||
### 7.4 Add a real event-driven trigger endpoint
|
||||
|
||||
**Proposal.** `POST /api/tools/event-trigger` with `{event_type_slug, trigger_date, project_id?}`. Resolves:
|
||||
1. `event_types.slug → event_types.id`
|
||||
2. `deadline_concept_event_types.event_type_id → concept_id` (per jurisdiction from project or explicit)
|
||||
3. `deadline_rules.concept_id → rules`
|
||||
4. Calculate the rules + their parent chains via Pipeline A.
|
||||
|
||||
Returns just the rules that flow from this event (filtered Pipeline A response).
|
||||
|
||||
**Cost.** Handler + service method, ~100 LoC. No schema change; uses existing junction.
|
||||
|
||||
**Ripple.** Lets the cascade UI offer "I just logged this event — here are the deadlines that follow" in one click. Also unlocks Phase-H-style email parsing → deadline spawn.
|
||||
|
||||
### 7.5 Promote court-set to a real column
|
||||
|
||||
**Proposal.** Add `is_court_set boolean NOT NULL DEFAULT false` to `paliad.deadline_rules`. Backfill from the heuristic. Calculator reads the column instead of inferring.
|
||||
|
||||
**Cost.** Migration ~20 LoC SQL (incl. backfill DO$$ block). Service: 1-line change in `isCourtDeterminedRule`.
|
||||
|
||||
**Ripple.** Faster + correct + no behaviour surprise. Cheap win.
|
||||
|
||||
### 7.6 Pipeline A gains `before` / `working_days` / `combine_op`
|
||||
|
||||
Covered in §7.1 (reconciliation).
|
||||
|
||||
### 7.7 Compound condition grammar
|
||||
|
||||
**Proposal.** Replace `condition_flag text[]` with `condition_expr jsonb`. Schema:
|
||||
|
||||
```json
|
||||
{"op":"and", "args":[{"flag":"with_ccr"},{"op":"not","args":[{"flag":"expedited"}]}]}
|
||||
```
|
||||
|
||||
Backfill: `['with_ccr','with_amend']` → `{"op":"and","args":[{"flag":"with_ccr"},{"flag":"with_amend"}]}`.
|
||||
|
||||
**Cost.** Migration with backfill ~80 LoC. Service: small recursive evaluator (~50 LoC Go). UI: condition picker for rule-editor (§8) — more involved.
|
||||
|
||||
**Ripple.** Future rule authors can express OR / NOT cleanly. No data drift; backward-compatible eval.
|
||||
|
||||
### 7.8 Wire cross-proceeding spawn
|
||||
|
||||
**Proposal.** Change `DeadlineRuleService.List(proceedingTypeID *int)` to allow a "follow spawn" mode that returns rules from spawned proceedings as well. Or: in `projection_service.computeProjections`, when a rule has `is_spawn=true` and the calculator returns a row from a different proceeding code, load the target proceeding's rule corpus lazily.
|
||||
|
||||
**Cost.** Service: ~50 LoC. Calculator: ~30 LoC. Risk: cycle prevention (don't infinite-loop A→B→A).
|
||||
|
||||
**Ripple.** SmartTimeline can fully chain across proceedings. The dependency-annotation breakage at `projection_service.go:896-901` resolves.
|
||||
|
||||
### 7.9 Seed the 9 orphan concepts with rules
|
||||
|
||||
**Proposal.** Per concept, add 1–3 rules to the appropriate proceeding_types. e.g. `wiedereinsetzung` → UPC R.320.1 (`UPC_INF.wiedereinsetzung`), EPA R.136 (`EPA_OPP.wiedereinsetzung`), DE PatG §123 (`DE_INF.wiedereinsetzung`).
|
||||
|
||||
**Cost.** Per orphan concept: ~20 LoC SQL. Total ~150 LoC across 9 concepts. Legal review required per rule.
|
||||
|
||||
**Ripple.** Cascade no longer dead-ends. This is the "coverage" gap m's t-paliad-167 explicitly called for.
|
||||
|
||||
### 7.10 Wire `condition_rule_id` or drop it
|
||||
|
||||
**Proposal.** Either:
|
||||
- (a) Implement: when calculator walks rules, gate a rule's render on `condition_rule_id`'s presence in the `computed` map.
|
||||
- (b) Drop the dead column.
|
||||
|
||||
**Recommendation.** **(b)**. The semantic is rarely needed; `condition_flag` covers most variant cases. Future need can resurrect.
|
||||
|
||||
### 7.11 Add `instance_level` to `paliad.projects`
|
||||
|
||||
**Proposal.** New column `instance_level text` ∈ `{first, appeal_olg, appeal_bgh, NULL}`. Combined with `proceeding_type.code` + `jurisdiction`, lets us derive `DE_INF_OLG` vs `DE_INF` from a project.
|
||||
|
||||
**Cost.** Migration ~10 LoC SQL. Project form: new picker. SmartTimeline forecast: small refactor in `proceedingCodeForProject`.
|
||||
|
||||
### 7.12 Rule audit log
|
||||
|
||||
**Proposal.** New table `paliad.deadline_rule_audit (id, rule_id, changed_by, changed_at, before_json, after_json, reason text)`. Trigger on UPDATE/INSERT/DELETE captures the diff. Required if §8 lands.
|
||||
|
||||
**Cost.** Migration ~40 LoC SQL (table + trigger). Read API for compliance review.
|
||||
|
||||
### 7.13 Backfill `rule_id` on existing deadlines
|
||||
|
||||
**Proposal.** One-time migration: for each `paliad.deadlines` row, fuzzy-match `title` against `paliad.deadline_concepts.aliases` + `paliad.deadline_rules.name`, link the highest-confidence match, leave low-confidence unlinked.
|
||||
|
||||
**Cost.** Migration ~100 LoC SQL. Run once.
|
||||
|
||||
**Ripple.** SmartTimeline anchor-from-actuals starts working for existing data. Bigger UX win than it sounds.
|
||||
|
||||
---
|
||||
|
||||
## 8. Rule-management UX — does m need an in-app rule editor?
|
||||
|
||||
m's "all in the Rules so we should be able to manage" reads as a direct ask.
|
||||
|
||||
### 8.1 The case for an in-app rule editor
|
||||
|
||||
- **Today: SQL migration only.** Every rule add/edit/disable requires a developer to write a migration, get reviewed, merge, deploy. The feedback loop is hours-to-days.
|
||||
- **Domain experts ≠ developers.** m is the rule expert. He shouldn't need to write `INSERT INTO paliad.deadline_rules (proceeding_type_id, code, name, duration_value, …)` SQL.
|
||||
- **Coverage gaps are persistent** (§3.4, §6.9). They stay open longer because the workflow is high-friction.
|
||||
- **Real-world law changes.** Procedural rules update (e.g. UPC R.49 just had a 2026-Q1 revision). Capturing those in SQL migrations is fragile.
|
||||
|
||||
### 8.2 The case against
|
||||
|
||||
- **Compliance / audit.** Rules are legal infrastructure. Any user-edit must be auditable, reviewable, reversible.
|
||||
- **Schema complexity.** 32 columns with semantic nuances (court-set heuristic, parent_id topology, condition_flag grammar). Naive form UI = footgun heaven.
|
||||
- **Cross-rule validation.** parent_id chains must remain DAGs. sequence_order must be topologically consistent. condition_flag values must be in a valid vocabulary. No live constraint catches all of these today.
|
||||
- **Build cost.** A real rule-editor with audit log, validation, preview, dry-run, and rollback is 4–6 PRs of work.
|
||||
|
||||
### 8.3 Three options
|
||||
|
||||
| Option | Description | Effort | When right |
|
||||
|---|---|---|---|
|
||||
| **(A) Status quo: SQL only** | Keep migrations as the rule-edit surface. Build tooling around migration authoring (mAi-assisted SQL gen, schema validators). | Low (~1 sprint of tooling). | If m's rule velocity is < 1 edit/week and audit trail is non-negotiable. |
|
||||
| **(B) Read-only admin surface** | Add `/admin/rules` page that lists rules, lets m search/filter/inspect. No edits in-app; "edit this rule" links to a Gitea issue template that drafts the migration. | Medium (~1 PR backend listing + 1 PR frontend). | If the friction is "I can't see what's in there" more than "I can't change what's there". |
|
||||
| **(C) Full rule editor** | `/admin/rules/{id}/edit` with form, validation, audit log, preview-on-trigger-date, "ship draft" → migration generator. | High (~4-6 PRs). | If m is genuinely going to edit rules weekly and the rule corpus is going to grow significantly. |
|
||||
|
||||
### 8.4 Inventor recommendation
|
||||
|
||||
**Start with (B), graduate to (C).**
|
||||
|
||||
- (B) immediately removes the "I can't see what's in there" friction, which today requires running SQL by hand or asking a developer. Low risk.
|
||||
- (B) makes the rule corpus discoverable inside the app — which is itself a win for transparency and for spotting coverage gaps (§3.4).
|
||||
- The Gitea-issue handoff preserves the audit trail and review workflow.
|
||||
- Once the corpus is browsable, the "I keep wanting to edit this thing" pressure tells us whether (C) is worth building.
|
||||
- **(C) without (B) is over-engineering** — we'd be building the form before we know which fields are actually edited often.
|
||||
|
||||
Hard requirement for (C) if we get there: `paliad.deadline_rule_audit` table (§7.12) with mandatory `reason` field, reviewer workflow, and migration-export so changes still land in version control.
|
||||
|
||||
§9 Q5 surfaces this for m's call.
|
||||
|
||||
---
|
||||
|
||||
## 9. Open questions for m (Phase 2 steering)
|
||||
|
||||
These are the 10–15 picks for m to make before Phase 2 starts.
|
||||
|
||||
**Q1 — Reconciliation of Pipelines A and C.** §6.1 + §7.1. Three options:
|
||||
- (a) Merge into one table (recommended; ~120 LoC migration + 80 LoC Go).
|
||||
- (b) Keep both but document the contract (cheap, but the drift continues).
|
||||
- (c) Deprecate Pipeline C entirely (deletes "Was kommt nach…" tab — UX loss).
|
||||
|
||||
**Q2 — Litigation vs fristenrechner corpus.** §6.2 + §7.2. Two options:
|
||||
- (a) Hard-split with CHECK constraint + rule migration (invasive).
|
||||
- (b) Soft-merge: drop the category discriminator, projects use fristenrechner codes only (recommended).
|
||||
|
||||
**Q3 — `is_mandatory` / `is_optional` cleanup.** §6.3 + §7.3. Pick the 4-value enum (`mandatory` / `recommended` / `optional` / `informational`) or keep the two booleans with formal docs.
|
||||
|
||||
**Q4 — Event-driven trigger endpoint.** §6.4 + §7.4. Build `POST /api/tools/event-trigger` (concept-keyed) now, or defer until rule corpus is reconciled?
|
||||
|
||||
**Q5 — Rule-management UX.** §8. Pick:
|
||||
- (A) status quo SQL only,
|
||||
- (B) read-only admin surface (recommended start),
|
||||
- (C) full editor with audit log.
|
||||
|
||||
**Q6 — Compound condition grammar.** §6.7 + §7.7. Move to `condition_expr jsonb` with AND/OR/NOT, or stay with `condition_flag text[]` AND-only and live with duplicate rules?
|
||||
|
||||
**Q7 — Cross-proceeding spawn.** §6.8 + §7.8. Wire it (let SmartTimeline chain across proceedings), or accept the current half-wired state?
|
||||
|
||||
**Q8 — Orphan concept seed.** §3.4 + §7.9. Priority order for the 9 missing-rule concepts? My guess: wiedereinsetzung > schriftsatznachreichung > versaeumnisurteil > weiterbehandlung > others. Legal review per concept.
|
||||
|
||||
**Q9 — Instance level on `paliad.projects`.** §6.11 + §7.11. Add `instance_level` column to support the DE_INF / DE_INF_OLG / DE_INF_BGH ladder, or accept that users manually re-pick proceeding_type on appeal?
|
||||
|
||||
**Q10 — Backfill `rule_id` on existing deadlines.** §6.13 + §7.13. Run the one-time fuzzy-match migration, or live with the broken anchor-from-actuals on legacy rows?
|
||||
|
||||
**Q11 — `working_days` and `before` semantics in Pipeline A.** §5.3 + §6.6. Add (recommended) or live without them?
|
||||
|
||||
**Q12 — Court-set as a real column.** §6.5 + §7.5. Promote (cheap win), or keep the heuristic?
|
||||
|
||||
**Q13 — Drop `condition_rule_id` dead column.** §1.6 + §7.10. Drop or wire?
|
||||
|
||||
**Q14 — Phase 2 cadence.** How should we structure the iterative refinement? Options:
|
||||
- (a) m drives via the worker pane — m raises concrete cases ("counterclaim with amendment in expedited proceedings"), worker proposes encoding, commits incrementally.
|
||||
- (b) Inventor (pauli) drafts a Phase 2 design for the §7 extensions in priority order m picks here, m gates.
|
||||
- (c) Mixed: m picks the top 2 from §9 (Q1–Q13) for Phase 2, the rest deferred to Phase 3.
|
||||
|
||||
**Q15 — Phase 3 framing.** Once Phase 2 lands the data-model changes, is the goal:
|
||||
- (a) Build the rule editor (§8 option C), or
|
||||
- (b) Backfill coverage gaps (§7.9), or
|
||||
- (c) Wire SmartTimeline cross-proceeding chains (§7.8), or
|
||||
- (d) Some other priority m has in mind?
|
||||
|
||||
---
|
||||
|
||||
## AUDIT READY FOR REVIEW
|
||||
|
||||
Awaiting m's go/no-go on §9 Q1–Q15 before Phase 2 starts. Inventor (pauli) parks after this commit — no implementation kickoff, no other-skill autoload, m gates the audit → Phase 2 transition.
|
||||
|
||||
Recommended Phase 2 worker: depends on m's Q14 pick. If (a) interactive pair-prog, then pauli or feynman. If (b) inventor design pass, pauli has the freshest context. If (c) mixed, pauli for design, hand off to a Sonnet coder for each landed extension. **NOT cronus per memory directive 2026-05-06.**
|
||||
704
docs/design-determinator-row-cascade-2026-05-13.md
Normal file
704
docs/design-determinator-row-cascade-2026-05-13.md
Normal file
@@ -0,0 +1,704 @@
|
||||
# Design — Determinator B1 row-by-row cascade (replaces breadcrumb drilldown)
|
||||
|
||||
**Author:** pauli (inventor)
|
||||
**Date:** 2026-05-13
|
||||
**Task:** t-paliad-166
|
||||
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
|
||||
**Gitea:** m/paliad#25 (re-opened by m's 2026-05-13 11:17 comment).
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live (before designing)
|
||||
|
||||
CLAUDE.md, mai-memory and the task brief can all be stale by days. Every anchor below is verified against the live codebase or live DB on `mai/pauli/determinator-b1-row-by` (baseline `adf377c` — main as of Slice 1 of t-paliad-179 merge).
|
||||
|
||||
### 0.1 The Pathway B markup today
|
||||
|
||||
`frontend/src/fristenrechner.tsx:227-310` is the Pathway B shell. Four functionally different layers are stacked with four visually different treatments. Live, in source order:
|
||||
|
||||
| Layer | Element | Affordance | Visual |
|
||||
|---|---|---|---|
|
||||
| **L1 Mode** | `.fristen-mode-toggle` | `role=radiogroup` with two `<input type="radio">` | Radio buttons. Tree vs Filter. |
|
||||
| **L2 Perspective** | `.fristen-perspective-bar` | Three `<button>` chips | Pill chips. Kläger / Beklagter / Beide. |
|
||||
| **L3 Inbox** | `.fristen-inbox-bar` | Four `<button>` chips | Pill chips. CMS / beA / Posteingang / Alle. |
|
||||
| **L4 Cascade** | `.fristen-b1-cascade` | Breadcrumb + question + button-grid (drill-down) | Cards in a grid, breadcrumb above. |
|
||||
|
||||
Below L4 sits `.fristen-b1-results` — the concept-card list that narrows as the cascade descends. That's content, not a decision layer.
|
||||
|
||||
**m's critique is exact:** L1/L2/L3/L4 are all "narrow the deadline-rule space" steps with the same conceptual weight, but the user sees a radio, two pill strips, and a card grid. The cascade itself (L4) hides previous steps behind a breadcrumb — so when you've drilled three levels deep you can no longer see "I picked CMS → vom Gericht → Hinweisbeschluss" in one glance unless you read tiny breadcrumb crumbs.
|
||||
|
||||
### 0.2 The cascade engine today
|
||||
|
||||
`frontend/src/client/fristenrechner.ts:2405-2574` (`renderB1Cascade`). For a given `?b1=<slug>`:
|
||||
|
||||
1. Build `trail = buildBreadcrumb(roots, currentSlug)`. The trail is the ancestors of the current node.
|
||||
2. Render `<nav class="fristen-b1-breadcrumb">` = root-reset + `›`-separated crumb buttons.
|
||||
3. Render `<p class="fristen-b1-question">` = the current node's `step_question_de` (or `"Was ist passiert?"` at root).
|
||||
4. Render `<div class="fristen-b1-buttons">` = child nodes as button cards (icon + label, `--leaf` modifier on terminal nodes).
|
||||
5. Render `<button class="fristen-b1-step-back">` = "← Eine Stufe zurück".
|
||||
|
||||
Drilling = `navigateB1(child.slug)` = `pushState` + `renderB1Cascade(child.slug)`. The previous question disappears; only the breadcrumb crumb survives as text. **There is no "row of answered decisions."**
|
||||
|
||||
### 0.3 Where narrowing happens today
|
||||
|
||||
`fristenrechner.ts:2509-2522` filters cascade children by two predicates before rendering:
|
||||
|
||||
- `inboxFilterAllowsForums(c.forums)` — hides nodes whose `forums` tag doesn't match `activeForumOnPage()`. The active forum is resolved at `fristenrechner.ts:2960-2970` with a three-input precedence chain:
|
||||
1. **Inbox chip** (`cms` → `upc`, `bea` / `posteingang` → `de`). User override beats everything.
|
||||
2. **Ad-hoc chip** from Step 1's explore-mode bypass (`upc` / `de` / `epa` / `dpma`).
|
||||
3. **Project context** (`project.proceeding_type_id` → `proceeding_types.code` → prefix → `upc` / `de` / `epa` / `dpma`).
|
||||
- `perspectiveAllowsParty(c.party)` — hides leaves whose `party` tag contradicts the perspective chip. t-paliad-164 already auto-fills the chip from `project.our_side`.
|
||||
|
||||
**So project-driven narrowing for the FORUM axis is shipped.** What m is asking for in this task is (a) generalize the pattern so MORE rows get pre-answered, (b) make the answered-state visible in the same row format, (c) hide rows whose answer is fully implied (UPC project + L3 Inbox).
|
||||
|
||||
### 0.4 The taxonomy and rule corpus
|
||||
|
||||
Live data, `paliad.event_categories` (recursive tree, t-paliad-133):
|
||||
|
||||
- **6 root buckets** under `(root)`: `cms-eingang` ("Von wem ist das Schriftstück?"), `muendl-verhandlung` ("Mündliche Verhandlung"), `beschluss-entscheidung` ("Beschluss / Entscheidung"), `frist-verpasst` ("Frist verpasst"), `ich-moechte-einreichen` ("Ich möchte etwas einreichen"), `sonstiges` (terminal leaf).
|
||||
- **103 leaves total.** 91 carry a `forums` tag (`upc` / `de` / `epa` / `dpma`); 12 are neutral. 16 leaves carry a `party` tag — all under `ich-moechte-einreichen.*` (claimant / defendant) — the perspective filter touches outgoing filings only, never incoming Gegenseiten-Schriftstücke (which are symmetric: you receive what the other side sent regardless of who you are).
|
||||
- Cascade depth varies 2–4 levels. Slug encodes the path with dots, e.g. `cms-eingang.gegenseite.upc-inf.klageschrift` is 4 segments deep.
|
||||
|
||||
`paliad.proceeding_types`:
|
||||
|
||||
- **20 `category='fristenrechner'` codes** (the wizard / B1 cascade vocabulary): `UPC_INF`, `UPC_REV`, `UPC_APP`, `UPC_APP_ORDERS`, `UPC_COST_APPEAL`, `UPC_DAMAGES`, `UPC_DISCOVERY`, `UPC_PI`, `DE_INF`, `DE_INF_OLG`, `DE_INF_BGH`, `DE_NULL`, `DE_NULL_BGH`, `DPMA_OPP`, `DPMA_BPATG_BESCHWERDE`, `DPMA_BGH_RB`, `EPA_OPP`, `EPA_APP`, `EP_GRANT`.
|
||||
- **7 `category='litigation'` codes** (the project model's vocabulary): `INF`, `REV`, `CCR`, `APM`, `APP`, `AMD`, `ZPO_CIVIL`. All `jurisdiction='UPC'` except `ZPO_CIVIL`.
|
||||
- **The two vocabularies overlap conceptually but not row-wise.** Mapping `litigation_code × jurisdiction → fristenrechner_code` is required for Akte-derived narrowing beyond the 4-letter forum prefix. The brief lists this mapping; the live data confirms it's the only path.
|
||||
|
||||
`paliad.deadline_rules.condition_flag` — 4 distinct flag-sets live in production: `[with_amend]`, `[with_cci]`, `[with_ccr]`, `[with_ccr, with_amend]`. Only on `UPC_INF` and `UPC_REV`. This is a Determinator-style variant axis the cascade does not surface today; out of scope for this design.
|
||||
|
||||
### 0.5 Live state of `paliad.projects`
|
||||
|
||||
| Column | Live data shape | Used by today's cascade? |
|
||||
|---|---|---|
|
||||
| `court` | **Free-text.** 4 non-null values across 4 rows: `LG München I` (1), `UPC` (2), `UPC CoA` (1). 7 rows NULL. | No. |
|
||||
| `proceeding_type_id` | FK → `proceeding_types.id`. **11/11 live rows are NULL.** | Yes — `forumFromProject` reads it, but it never fires in production today. |
|
||||
| `our_side` | enum `claimant` / `defendant` / `both` / `court` / NULL. | Yes — t-paliad-164 perspective chip predefine. |
|
||||
| `counterclaim_of` | uuid FK self-reference. | No (relevant for SmartTimeline, not Determinator). |
|
||||
| `filing_date` / `grant_date` | dates. | No (relevant to Verfahrensablauf wizard). |
|
||||
|
||||
**Critical caveat:** 11/11 live projects have NULL `proceeding_type_id`. Until that's backfilled (a separate cleanup), Akte-driven narrowing degrades to "no opinion" for every existing project. The design honours this — silent degrade, no failed-load toast, the cascade simply doesn't narrow. m locked this v1 behaviour with kelvin on 2026-05-13.
|
||||
|
||||
### 0.6 Anchor files for the implementer
|
||||
|
||||
- `frontend/src/fristenrechner.tsx:227-310` — Pathway B markup (the four-layer mess).
|
||||
- `frontend/src/client/fristenrechner.ts:2405-2574` — `renderB1Cascade`.
|
||||
- `frontend/src/client/fristenrechner.ts:2914-3081` — forum + perspective narrowing engine (`activeForumOnPage`, `inboxFilterAllowsForums`, `perspectiveAllowsParty`, `applyOurSidePredefine`).
|
||||
- `frontend/src/styles/global.css:1636-1822` — `.fristen-pathway-shell`, `.fristen-mode-toggle`, `.fristen-b1-breadcrumb`, `.fristen-b1-question`, `.fristen-b1-buttons`, `.fristen-b1-button`, `.fristen-b1-step-back` (the visuals this design overhauls).
|
||||
- `frontend/src/styles/global.css:1965-2065` — `.fristen-inbox-bar`, `.fristen-perspective-bar`, `.fristen-inbox-chip` (the chip strip rules).
|
||||
- `frontend/src/client/views/verfahrensablauf-core.ts` (t-paliad-179) — pure-functional core, verified to carry **zero** Pathway B / cascade code. The lift is clean; this design is independent of it.
|
||||
|
||||
### 0.7 Adjacent design docs
|
||||
|
||||
- `docs/design-tools-cleanup-2026-05-12.md` (kelvin, t-paliad-178). Slice 1 of that shipped today; Slice 2 (Step 0 toggle + Akte auto-derivation on `/tools/fristenrechner`) is adjacent and will share the `litigation_code × jurisdiction → fristenrechner_code` mapping with this design.
|
||||
- `docs/research-determinator-coverage-2026-05-08.md` (curie, t-paliad-167). Identified leaves missing from the cascade. Out of scope here — this design is the UX shell that any future coverage additions will land into.
|
||||
|
||||
If any of these conflict with what the task brief or memory asserts, **the live state wins** and the brief is the bug — flagged in §13 for m.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision + the three pillars
|
||||
|
||||
m's framing (2026-05-13 11:17):
|
||||
|
||||
> When I select a project, it should already narrow down the options (at least if it is a court proceeding). If it is a UPC proceeding, there is no need to show "non-UPC options"; this starts with the "how did you receive it?" which - for the UPC - will always be the UPC CMS.
|
||||
>
|
||||
> Not only is the different format for the levels of the questions weird (this needs an overhaul!), also there is no narrowing at all. I already described before that I want each decision on the tree to remain visible (one row per decision, it may be more compact than the active question was) and then go through things until there are only the least possible options left.
|
||||
|
||||
Three pillars, intertwined:
|
||||
|
||||
### Pillar 1 — Project-driven narrowing
|
||||
Pre-fill or hide decision rows whose answer is implied by the project. UPC project → "Wo kam es an?" is implied (CMS). Project with `our_side` → perspective implied. Project with `proceeding_type_id` → cascade root narrows to the matching forum (and deeper, if mappable).
|
||||
|
||||
### Pillar 2 — Visual hierarchy overhaul
|
||||
All decision layers are **the same primitive**: a row with a question label, an answer-area, and an inline "ändern" affordance. Whether the layer is mode-toggle, perspective, inbox, or a cascade level, the visual shape is identical. The active layer expands inside its row; inactive (answered) layers compact to a single line.
|
||||
|
||||
### Pillar 3 — Row-by-row persistent cascade
|
||||
Replace breadcrumb drilldown with stacked rows. Each answered decision stays visible as a compact row. The active question is the only row that expands. The cascade builds top-to-bottom; the user sees every choice they made in one glance, and the answered rows act as their own affordances for "ändern".
|
||||
|
||||
The pillars interact:
|
||||
|
||||
- Pillar 3 (row layout) needs to know what to skip (Pillar 1 narrowing). A skipped row can render as a compact "(aus Akte) UPC CMS" pseudo-row, or be absent. We pick per row in §5.
|
||||
- Pillar 2 (visual hierarchy) defines how *answered* vs *active* vs *skipped-but-shown* rows look. The four-different-treatments mess gets resolved by a single `.fristen-row` primitive.
|
||||
- Pillar 1 (narrowing) also affects *initial state*: in Akte-mode, several rows may render as already-answered on page load. The cascade jumps to the first un-answered row.
|
||||
|
||||
---
|
||||
|
||||
## 2. The row primitive
|
||||
|
||||
The whole new layout is built from one element shape. Call it `.fristen-row` (the existing `.fristen-b1-*` class names get retired or rebased).
|
||||
|
||||
```text
|
||||
┌─ .fristen-row ──────────────────────────────────────────────────────┐
|
||||
│ .fristen-row-num .fristen-row-label .fristen-row-edit │
|
||||
│ [1] Wie suchen? [ändern] │
|
||||
│ .fristen-row-body │
|
||||
│ ✓ Schritt-für-Schritt │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Three states:
|
||||
|
||||
### 2.1 `state="active"` — the user is answering this row
|
||||
|
||||
```text
|
||||
┌─ .fristen-row.is-active ────────────────────────────────────────────┐
|
||||
│ [3] Von wem ist das Schriftstück? │
|
||||
│ │
|
||||
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||
│ │ ⚖️ │ │ 🏛️ │ │ ✉️ │ │
|
||||
│ │ Vom Gericht │ │ Von der │ │ Vom Patent- │ │
|
||||
│ │ │ │ Gegenseite │ │ amt │ │
|
||||
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||
│ │
|
||||
│ ← zurück │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Same chip-style buttons regardless of which row it is. Mode pick = two big chips. Perspective = three chips. Inbox = four chips. Cascade step = N chips, one per child node. Leaf cascade chips get a subtle modifier (`.fristen-row-chip--leaf`) so the user can see "this one ends the cascade".
|
||||
|
||||
### 2.2 `state="answered"` — the user has picked, but the answer is below
|
||||
|
||||
```text
|
||||
┌─ .fristen-row.is-answered ──────────────────────────────────────────┐
|
||||
│ [1] Wie suchen? ✓ Schritt-für-Schritt │
|
||||
│ [ändern] │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Single line. The label, the picked answer, an "ändern" affordance. Click anywhere on the row (or the explicit ändern link) re-opens the row as active and drops every row below it. (This matches the existing breadcrumb-click semantic: jumping back to an ancestor invalidates descendants.)
|
||||
|
||||
### 2.3 `state="prefilled"` — derived from the project (or other auto-source)
|
||||
|
||||
```text
|
||||
┌─ .fristen-row.is-answered.is-prefilled ─────────────────────────────┐
|
||||
│ [2] Ich vertrete ✓ Klägerseite │
|
||||
│ aus Akte: HL-2024-001 [ändern] │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Visually identical to `is-answered` but carries a small "aus Akte: <reference>" tag and a slightly muted background. Clicking ändern flips it to active (and drops the prefilled marker — the user has now made an explicit choice).
|
||||
|
||||
This generalises t-paliad-164's perspective predefine: same shape, same hint, same override-by-click semantics. The hint becomes a row-level token rather than a one-off `<span>` next to the chip strip.
|
||||
|
||||
### 2.4 `state="hidden"` — row is implied by an earlier pre-fill
|
||||
|
||||
A row that adds no information given upstream rows can be omitted entirely. e.g. UPC project → forum is `upc` → inbox row's only valid answer is "CMS" → the row simply doesn't render. We **do not** render a `is-hidden` placeholder; the absence is the affordance. (This is m's "no need to show non-UPC options".)
|
||||
|
||||
The first user-actionable row floats up under the prefilled stack.
|
||||
|
||||
### 2.5 Why one primitive
|
||||
|
||||
The current four-layer mess works against m because each layer looks like a different *kind* of question. The row primitive collapses that: every decision row carries the same "label + answer + ändern" anatomy. The user reads top-to-bottom; the answered rows stack as a paper trail; the active row is the only thing that demands interaction.
|
||||
|
||||
This also implicitly solves the row-count tax of m's "see your selections" ask: the rows compact to ~28px each when answered, so even a deep cascade keeps the active question in the upper third of the viewport.
|
||||
|
||||
---
|
||||
|
||||
## 3. Answered / active / prefilled / hidden — visual treatment
|
||||
|
||||
Concrete CSS sketch (Slice 1 will tune; this is the contract):
|
||||
|
||||
| Token | Active | Answered | Prefilled | Hidden |
|
||||
|---|---|---|---|---|
|
||||
| `min-height` | auto (chips wrap) | `28px` | `28px` | 0 (not rendered) |
|
||||
| `background` | `var(--surface-card)` | `transparent` | `color-mix(var(--color-accent) 4%, transparent)` | n/a |
|
||||
| `border-left` | `4px solid var(--color-accent)` | none | `4px solid var(--color-accent-faded)` | n/a |
|
||||
| `font-weight` (label) | 600 | 500 | 500 | n/a |
|
||||
| `font-weight` (answer) | n/a | 600 | 600 | n/a |
|
||||
| `cursor` | default | pointer (whole row) | pointer (whole row) | n/a |
|
||||
| `ändern` affordance | hidden | shown on hover + always on focus-within | always shown | n/a |
|
||||
| Row number badge | accent-filled | outlined | outlined (faded) | n/a |
|
||||
|
||||
**No `::before { inset: 0 }` overlay tricks.** The whole-row click is wired via a JS handler that calls `reopenRow(idx)` and skips clicks on `<a>` / `<button>` inside the row body — same pattern as `.entity-table` and the project-detail Verlauf items (CLAUDE.md anchor under "Whole-card / whole-row click").
|
||||
|
||||
Active vs answered transition: when the user picks an answer in an active row, the row collapses to `is-answered` and the **next un-prefilled row materialises as active**. The DOM is preserved across the transition (row stack is one container with `data-state` attribute switched on each row); the chip set inside the answered row replaces with the single ✓-prefixed answer span.
|
||||
|
||||
For the prefilled state's "aus Akte: <reference>" tag — reference comes from `project.reference` (e.g. `HL-2024-001`), falling back to the first 8 chars of `project.id` if no reference. Click on the reference tag is a navigation shortcut to the project (open in new tab — keeps the Fristenrechner state intact).
|
||||
|
||||
---
|
||||
|
||||
## 4. Project-driven narrowing — data mapping
|
||||
|
||||
What can we derive from a selected project, and where does each derivation land?
|
||||
|
||||
### 4.1 Mapping table
|
||||
|
||||
| Derivation | Source column(s) | Maps to | Pre-fills row | Hides row? |
|
||||
|---|---|---|---|---|
|
||||
| **Forum** (upc / de / epa / dpma) | `proceeding_type_id` → `proceeding_types.code` prefix. Fallback: `court` free-text contains UPC/LG/OLG/BGH/BPatG/EPA/DPMA. | Cascade filter (existing `inboxFilterAllowsForums`). | "Wo kam es an?" if forum=UPC (→ CMS). DE: prefills nothing (beA vs Posteingang is a Postal Realität, not on the project). | UPC: yes. DE/EPA/DPMA: no. |
|
||||
| **Perspective** | `project.our_side` ∈ {claimant, defendant} | Cascade filter (existing `perspectiveAllowsParty`). | "Ich vertrete" → Klägerseite / Beklagtenseite. `both` / `court` / NULL: no prefill. | No — even when prefilled, the row stays visible (the user needs to see "ah yes, I'm the Beklagte here"). |
|
||||
| **Proceeding type** | `proceeding_type_id` + jurisdiction → fristenrechner code via `mapLitigationToFristenrechner()` (new helper, shared with t-paliad-178 Slice 2) | Cascade depth: prunes root buckets that don't apply, and prunes inner buckets to those matching the proceeding code. e.g. UPC + INF → only `cms-eingang.gegenseite.upc-inf.*`, `cms-eingang.gericht.urteil-upc-cfi`, etc. | Pre-collapses cascade sub-branches; surfaces deeper-leaf rows directly when only one path applies. | Hides intermediate cascade rows whose only child matches the derived code. |
|
||||
| **Counterclaim** | `counterclaim_of IS NOT NULL` | Implies `with_ccr` / `with_cci` condition flag context. | Not a cascade row today — surfaces as a `condition_flag` chip on the wizard. **Out of scope for this design**; flagged in §13 Q6. | n/a |
|
||||
| **Filing / grant dates** | `filing_date`, `grant_date` | Wizard anchor pre-fill. | Not a cascade row. Out of scope. | n/a |
|
||||
|
||||
### 4.2 Detail: the litigation → fristenrechner mapping
|
||||
|
||||
t-paliad-178 §0 and the task brief both call out: `project.proceeding_type_id` points at the **7 `litigation` codes** (INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL). The cascade speaks **`fristenrechner` codes** (UPC_INF, DE_INF, ...). A small mapping is needed:
|
||||
|
||||
```text
|
||||
INF + UPC → UPC_INF
|
||||
INF + DE → DE_INF (first instance; OLG/BGH not derivable from project)
|
||||
REV + UPC → UPC_REV
|
||||
REV + DE → DE_NULL
|
||||
CCR + UPC → UPC_INF + condition_flag=[with_ccr] (linked via parent's proceeding)
|
||||
CCR + DE → DE_NULL (German Nichtigkeit IS the counterclaim equivalent)
|
||||
APP + UPC → UPC_APP
|
||||
APP + DE → DE_INF_OLG | DE_NULL_BGH (ambiguous — needs court or instance hint; degrade)
|
||||
APM + UPC → UPC_PI
|
||||
AMD + UPC → UPC_INF + condition_flag=[with_amend]
|
||||
ZPO_CIVIL + DE → ZPO civil only; ignore for cascade (no fristenrechner code)
|
||||
```
|
||||
|
||||
The mapping lives in **one** place — a new `internal/services/proceeding_mapping.go` (or the same shared helper t-paliad-178 Slice 2 introduces). The frontend gets the **resolved fristenrechner code** plus `condition_flag` array as part of the project payload (`ProjectOption.derived_fristenrechner_code` + `.derived_condition_flags`).
|
||||
|
||||
**Honest about degrade:** the mapping isn't always 1:1. APP+DE is ambiguous, ZPO_CIVIL has no analogue, and projects without `proceeding_type_id` (all 11 live ones today) get no derivation at all. The cascade falls back to forum-only narrowing in every ambiguous case. **Never silent FK promotion.**
|
||||
|
||||
### 4.3 Detail: court free-text fallback
|
||||
|
||||
When `proceeding_type_id` is NULL but `court` has a recognisable substring:
|
||||
|
||||
```text
|
||||
court contains "UPC" → forum=upc
|
||||
court contains "BPatG" → forum=de (Nichtigkeit / DPMA-Beschwerde)
|
||||
court contains "BGH" → forum=de
|
||||
court contains "OLG" → forum=de
|
||||
court contains "LG" → forum=de
|
||||
court contains "EPA" / "EPO" → forum=epa
|
||||
court contains "DPMA" → forum=dpma
|
||||
otherwise → no narrowing
|
||||
```
|
||||
|
||||
This is a UX nicety, not a correctness mechanism. The fuzzy match always loses to a real `proceeding_type_id` if both are set. Surfaces as the prefilled-row reference tag: "Forum: UPC (aus Gericht: UPC CoA)".
|
||||
|
||||
### 4.4 What the cascade hides given a forum
|
||||
|
||||
`event_categories.forums` is the live signal:
|
||||
|
||||
- 91/103 leaves carry a forum tag.
|
||||
- 12 are neutral (cross-cutting: `frist-verpasst`, `sonstiges`, some Mündl-Verhandlung leaves, court actions).
|
||||
|
||||
With `forum=upc` active, ~73 leaves drop from the cascade. The user sees the same root buckets (cms-eingang / muendl / beschluss / frist-verpasst / ich-moechte-einreichen / sonstiges) but each bucket's children list collapses to the upc-relevant subset. **This is already wired today; the design doesn't change the filter, only its visual presentation.**
|
||||
|
||||
The new contribution: when a non-leaf bucket reduces to a single descendant chain (e.g. UPC project → `cms-eingang` → `gegenseite` → `upc-inf` is the only chain), the cascade should optionally **auto-walk** the chain and surface the leaf parent's siblings directly. §5 below.
|
||||
|
||||
### 4.5 What the cascade hides given perspective
|
||||
|
||||
Currently only the 16 `ich-moechte-einreichen.*` leaves carry `party` tags. So perspective filters outgoing-filing nodes only. Incoming `cms-eingang.gegenseite.*` nodes don't have party tags — receiving from the opposing side is symmetric (you receive what they sent, regardless of who you are). This is correct and doesn't need fixing.
|
||||
|
||||
**Design implication:** the perspective row is *always* visible (rows can never be `is-hidden` based on perspective alone), even when prefilled, because its filter affects user-write decisions that the user might still want to override. Match t-paliad-164.
|
||||
|
||||
---
|
||||
|
||||
## 5. What gets pre-answered, hidden, or skipped-but-shown
|
||||
|
||||
A concrete matrix per row, given live data + the rules above:
|
||||
|
||||
| Row | Question | Pre-fill source | UPC project | DE project | EPA / DPMA project | No project (ad-hoc) | No project (zero ctx) |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| **R0 Mode** | Wie suchen? | none | active | active | active | active | active |
|
||||
| **R1 Perspective** | Ich vertrete | `project.our_side` | prefilled iff `our_side` ∈ {claimant, defendant}; else active | same | same (rare for EPA/DPMA — usually only `court` or NULL) | active | active |
|
||||
| **R2 Inbox** | Wo kam es an? | forum derivation | **hidden** (forum=upc ⇒ CMS implied) | active (beA vs Posteingang) | active | active | active |
|
||||
| **R3 Bucket** | Was ist passiert? | none — user always picks the bucket | active | active | active | active | active |
|
||||
| **R4..Rn Cascade** | per-node `step_question_de` | proceeding-code derivation can pre-walk a single-child chain | optionally auto-walks single-child chains | same | same | active | active |
|
||||
|
||||
Notes:
|
||||
|
||||
- **R0 Mode**: kept active in all cases. The user always picks Tree vs Filter (or skips R0 entirely if we ditch the mode toggle — see §6). The mode pick is meta and not derivable from the project.
|
||||
- **R1 Perspective**: a project with `our_side='both'` is rare but legitimate; it lands as active. `'court'` is even rarer (m's project model includes a "we are the court" perspective for hypothetical training scenarios). For now: `court` → active row.
|
||||
- **R2 Inbox**: m's literal ask. UPC → hidden. DE → active (because beA vs Posteingang is meaningful for downstream Phase-0 manual workflows even if the cascade filter doesn't care). EPA/DPMA → active (e.g. EPA online filing vs Post). The "Alle" chip stays for "I don't know yet".
|
||||
- **R3 Bucket**: the 6 root buckets are always shown. Even with a derived proceeding code, the user still has to say "I'm here because I received something / mündl. Verhandlung / Urteil / etc." This is too coarse to derive.
|
||||
- **R4..Rn Cascade auto-walk**: when a derived proceeding code reduces a bucket's children to a single chain, the cascade should pre-walk that chain. e.g. UPC + INF + `cms-eingang` bucket → only `gegenseite.upc-inf.*` chain survives → R4 `gegenseite` is pre-answered (with the "aus Akte" badge), R5 jumps directly to `upc-inf` (also pre-answered), and R6 is the active question "Welcher Schriftsatz?". The user sees four R-rows (R0, R1 prefilled, R3 picked, R4 prefilled, R5 prefilled, R6 active) — clean paper trail of inference + one active question.
|
||||
|
||||
**Important constraint:** auto-walk is **descendants-of-the-picked-bucket only**. R3 (bucket) is always active because the bucket is the user's intent. We never auto-pick the bucket. So a UPC project doesn't pre-pick "cms-eingang" for you; it just makes the sub-cascade efficient once you've said "cms-eingang".
|
||||
|
||||
### 5.1 Compact summary diagram — UPC INF project drilling into a cms-eingang opposing-side schriftsatz
|
||||
|
||||
```text
|
||||
┌─ Step 1: Akte (Step 1 surface, above Pathway B) ────────────────────┐
|
||||
│ Akte: HL-2024-001 — Acme v. Globex (UPC INF) [Andere Akte] │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
┌─ [1] Wie suchen? ✓ Schritt-für-Schritt [ändern]┐
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
┌─ [2] Ich vertrete ✓ Klägerseite [ändern]┐
|
||||
│ aus Akte: HL-2024-001│
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
⨯ Row R2 (Inbox) hidden — UPC implies CMS
|
||||
┌─ [3] Was ist passiert? ✓ CMS-Eingang [ändern]┐
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
┌─ [4] Von wem ist das Schriftstück? ✓ Von der Gegenseite [ändern]┐
|
||||
│ aus Akte (UPC INF impliziert)│
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
┌─ [5] Welches Verfahren? ✓ UPC Verletzungsverfahren │
|
||||
│ aus Akte: HL-2024-001 │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
┌─ [6] Welcher Schriftsatz wurde eingereicht? (active, awaiting pick)│
|
||||
│ │
|
||||
│ ┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐ │
|
||||
│ │ Klageschrift │ │ Klageerwiderung │ │ Replik │ │
|
||||
│ │ (R.13) │ │ + Widerklagen │ │ │ │
|
||||
│ └──────────────────┘ └──────────────────┘ └──────────────────┘ │
|
||||
│ ... (rest of UPC_INF Schriftsätze) │
|
||||
│ │
|
||||
│ ← zurück │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Six rows. Three user picks (mode, bucket, leaf). Three Akte-derived prefills. One R2 absent. The user sees their full decision path at a glance.
|
||||
|
||||
For comparison, today's UI: the user clicks four times into the cascade, the top of the page is two chip strips and a radio they didn't touch, the breadcrumb at the top of `.fristen-b1-cascade` shows three crumb buttons in 12pt text, and there's no inline indication that the cascade is narrower than the full taxonomy. m's "no narrowing at all" is the literal reading of what's visible.
|
||||
|
||||
### 5.2 Compact summary diagram — DE project drilling into the same
|
||||
|
||||
```text
|
||||
┌─ [1] Wie suchen? ✓ Schritt-für-Schritt [ändern]┐
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
┌─ [2] Ich vertrete ✓ Klägerseite [ändern]┐
|
||||
│ aus Akte: HL-2024-002│
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
┌─ [3] Wo kam es an? (active, awaiting pick)┐
|
||||
│ │
|
||||
│ ┌──────┐ ┌──────────────┐ ┌──────┐ │
|
||||
│ │ beA │ │ Posteingang │ │ Alle │ │
|
||||
│ └──────┘ └──────────────┘ └──────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
... and the cascade continues below once R3 is answered.
|
||||
```
|
||||
|
||||
R2 (Inbox) is active because beA vs Posteingang is a real distinction for German projects. The forum is already known (`de`), so the cascade below R3 will be DE-only — but the user still tells us *how* the document arrived.
|
||||
|
||||
### 5.3 Compact summary diagram — abstract / no-Akte mode
|
||||
|
||||
```text
|
||||
┌─ [1] Wie suchen? (active, awaiting pick)┐
|
||||
│ │
|
||||
│ ┌────────────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Schritt-für-Schritt │ │ Filter / Suche │ │
|
||||
│ │ (Entscheidungsbaum) │ │ │ │
|
||||
│ └────────────────────────┘ └─────────────────────┘ │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
No prefills, no hidden rows. Every row is asked. Full taxonomy.
|
||||
|
||||
---
|
||||
|
||||
## 6. Filter / Suche mode — coexistence with the cascade
|
||||
|
||||
Today's mode toggle (radio) is a UX wart: it's the only radio on the page, it looks unlike everything else, and it sits at the top of Pathway B as if it were a primary axis.
|
||||
|
||||
Two options to fold it into the row model:
|
||||
|
||||
### Option A — Mode is R0, a row like any other
|
||||
|
||||
The mode toggle becomes the first row in the stack. Two chips. Pick determines what populates below: tree picker → R3 + cascade. Filter picker → R3 collapses into a search input + result list. The row stays visible (you can switch mid-flow via ändern), but the chrome is consistent.
|
||||
|
||||
Pros: simple, every decision is a row, the page reads top-to-bottom.
|
||||
Cons: adds one always-active row to every flow including the "I know what I'm doing, just give me search" use case.
|
||||
|
||||
### Option B — Mode is an escape hatch, not a row
|
||||
|
||||
Filter is positioned as "ich weiß schon, wonach ich suche" — a small link / icon at the top of Pathway B that toggles between cascade and search. No R0 row. Default = cascade. Click → search replaces the row stack.
|
||||
|
||||
Pros: fewer rows, less for the common case to scan past.
|
||||
Cons: more discoverable than the current radio? unclear. "Where did the radio go?" is a question.
|
||||
|
||||
### Option C — Filter as a *bottom-of-stack* affordance
|
||||
|
||||
Cascade is the only top-down flow. Below the cascade results, a "Sie wissen schon den Namen? → direkt suchen" link / row appears. Search is a graceful fallback, not a peer mode.
|
||||
|
||||
Pros: gives cascade the primary surface, search becomes a tool for "wait, I know better".
|
||||
Cons: discoverability of search is reduced for power users who DO know.
|
||||
|
||||
**Inventor's pick:** Option B. The radio is dead weight, and the search use case is "I know the name; let me skip the cascade" — that's an escape hatch, not a peer axis. Visually: a small `🔍` icon-button at the top-right of Pathway B titled "Direkt suchen". Click expands a search input that replaces the row stack; result list appears below; "← Zurück zum Entscheidungsbaum" returns to the row stack with prior state preserved.
|
||||
|
||||
But this is design-question territory — m's call. §13 Q1.
|
||||
|
||||
---
|
||||
|
||||
## 7. Mobile + responsive
|
||||
|
||||
The row primitive is naturally responsive: rows stack vertically by default. Width concerns only the chip set inside an active row.
|
||||
|
||||
### 7.1 Breakpoints
|
||||
|
||||
`paliad` already uses 640 / 768 / 1023 px breakpoints. The rows live inside `.fristen-pathway-shell` which is already a column-flex.
|
||||
|
||||
| Width | Row chrome | Chip layout (active row) |
|
||||
|---|---|---|
|
||||
| ≥ 1024px | full label + answer + ändern on one line, badge left | chips in a 3-column grid (or auto-fill min 220px) |
|
||||
| 768–1023px | same | chips in a 2-column grid |
|
||||
| 640–767px | label + answer on line 1, ändern on line 2 right-aligned | chips in a 1-column stack |
|
||||
| < 640px | label on line 1, answer on line 2, ändern as `›` icon right-aligned | chips full-width, single column |
|
||||
|
||||
### 7.2 Active-row collapse on tap (mobile-only)
|
||||
|
||||
On `< 768px`, the row stack scrolls; the active row's chip set can be long (e.g. 9 Schriftsatz children). When the user picks an answer, the page autoscrolls so the next active row is at the top of the viewport. This is the same pattern as the Akte picker (Step 1) and existing form flows.
|
||||
|
||||
### 7.3 What we don't do on mobile
|
||||
|
||||
- **No drawer / modal for the cascade.** The whole point of the row stack is being able to see history at a glance; collapsing into a separate surface defeats it.
|
||||
- **No fly-out for ändern.** Tap on an answered row's ändern affordance simply re-activates the row in place.
|
||||
- **No "next" button.** Picking a chip advances automatically; mobile doesn't need an extra tap to confirm.
|
||||
|
||||
---
|
||||
|
||||
## 8. "Neu starten" / Reset semantics
|
||||
|
||||
Three flavours of reset, all need a home:
|
||||
|
||||
### 8.1 Reset the whole cascade (every row to empty)
|
||||
|
||||
Today: clicking the breadcrumb's "Pfad zurücksetzen" root crumb. In the new layout: a small `↺ Pfad zurücksetzen` link at the top of the row stack, right of the heading. Clicking it:
|
||||
- Drops every cascade row (R3+).
|
||||
- Leaves R0 (Mode), R1 (Perspective prefilled), R2 (Inbox if visible) as they are — those are "context", not "the user's investigation".
|
||||
- Re-activates R3.
|
||||
|
||||
Optional behaviour (per Q9): a confirm-dialog if the user has drilled ≥ 3 cascade levels deep. Probably overkill; current breadcrumb root-click is destructive without confirm. Match existing semantic.
|
||||
|
||||
### 8.2 Drop just one decision (ändern semantic)
|
||||
|
||||
Built into every answered row's `[ändern]` affordance and clicking on the row body. Effect: that row reverts to active; every row below it drops; URL ?b1= shortens to that row's prefix.
|
||||
|
||||
This is the workhorse of the row stack — m's "you can see your selections" UX implies "you can also rewind to any of them at any time". Built-in.
|
||||
|
||||
### 8.3 Drop the Akte-derived prefills
|
||||
|
||||
Trickier: if the user clicks ändern on a `is-prefilled` row, the prefill is overridden. But what about "I want to ignore my Akte entirely for this exercise"? The Akte itself is bound at the Step 1 surface, above Pathway B. Clicking "Andere Akte" at the Step 1 summary unbinds the Akte and drops all `is-prefilled` markers. The cascade rows that were `is-answered` because they were prefilled now revert to `is-active` (or, if the user had already explicitly overridden via ändern, stay answered with no `is-prefilled` flag).
|
||||
|
||||
This semantic already half-exists for t-paliad-164's perspective predefine; we generalise it to every prefilled row. Implementation: hold a `prefillSources: Map<rowID, "akte" | "user">` and re-derive on Akte unbind / change.
|
||||
|
||||
### 8.4 The "Neu starten" button at the bottom
|
||||
|
||||
A second affordance at the bottom of the results area, after the user has reached a leaf and is reading concept-cards. "Andere Frist nachschlagen?" → reset to R3. Optional but discoverable; today's UI lacks an equivalent, so this is a small UX win.
|
||||
|
||||
---
|
||||
|
||||
## 9. Search affordance integration
|
||||
|
||||
Tied to §6's mode-toggle question. Two integration points:
|
||||
|
||||
### 9.1 Search panel placement (Option B from §6)
|
||||
|
||||
The `🔍 Direkt suchen` button lives at the top-right of `.fristen-pathway-shell`. Click → animates the row stack out (or simply replaces it), shows a search input row with a single text field + result list below. ESC or "← Zurück zum Entscheidungsbaum" returns; row stack restores via URL state.
|
||||
|
||||
The search is the existing `?q=` + B2 chips flow — we don't rebuild it, just relocate its entry point. Existing forum-filter chip row stays inside the search panel.
|
||||
|
||||
### 9.2 Inline search on each cascade row (rejected)
|
||||
|
||||
An alternative: each cascade row's chip list gets a tiny "filter chips" input at the top. Reject. Adds chrome to every active row for a feature most users don't need.
|
||||
|
||||
### 9.3 "I searched but want to see the path" round-trip
|
||||
|
||||
When the user lands on a leaf via search, optionally show "Im Entscheidungsbaum öffnen → " — clicking restores the row stack with all ancestor rows pre-answered (which is what the cascade's slug already encodes). This is a small extra: lets a search-first user verify "yes, this is the leaf I thought, here's the proceeding context I missed".
|
||||
|
||||
---
|
||||
|
||||
## 10. Slicing for the coder pass
|
||||
|
||||
Three slices, each independently shippable, mergeable in order:
|
||||
|
||||
### Slice 1 — Visual hierarchy + row-by-row layout (no narrowing change)
|
||||
|
||||
Replaces the four-layer mess with the row primitive. **No backend or DB changes.** The narrowing engine stays the same (existing forum + perspective filters fire); the visual presentation moves from breadcrumb + chip strips + radio → row stack.
|
||||
|
||||
In scope:
|
||||
- New `.fristen-row` CSS primitive (with `.is-active`, `.is-answered`, `.is-prefilled` modifiers).
|
||||
- Refactor `renderB1Cascade` into a row-stack renderer (`renderRowStack(rows: RowSpec[])`).
|
||||
- Migrate L1 (mode) / L2 (perspective) / L3 (inbox) / L4..n (cascade) all to row instances.
|
||||
- "ändern" semantic = re-activate row, drop rows below, push history state.
|
||||
- Reset link at top of stack.
|
||||
- i18n keys for row labels.
|
||||
|
||||
Out of scope for Slice 1:
|
||||
- Project-derived proceeding-code narrowing (the `mapLitigationToFristenrechner` helper).
|
||||
- Auto-walk single-child cascade chains.
|
||||
- Hide-R2-on-UPC behaviour (Slice 2 — needs the proceeding mapping helper anyway).
|
||||
- Search affordance relocation (Slice 3).
|
||||
|
||||
Outcome: same data, same narrowing, **vastly better visual narrative**. The user can finally see their decision path. m's pillar 2 + 3 are addressed.
|
||||
|
||||
### Slice 2 — Project-driven narrowing depth
|
||||
|
||||
Adds the `litigation_code × jurisdiction → fristenrechner_code` mapping and uses it to:
|
||||
- Pre-fill the proceeding-type sub-cascade rows (R5 in the §5.1 diagram).
|
||||
- Hide R2 (Inbox) when project is UPC.
|
||||
- Auto-walk single-child chains.
|
||||
- Add the "aus Akte: <reference>" tag on prefilled rows.
|
||||
|
||||
This is where Pillar 1 fully lands. Depends on Slice 1's row primitive.
|
||||
|
||||
Includes a small backend helper (shared with t-paliad-178 Slice 2 if both ship in parallel): `internal/services/proceeding_mapping.go` exposes `MapLitigationToFristenrechner(litCode string, jurisdiction string) (fristenCode string, conditionFlags []string, ok bool)`.
|
||||
|
||||
Outcome: an Akte-bound user starts the cascade with three rows already answered, and only one or two active questions remain to drill to the leaf.
|
||||
|
||||
### Slice 3 — Search affordance + mobile polish
|
||||
|
||||
Relocates the mode-toggle / search affordance per §6 Option B. Adds the responsive breakpoints from §7. Polishes the autoscroll-to-active behaviour on mobile.
|
||||
|
||||
Mobile-only fixes ride here so Slices 1+2 can be reviewed by m at desktop width first.
|
||||
|
||||
### Why this order
|
||||
|
||||
- Slice 1 is purely visual. m can see the row stack and validate the layout BEFORE we change any narrowing semantic. If m hates the row primitive, we revert one PR. (We won't — but the option matters.)
|
||||
- Slice 2 is the heavy correctness lift. It depends on the mapping helper, on Akte payload extensions, and on careful Test_DATABASE_URL integration tests.
|
||||
- Slice 3 is final polish. Independently mergeable, lowest risk.
|
||||
|
||||
Each slice is roughly:
|
||||
- Slice 1: 1 frontend PR (~700 LoC TSX + CSS + client). No backend, no migrations.
|
||||
- Slice 2: 1 mixed PR (~150 LoC Go + 300 LoC client). No migrations.
|
||||
- Slice 3: 1 frontend PR (~150 LoC).
|
||||
|
||||
---
|
||||
|
||||
## 11. Tradeoffs flagged
|
||||
|
||||
### 11.1 Row stack is taller than the current shell
|
||||
|
||||
A deep cascade (4 levels) plus 3 prefilled rows + R0 = 8 rows. Each ~28px compact + the active row's chip body (200–400px depending on chip count) + spacing → ~600–800px tall. The current shell is ~400px tall in the same scenario. Mitigation: rows are compact (28px), active-row autoscrolling keeps the chip set in view on mobile, and the visual narrative wins. m's ask explicitly trades vertical space for visibility.
|
||||
|
||||
### 11.2 "Aus Akte" tags are slightly noisy
|
||||
|
||||
Three rows showing "aus Akte: HL-2024-001" reads a bit redundant. Mitigation: only the first prefilled row shows the reference; subsequent rows show "(aus Akte)" without the reference. Saves vertical noise, keeps the source visible once.
|
||||
|
||||
### 11.3 Auto-walk single-child chains can confuse
|
||||
|
||||
The user picks "cms-eingang" → suddenly two rows materialise pre-answered. Looks magical. Mitigation: the two rows are clearly `is-prefilled` with an "aus Akte (UPC INF impliziert)" tag, and ändern is available on each. After the user has done it twice, the inference becomes a feature; before, a tooltip on first-render ("Diese Schritte ergeben sich aus Ihrer Akte") could help (deferred for v2 — see Q11).
|
||||
|
||||
### 11.4 Removing the radio mode-toggle is a behavioural change
|
||||
|
||||
Existing power users may know the radio. Mitigation: the new `🔍 Direkt suchen` icon-button at the top of Pathway B is a visible affordance; URL ?mode=filter still works as deep-link. Soft transition.
|
||||
|
||||
### 11.5 11/11 live projects have NULL `proceeding_type_id`
|
||||
|
||||
Slice 2's narrowing literally doesn't fire in production today. We're building UX that requires data nobody has yet. Mitigation: graceful degrade (forum-only narrowing via court free-text fuzzy match — already a feature today). Backfill of `proceeding_type_id` is a separate follow-up (see Q13).
|
||||
|
||||
### 11.6 The mapping table in §4.2 has ambiguities
|
||||
|
||||
APP+DE → ambiguous; ZPO_CIVIL → no analogue; CCR ↔ counterclaim modeling is fragile. Mitigation: every ambiguous case degrades to "no narrowing" — the row stays active rather than incorrectly pre-filled. Better silent than wrong.
|
||||
|
||||
### 11.7 ändern-on-an-ancestor invalidates descendants
|
||||
|
||||
Same as today's breadcrumb-click semantic — clicking a non-current crumb drops cascade depth. **No data is lost** (you can re-walk the cascade), but if the user was reading concept-cards at a leaf, those cards disappear. Mitigation: when ändern is clicked on an answered row, before dropping descendants, brief inline confirmation? Or just match today's behaviour (drop immediately). Inventor recommends match-today; Q12.
|
||||
|
||||
### 11.8 The row primitive may be over-engineered
|
||||
|
||||
A single visual primitive for four functionally different layers is a strong opinion. If a future cascade layer (e.g. variant chips for `condition_flag`) doesn't fit the primitive shape, we have to either extend the primitive or break the consistency. Mitigation: the primitive is shape (label + answer-area + ändern), not behaviour — variant chips fit because they're also "pick one (or several)". The contract is loose enough.
|
||||
|
||||
---
|
||||
|
||||
## 12. Files the implementer will touch (Slice 1 only)
|
||||
|
||||
### 12.1 Frontend
|
||||
|
||||
- **`frontend/src/fristenrechner.tsx:227-310`** — Pathway B markup. Replace `.fristen-mode-toggle` + `.fristen-perspective-bar` + `.fristen-inbox-bar` + `.fristen-b1-cascade` with a single `.fristen-row-stack` container. Add minimal scaffolding rows for mode / perspective / inbox / cascade-host. Keep `.fristen-b1-results` below — unchanged.
|
||||
- **`frontend/src/client/fristenrechner.ts:2405-2574`** — Refactor `renderB1Cascade` into `renderRowStack(rows)`. The row spec is a discriminated union: `{kind: "mode" | "perspective" | "inbox" | "cascade", state: "active" | "answered" | "prefilled", question, options[], picked?}`. Rendering is one function per state; one switch on `kind` for the options builder.
|
||||
- **`frontend/src/client/fristenrechner.ts:2914-3081`** — `inboxFilterAllowsForums` + `perspectiveAllowsParty` unchanged (Slice 1 is visual-only).
|
||||
- **`frontend/src/client/fristenrechner.ts:initInboxFilter`** + perspective init — same handlers, new DOM targets.
|
||||
- **`frontend/src/client/i18n.ts`** — ~20 new keys under `deadlines.row.*` (row labels, ändern affordance, prefilled tag, reset link, "next active" autoscroll-target announce).
|
||||
- **`frontend/src/styles/global.css:1636-1822` + `:1965-2065`** — Retire `.fristen-mode-toggle`, `.fristen-perspective-bar`, `.fristen-inbox-bar`, `.fristen-b1-breadcrumb`, `.fristen-b1-question`, `.fristen-b1-buttons`, `.fristen-b1-button*`. Add `.fristen-row-stack`, `.fristen-row`, `.fristen-row-num`, `.fristen-row-label`, `.fristen-row-answer`, `.fristen-row-edit`, `.fristen-row-body`, `.fristen-row-chip`, `.fristen-row-chip--leaf`, `.is-active`, `.is-answered`, `.is-prefilled`.
|
||||
|
||||
### 12.2 Backend
|
||||
|
||||
No backend changes for Slice 1. The existing `/api/tools/fristenrechner/event-categories` and `/api/tools/fristenrechner/search` endpoints are unchanged.
|
||||
|
||||
### 12.3 Tests
|
||||
|
||||
- Pure-TS unit tests for `buildRowStack(currentState)` if extracted (table-driven: given URL state + Akte payload, output the RowSpec[]).
|
||||
- Playwright smoke (post-deploy): land on Pathway B with `?path=b&project=<uuid>`, verify R1 prefilled with "aus Akte", R2 hidden for UPC project, ändern on R1 reopens, ändern on bucket drops cascade depth.
|
||||
|
||||
### 12.4 Anchoring back
|
||||
|
||||
t-paliad-164 perspective predefine code is the precedent. Re-read it before implementing — same hint mechanism, same override semantics, generalised.
|
||||
|
||||
t-paliad-178 Slice 2 (Step 0 toggle + Akte auto-derivation) is parallel; coordinate on the shared `proceeding_mapping.go` helper file (Slice 2 of this task introduces it; t-paliad-178 Slice 2 can adopt or vice versa, depending on which lands first).
|
||||
|
||||
---
|
||||
|
||||
## 13. Open questions for m
|
||||
|
||||
These are inventor's calls flagged for m's gate. Picking is on m, not the coder.
|
||||
|
||||
**Q1 — Mode-toggle disposition.** Three options in §6: (A) R0 row, (B) escape-hatch icon-button [inventor's pick], (C) bottom-of-stack affordance. Pick one or specify another.
|
||||
|
||||
**Q2 — UPC project: hide R2 entirely or show as compact prefilled?**
|
||||
- Hide entirely (inventor's pick — matches m's "no need to show non-UPC options").
|
||||
- Show as compact `[2] Wo kam es an? ✓ UPC CMS [ändern] aus Akte` row — verbose but explicit.
|
||||
|
||||
**Q3 — Auto-walk single-child cascade chains?**
|
||||
- Yes, materialise R4..Rn-1 as prefilled (inventor's pick — strong UX, but feels magical first time).
|
||||
- No, the user always picks their way down even when only one child applies (slower, more predictable).
|
||||
- Yes-but-only-when-≥-2-rows-collapse (tradeoff).
|
||||
|
||||
**Q4 — "ändern" affordance shape on an answered row.**
|
||||
- Hover-revealed link "ändern" (inventor's pick — keeps row clean by default).
|
||||
- Always-visible pencil icon (more discoverable but more chrome).
|
||||
- Whole-row click is the only handle (cleanest, but no visible affordance — newcomers won't discover it).
|
||||
|
||||
**Q5 — Drop confirmation when ändern invalidates descendants?**
|
||||
- No (match today's breadcrumb-click — inventor's pick).
|
||||
- Yes, when ≥ 3 cascade levels would be dropped.
|
||||
- Always — even a one-row drop confirms.
|
||||
|
||||
**Q6 — Counterclaim awareness in the cascade.**
|
||||
`project.counterclaim_of IS NOT NULL` implies `[with_ccr]` or `[with_cci]` condition flag depending on the parent's proceeding code. Should this surface as a prefilled row (e.g. "Variante: with_ccr"), or only as a backend filter on the result concept cards (silent)?
|
||||
- Surface as a prefilled row (transparency — user sees the variant is active).
|
||||
- Silent backend filter (no row tax, but mystery narrowing).
|
||||
- Out of scope for this design — handle in a separate variant-chip task.
|
||||
|
||||
**Q7 — R0 mode-pick deep link.**
|
||||
If a user lands on `?path=b` without `?mode=`, do we default to tree or to "no R0 picked yet"?
|
||||
- Default to tree, R0 prefilled (today's behaviour — silent).
|
||||
- R0 active until the user picks (more explicit, but adds one extra click for the common case).
|
||||
|
||||
**Q8 — Prefilled-row override permanence.**
|
||||
After the user clicks ändern on a prefilled R1 (perspective) and explicitly picks "Beklagter" instead of the Akte's "Kläger", does this override persist if they re-bind the same Akte?
|
||||
- No, re-bind re-applies (today's behaviour — clean, but overrides feel ephemeral).
|
||||
- Yes, store override per-Akte in localStorage (sticky overrides — UX-friendly, but new state).
|
||||
|
||||
**Q9 — Reset confirm.**
|
||||
A "Pfad zurücksetzen" link at the top of the row stack — confirm dialog?
|
||||
- No confirm — match today's breadcrumb root-click (inventor's pick).
|
||||
- Confirm if cascade depth ≥ 3.
|
||||
- Always confirm.
|
||||
|
||||
**Q10 — Search escape-hatch position.**
|
||||
Per §6 / §9, the `🔍 Direkt suchen` button sits at the top-right of Pathway B.
|
||||
- Top-right (inventor's pick — discoverable, doesn't push down the row stack).
|
||||
- Below the row stack, after results.
|
||||
- As a permanent row at the bottom of the stack.
|
||||
|
||||
**Q11 — First-visit tooltip on auto-walked rows.**
|
||||
"Diese Schritte ergeben sich aus Ihrer Akte" tooltip on the first prefilled-from-mapping row, dismissed forever on first close?
|
||||
- Yes (helps onboarding).
|
||||
- No (extra chrome; the "aus Akte" tag is enough).
|
||||
- Inline help-icon (?) link to a docs page (longer-form).
|
||||
|
||||
**Q12 — Concept cards live below the row stack today. Should they collapse / hide when the user reopens an ancestor row (ändern)?**
|
||||
- Collapse/hide on ändern, repopulate when the cascade reaches a leaf again (inventor's pick — matches the "no orphan content" rule).
|
||||
- Keep visible as last-known until cascade resolves to a new leaf.
|
||||
|
||||
**Q13 — Backfill `paliad.projects.proceeding_type_id`?**
|
||||
11/11 live rows are NULL. Slice 2's narrowing depends on this. Should the Slice 2 PR also include a one-off Akte-edit nudge ("Projekt-Setup vervollständigen: Verfahrensart fehlt"), or do we wait until m manually fills them in over time?
|
||||
- Inline "Verfahrensart ergänzen" link on Akten with NULL proceeding_type_id.
|
||||
- Backfill script (inferring from `court` free-text where unambiguous).
|
||||
- Defer entirely; live with degraded narrowing until users fill it organically.
|
||||
|
||||
**Q14 — Reorder rows so prefilled stack at top, user-picked at bottom?**
|
||||
The §5.1 diagram orders rows R0..Rn in their natural cascade sequence (mode → perspective → inbox → bucket → cascade depth). The prefilled rows happen to be R1, R4, R5 (not contiguous). Alternative: visually float all prefilled rows to a single "aus Akte" group at the top, with user-picked rows below. Tradeoff: cleaner separation vs. losing the temporal narrative of the decision path.
|
||||
- Keep natural order (inventor's pick — narrative wins).
|
||||
- Group prefilled at top.
|
||||
|
||||
**Q15 — Should `Filter / Suche` mode also see Akte prefills?**
|
||||
If the user enters search mode with a project bound, do we silently scope results to the project's forum, or show the full taxonomy?
|
||||
- Scope (consistent with cascade narrowing — inventor's pick).
|
||||
- Don't scope (search is a "I know what I'm looking for" mode; the project is incidental).
|
||||
- Scope with a visible toggle "Auch andere Foren anzeigen".
|
||||
|
||||
---
|
||||
|
||||
## DESIGN READY FOR REVIEW
|
||||
|
||||
Awaiting m's go/no-go on the questions in §13 before the coder shift starts. Inventor (pauli) parks after this commit — no implementation kickoff, no other-skill autoload, head gates the transition.
|
||||
|
||||
Recommended implementer: pattern-fluent Sonnet coder. The row primitive is straightforward CSS + a small state machine refactor; the precedent code (t-paliad-164 + t-paliad-133 cascade engine) is well-understood. **NOT cronus per memory directive 2026-05-06.**
|
||||
1078
docs/design-fristen-phase2-2026-05-15.md
Normal file
1078
docs/design-fristen-phase2-2026-05-15.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1158,7 +1158,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.notfound": "Projekt nicht gefunden oder keine Berechtigung.",
|
||||
"projects.detail.smarttimeline.open_chart": "Als Chart anzeigen \u2197",
|
||||
"projects.chart.title": "Projekt-Chart \u2014 Paliad",
|
||||
"projects.chart.back": "\u2190 Zur\u00fcck zum Projekt",
|
||||
"projects.chart.back": "\u2190 Zur\u00fcck zum Verlauf",
|
||||
"projects.chart.loading": "L\u00e4dt\u2026",
|
||||
"projects.chart.notfound": "Projekt nicht gefunden oder keine Berechtigung.",
|
||||
"projects.chart.error.mount": "Chart konnte nicht initialisiert werden.",
|
||||
@@ -1167,6 +1167,33 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.chart.control.density.standard": "Dichte: Standard",
|
||||
"projects.chart.control.palette.default": "Palette: Standard",
|
||||
"projects.chart.control.export.soon": "Export \u2193 (Slice 2)",
|
||||
"projects.chart.control.palette.label": "Palette:",
|
||||
"projects.chart.palette.default": "Standard",
|
||||
"projects.chart.palette.kind_coded": "Nach Ereignistyp",
|
||||
"projects.chart.palette.track_coded": "Nach Spur",
|
||||
"projects.chart.palette.high_contrast": "Hoher Kontrast",
|
||||
"projects.chart.palette.print": "Druck (S/W)",
|
||||
"projects.chart.control.density.label": "Dichte:",
|
||||
"projects.chart.density.compact": "Kompakt",
|
||||
"projects.chart.density.standard": "Standard",
|
||||
"projects.chart.density.spacious": "Großzügig",
|
||||
"projects.chart.control.range.label": "Zeitraum:",
|
||||
"projects.chart.range.1y": "1 Jahr",
|
||||
"projects.chart.range.2y": "2 Jahre",
|
||||
"projects.chart.range.all": "Alles anzeigen",
|
||||
"projects.chart.range.custom": "Eigener Bereich…",
|
||||
"projects.chart.range.from": "Von:",
|
||||
"projects.chart.range.to": "Bis:",
|
||||
"projects.chart.permalink.copy": "🔗 Link kopieren",
|
||||
"projects.chart.permalink.title": "URL mit allen Filtern in die Zwischenablage kopieren",
|
||||
"nav.context.project_chart": "Als Chart anzeigen",
|
||||
"projects.chart.export.menu": "⇓ Export",
|
||||
"projects.chart.export.svg": "SVG (Vektorgrafik)",
|
||||
"projects.chart.export.png": "PNG (Bild, 2× HiDPI)",
|
||||
"projects.chart.export.print": "PDF (Drucken)",
|
||||
"projects.chart.export.csv": "CSV (Excel-Tabelle)",
|
||||
"projects.chart.export.json": "JSON (Rohdaten)",
|
||||
"projects.chart.export.ics": "iCal (.ics — Outlook / Apple)",
|
||||
"projects.detail.edit": "Bearbeiten",
|
||||
"projects.detail.edit.modal.title": "Projekt bearbeiten",
|
||||
"projects.detail.save": "Speichern",
|
||||
@@ -2179,6 +2206,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.shape.list": "Liste",
|
||||
"views.shape.cards": "Karten",
|
||||
"views.shape.calendar": "Kalender",
|
||||
"views.shape.timeline": "Timeline",
|
||||
"views.timeline.caveat.body": "Custom Views zeigen nur eingetretene Ereignisse. Für prognostizierte Fristen das Projekt-Chart öffnen.",
|
||||
"views.save_as": "Als Ansicht speichern",
|
||||
"views.action.edit": "Bearbeiten",
|
||||
"views.empty.title": "Keine Einträge gefunden.",
|
||||
@@ -3466,7 +3495,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.notfound": "Project not found or no access.",
|
||||
"projects.detail.smarttimeline.open_chart": "View as chart \u2197",
|
||||
"projects.chart.title": "Project Chart \u2014 Paliad",
|
||||
"projects.chart.back": "\u2190 Back to project",
|
||||
"projects.chart.back": "\u2190 Back to Activity",
|
||||
"projects.chart.loading": "Loading\u2026",
|
||||
"projects.chart.notfound": "Project not found or no access.",
|
||||
"projects.chart.error.mount": "Chart could not be initialised.",
|
||||
@@ -3475,6 +3504,33 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.chart.control.density.standard": "Density: standard",
|
||||
"projects.chart.control.palette.default": "Palette: default",
|
||||
"projects.chart.control.export.soon": "Export \u2193 (Slice 2)",
|
||||
"projects.chart.control.palette.label": "Palette:",
|
||||
"projects.chart.palette.default": "Default",
|
||||
"projects.chart.palette.kind_coded": "By event kind",
|
||||
"projects.chart.palette.track_coded": "By track",
|
||||
"projects.chart.palette.high_contrast": "High contrast",
|
||||
"projects.chart.palette.print": "Print (B/W)",
|
||||
"projects.chart.control.density.label": "Density:",
|
||||
"projects.chart.density.compact": "Compact",
|
||||
"projects.chart.density.standard": "Standard",
|
||||
"projects.chart.density.spacious": "Spacious",
|
||||
"projects.chart.control.range.label": "Range:",
|
||||
"projects.chart.range.1y": "1 year",
|
||||
"projects.chart.range.2y": "2 years",
|
||||
"projects.chart.range.all": "Show all",
|
||||
"projects.chart.range.custom": "Custom range…",
|
||||
"projects.chart.range.from": "From:",
|
||||
"projects.chart.range.to": "To:",
|
||||
"projects.chart.permalink.copy": "🔗 Copy link",
|
||||
"projects.chart.permalink.title": "Copy the URL with all filters to clipboard",
|
||||
"nav.context.project_chart": "View as chart",
|
||||
"projects.chart.export.menu": "⇓ Export",
|
||||
"projects.chart.export.svg": "SVG (vector graphic)",
|
||||
"projects.chart.export.png": "PNG (raster, 2× HiDPI)",
|
||||
"projects.chart.export.print": "PDF (print)",
|
||||
"projects.chart.export.csv": "CSV (Excel table)",
|
||||
"projects.chart.export.json": "JSON (raw data)",
|
||||
"projects.chart.export.ics": "iCal (.ics — Outlook / Apple)",
|
||||
"projects.detail.edit": "Edit",
|
||||
"projects.detail.edit.modal.title": "Edit project",
|
||||
"projects.detail.save": "Save",
|
||||
@@ -4483,6 +4539,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.shape.list": "List",
|
||||
"views.shape.cards": "Cards",
|
||||
"views.shape.calendar": "Calendar",
|
||||
"views.shape.timeline": "Timeline",
|
||||
"views.timeline.caveat.body": "Custom Views show actual events only. Open the project's chart for projected rules.",
|
||||
"views.save_as": "Save as view",
|
||||
"views.action.edit": "Edit",
|
||||
"views.empty.title": "No matches found.",
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { mount, type ChartHandle } from "./views/shape-timeline-chart";
|
||||
import {
|
||||
ALL_DENSITIES,
|
||||
ALL_PALETTES,
|
||||
ALL_RANGE_PRESETS,
|
||||
mount,
|
||||
type ChartHandle,
|
||||
type Density,
|
||||
type Palette,
|
||||
type RangePreset,
|
||||
} from "./views/shape-timeline-chart";
|
||||
import {
|
||||
exportCSV,
|
||||
exportJSON,
|
||||
exportPNG,
|
||||
exportPrint,
|
||||
exportSVG,
|
||||
type ExportContext,
|
||||
} from "./views/chart-export";
|
||||
|
||||
// t-paliad-177 Slice 1 — boot client for the standalone Project Timeline
|
||||
// / Chart page. Reads the project id from the URL path, loads the
|
||||
@@ -25,6 +42,117 @@ function projectIdFromPath(): string | null {
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
const PALETTE_SET: ReadonlySet<string> = new Set(ALL_PALETTES);
|
||||
|
||||
/** Reads ?palette=... from the URL; returns the default when missing /
|
||||
* unknown so a hostile or stale URL can't break the chart. */
|
||||
function paletteFromURL(): Palette {
|
||||
const raw = new URLSearchParams(window.location.search).get("palette");
|
||||
if (raw && PALETTE_SET.has(raw)) return raw as Palette;
|
||||
return "default";
|
||||
}
|
||||
|
||||
/** Mirrors paletteFromURL but for writing — pushes a new history entry
|
||||
* so the URL stays bookmarkable / shareable per design §8.2. */
|
||||
function writePaletteToURL(palette: Palette): void {
|
||||
writeParamToURL("palette", palette, "default");
|
||||
}
|
||||
|
||||
const DENSITY_SET: ReadonlySet<string> = new Set(ALL_DENSITIES);
|
||||
|
||||
function densityFromURL(): Density {
|
||||
const raw = new URLSearchParams(window.location.search).get("density");
|
||||
if (raw && DENSITY_SET.has(raw)) return raw as Density;
|
||||
return "standard";
|
||||
}
|
||||
|
||||
function writeDensityToURL(density: Density): void {
|
||||
writeParamToURL("density", density, "standard");
|
||||
}
|
||||
|
||||
const RANGE_SET: ReadonlySet<string> = new Set(ALL_RANGE_PRESETS);
|
||||
|
||||
interface RangeState {
|
||||
preset: RangePreset;
|
||||
from?: string;
|
||||
to?: string;
|
||||
}
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
function rangeFromURL(): RangeState {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get("range");
|
||||
const preset: RangePreset = raw && RANGE_SET.has(raw) ? (raw as RangePreset) : "1y";
|
||||
if (preset === "custom") {
|
||||
const from = params.get("from") || "";
|
||||
const to = params.get("to") || "";
|
||||
return {
|
||||
preset,
|
||||
from: ISO_DATE_RE.test(from) ? from : undefined,
|
||||
to: ISO_DATE_RE.test(to) ? to : undefined,
|
||||
};
|
||||
}
|
||||
return { preset };
|
||||
}
|
||||
|
||||
function writeRangeToURL(state: RangeState): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (state.preset === "1y") {
|
||||
params.delete("range");
|
||||
} else {
|
||||
params.set("range", state.preset);
|
||||
}
|
||||
if (state.preset === "custom") {
|
||||
if (state.from) params.set("from", state.from);
|
||||
else params.delete("from");
|
||||
if (state.to) params.set("to", state.to);
|
||||
else params.delete("to");
|
||||
} else {
|
||||
params.delete("from");
|
||||
params.delete("to");
|
||||
}
|
||||
const qs = params.toString();
|
||||
const next = window.location.pathname + (qs ? "?" + qs : "");
|
||||
window.history.replaceState(null, "", next);
|
||||
}
|
||||
|
||||
/** Read ?lanes=id1,id2 from the URL. Empty / missing → null (show all).
|
||||
* Defence: ids that look hostile (commas embedded, oversized) are dropped
|
||||
* on render via the renderer's allow-set intersection. */
|
||||
function lanesFromURL(): string[] | null {
|
||||
const raw = new URLSearchParams(window.location.search).get("lanes");
|
||||
if (!raw) return null;
|
||||
const ids = raw.split(",").map((s) => s.trim()).filter((s) => s.length > 0 && s.length < 200);
|
||||
return ids.length === 0 ? null : ids;
|
||||
}
|
||||
|
||||
function writeLanesToURL(lanes: string[] | null): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (!lanes || lanes.length === 0) {
|
||||
params.delete("lanes");
|
||||
} else {
|
||||
params.set("lanes", lanes.join(","));
|
||||
}
|
||||
const qs = params.toString();
|
||||
const next = window.location.pathname + (qs ? "?" + qs : "");
|
||||
window.history.replaceState(null, "", next);
|
||||
}
|
||||
|
||||
/** Shared URL writer — omits the param when it equals its default, so the
|
||||
* canonical URL stays short and dedupable. */
|
||||
function writeParamToURL(name: string, value: string, defaultValue: string): void {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (value === defaultValue) {
|
||||
params.delete(name);
|
||||
} else {
|
||||
params.set(name, value);
|
||||
}
|
||||
const qs = params.toString();
|
||||
const next = window.location.pathname + (qs ? "?" + qs : "");
|
||||
window.history.replaceState(null, "", next);
|
||||
}
|
||||
|
||||
async function loadProject(id: string): Promise<Project | null> {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`);
|
||||
@@ -69,8 +197,11 @@ async function boot(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wire back-link to the project's detail page.
|
||||
if (backLink) backLink.href = `/projects/${encodeURIComponent(id)}`;
|
||||
// Wire back-link to the Verlauf tab specifically — projects-detail.ts
|
||||
// reads the /history sub-path on init and switches to that tab. Going
|
||||
// back to the bare /projects/{id} also lands on Verlauf today, but the
|
||||
// /history form is explicit + survives a future default-tab change.
|
||||
if (backLink) backLink.href = `/projects/${encodeURIComponent(id)}/history`;
|
||||
|
||||
if (titleEl) titleEl.textContent = project.title || t("projects.chart.title");
|
||||
if (metaEl) metaEl.textContent = formatMeta(project);
|
||||
@@ -78,14 +209,126 @@ async function boot(): Promise<void> {
|
||||
loadingEl.style.display = "none";
|
||||
bodyEl.style.display = "";
|
||||
|
||||
const initialPalette = paletteFromURL();
|
||||
const initialDensity = densityFromURL();
|
||||
const initialRange = rangeFromURL();
|
||||
const initialLanes = lanesFromURL();
|
||||
let handle: ChartHandle | null = null;
|
||||
// Module-scope mirrors so the chip click handlers (rendered later)
|
||||
// can reach the live state without threading it through callbacks.
|
||||
moduleVisibleLanes = initialLanes;
|
||||
try {
|
||||
handle = mount(host, { projectId: id });
|
||||
handle = mount(host, {
|
||||
projectId: id,
|
||||
palette: initialPalette,
|
||||
density: initialDensity,
|
||||
rangePreset: initialRange.preset,
|
||||
rangeFrom: initialRange.from,
|
||||
rangeTo: initialRange.to,
|
||||
visibleLanes: initialLanes,
|
||||
onDataLoaded: ({ lanes }) => {
|
||||
renderLaneFilter(lanes);
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("chart mount failed", err);
|
||||
host.textContent = t("projects.chart.error.mount");
|
||||
return;
|
||||
}
|
||||
moduleHandleRef = handle;
|
||||
|
||||
// Wire the palette picker. Reflect the URL-decoded initial value, then
|
||||
// re-write the URL + flip the data-palette attribute on every change.
|
||||
const paletteSel = document.getElementById("projects-chart-palette") as HTMLSelectElement | null;
|
||||
if (paletteSel) {
|
||||
paletteSel.value = initialPalette;
|
||||
paletteSel.addEventListener("change", () => {
|
||||
const next = paletteSel.value;
|
||||
if (!PALETTE_SET.has(next)) return;
|
||||
const p = next as Palette;
|
||||
handle!.setPalette(p);
|
||||
writePaletteToURL(p);
|
||||
});
|
||||
}
|
||||
|
||||
// Density picker — same URL-state pattern. Density triggers a repaint
|
||||
// (lane height + mark radius change), palette is a pure CSS swap.
|
||||
const densitySel = document.getElementById("projects-chart-density") as HTMLSelectElement | null;
|
||||
if (densitySel) {
|
||||
densitySel.value = initialDensity;
|
||||
densitySel.addEventListener("change", () => {
|
||||
const next = densitySel.value;
|
||||
if (!DENSITY_SET.has(next)) return;
|
||||
const d = next as Density;
|
||||
handle!.setDensity(d);
|
||||
writeDensityToURL(d);
|
||||
});
|
||||
}
|
||||
|
||||
// Range chips — 4-option select plus a custom date-pair that shows
|
||||
// only when preset === "custom". Per design §8.2 + faraday-Q8 default.
|
||||
const rangeSel = document.getElementById("projects-chart-range") as HTMLSelectElement | null;
|
||||
const rangeCustomWrap = document.getElementById("projects-chart-range-custom");
|
||||
const rangeFromInput = document.getElementById("projects-chart-range-from") as HTMLInputElement | null;
|
||||
const rangeToInput = document.getElementById("projects-chart-range-to") as HTMLInputElement | null;
|
||||
if (rangeSel && rangeCustomWrap && rangeFromInput && rangeToInput) {
|
||||
rangeSel.value = initialRange.preset;
|
||||
if (initialRange.preset === "custom") {
|
||||
rangeCustomWrap.style.display = "";
|
||||
if (initialRange.from) rangeFromInput.value = initialRange.from;
|
||||
if (initialRange.to) rangeToInput.value = initialRange.to;
|
||||
}
|
||||
const applyRange = () => {
|
||||
const preset = rangeSel.value;
|
||||
if (!RANGE_SET.has(preset)) return;
|
||||
const p = preset as RangePreset;
|
||||
rangeCustomWrap.style.display = p === "custom" ? "" : "none";
|
||||
const from = rangeFromInput.value || undefined;
|
||||
const to = rangeToInput.value || undefined;
|
||||
handle!.setRange(p, from, to);
|
||||
writeRangeToURL({ preset: p, from, to });
|
||||
};
|
||||
rangeSel.addEventListener("change", applyRange);
|
||||
rangeFromInput.addEventListener("change", applyRange);
|
||||
rangeToInput.addEventListener("change", applyRange);
|
||||
}
|
||||
|
||||
// Export menu. Each button maps to one chart-export function; the
|
||||
// handle exposes the live SVG + last-fetched data needed to compose
|
||||
// an ExportContext. Errors land in the host's message area so the
|
||||
// user gets feedback instead of a silent failure.
|
||||
function ctxNow(): ExportContext {
|
||||
const data = handle!.getData();
|
||||
return {
|
||||
projectId: id,
|
||||
projectTitle: project.title || t("projects.chart.title"),
|
||||
svgEl: handle!.getSVGElement(),
|
||||
events: data.events,
|
||||
lanes: data.lanes,
|
||||
};
|
||||
}
|
||||
function runExport(fn: (ctx: ExportContext) => void | Promise<void>): void {
|
||||
void Promise.resolve()
|
||||
.then(() => fn(ctxNow()))
|
||||
.catch((err) => {
|
||||
console.error("export failed", err);
|
||||
if (host) {
|
||||
host.setAttribute("data-export-error", "1");
|
||||
}
|
||||
});
|
||||
}
|
||||
wirePermalinkCopy("projects-chart-copylink");
|
||||
|
||||
wireExport("projects-chart-export-svg", () => runExport(exportSVG));
|
||||
wireExport("projects-chart-export-png", () => runExport(exportPNG));
|
||||
wireExport("projects-chart-export-csv", () => runExport(exportCSV));
|
||||
wireExport("projects-chart-export-json", () => runExport(exportJSON));
|
||||
wireExport("projects-chart-export-print", () => exportPrint());
|
||||
// iCal goes server-side so it reuses the existing caldav_ical formatter
|
||||
// (faraday-Q6 / m's pick: deadlines + appointments only — no projected).
|
||||
wireExport("projects-chart-export-ics", () => {
|
||||
window.location.href = `/api/projects/${encodeURIComponent(id)}/timeline.ics`;
|
||||
});
|
||||
|
||||
// After the first paint, surface the undated hint when the renderer
|
||||
// reports clipped/undated rows. Re-checked on resize-debounced repaint.
|
||||
@@ -106,6 +349,141 @@ async function boot(): Promise<void> {
|
||||
setTimeout(checkUndated, 1500);
|
||||
}
|
||||
|
||||
/** Render the lane-filter chip group once the renderer has lanes from
|
||||
* the server. One toggle button per lane; clicking flips inclusion in
|
||||
* the visible-lane allow-set. Hidden when there's only one lane (or
|
||||
* none) — the filter is pointless on a single-track render. */
|
||||
function renderLaneFilter(lanes: ReadonlyArray<{ id: string; label: string }>): void {
|
||||
const container = document.getElementById("projects-chart-lanes-filter");
|
||||
if (!container) return;
|
||||
// Hide and bail when the filter wouldn't add value.
|
||||
if (lanes.length < 2) {
|
||||
container.innerHTML = "";
|
||||
container.style.display = "none";
|
||||
return;
|
||||
}
|
||||
container.style.display = "";
|
||||
container.innerHTML = "";
|
||||
const titleEl = document.createElement("span");
|
||||
titleEl.className = "smart-timeline-chart-lanes-label";
|
||||
titleEl.textContent = "Spuren:";
|
||||
container.appendChild(titleEl);
|
||||
for (const lane of lanes) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "smart-timeline-chart-lane-chip";
|
||||
const isVisible = laneIsVisible(lane.id);
|
||||
btn.setAttribute("aria-pressed", isVisible ? "true" : "false");
|
||||
btn.dataset.laneId = lane.id;
|
||||
btn.textContent = lane.label || lane.id;
|
||||
btn.addEventListener("click", () => {
|
||||
toggleLane(lane.id, lanes);
|
||||
// Reflect new state immediately on this button + siblings.
|
||||
for (const sibling of container.querySelectorAll<HTMLButtonElement>("button[data-lane-id]")) {
|
||||
const sid = sibling.dataset.laneId || "";
|
||||
sibling.setAttribute("aria-pressed", laneIsVisible(sid) ? "true" : "false");
|
||||
}
|
||||
});
|
||||
container.appendChild(btn);
|
||||
}
|
||||
}
|
||||
|
||||
/** Permalink copy. The URL already aggregates every chip's state via the
|
||||
* individual writeParamToURL writers (palette + density + range + lanes),
|
||||
* so window.location.href IS the canonical shareable link. We copy it
|
||||
* to the clipboard and flash a "kopiert" confirmation on the button. */
|
||||
function wirePermalinkCopy(buttonId: string): void {
|
||||
const btn = document.getElementById(buttonId) as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
const originalLabel = btn.textContent || "";
|
||||
let resetTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
btn.addEventListener("click", async () => {
|
||||
const url = window.location.href;
|
||||
const ok = await copyToClipboard(url);
|
||||
if (resetTimer) clearTimeout(resetTimer);
|
||||
btn.textContent = ok ? "✓ Kopiert" : "⚠ Konnte nicht kopieren";
|
||||
btn.classList.add(ok ? "is-success" : "is-error");
|
||||
resetTimer = setTimeout(() => {
|
||||
btn.textContent = originalLabel;
|
||||
btn.classList.remove("is-success", "is-error");
|
||||
}, 1800);
|
||||
});
|
||||
}
|
||||
|
||||
async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// Prefer the async Clipboard API. Falls back to the legacy exec hack
|
||||
// for browsers / contexts where it's unavailable (some iframes, file://).
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
try {
|
||||
const textarea = document.createElement("textarea");
|
||||
textarea.value = text;
|
||||
textarea.style.position = "fixed";
|
||||
textarea.style.opacity = "0";
|
||||
document.body.appendChild(textarea);
|
||||
textarea.select();
|
||||
const ok = document.execCommand("copy");
|
||||
document.body.removeChild(textarea);
|
||||
return ok;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function wireExport(buttonId: string, handler: () => void): void {
|
||||
const btn = document.getElementById(buttonId) as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
handler();
|
||||
// Close the <details> dropdown so the user sees the chart-area
|
||||
// update (download notification, print preview, etc).
|
||||
const details = btn.closest("details");
|
||||
if (details) details.removeAttribute("open");
|
||||
});
|
||||
}
|
||||
|
||||
// Lane-filter mutable state lives at module scope so renderLaneFilter
|
||||
// closures over the same set as toggleLane / laneIsVisible. We can't
|
||||
// access boot()'s `visibleLanes` from here cleanly, so we mirror it.
|
||||
let moduleVisibleLanes: string[] | null = null;
|
||||
let moduleHandleRef: ChartHandle | null = null;
|
||||
|
||||
function laneIsVisible(id: string): boolean {
|
||||
if (moduleVisibleLanes === null) return true;
|
||||
return moduleVisibleLanes.includes(id);
|
||||
}
|
||||
|
||||
function toggleLane(id: string, allLanes: ReadonlyArray<{ id: string }>): void {
|
||||
if (moduleVisibleLanes === null) {
|
||||
// Currently "show all" — turning a chip off means everyone except this one.
|
||||
moduleVisibleLanes = allLanes.map((l) => l.id).filter((l) => l !== id);
|
||||
} else if (moduleVisibleLanes.includes(id)) {
|
||||
moduleVisibleLanes = moduleVisibleLanes.filter((l) => l !== id);
|
||||
} else {
|
||||
moduleVisibleLanes = [...moduleVisibleLanes, id];
|
||||
}
|
||||
// If user toggled every lane back on, collapse to null (show all).
|
||||
if (moduleVisibleLanes.length === allLanes.length) {
|
||||
moduleVisibleLanes = null;
|
||||
}
|
||||
// If user toggled every lane off, snap back to null too — an empty
|
||||
// chart is never useful, treat as "you didn't mean that, show all".
|
||||
if (moduleVisibleLanes !== null && moduleVisibleLanes.length === 0) {
|
||||
moduleVisibleLanes = null;
|
||||
}
|
||||
if (moduleHandleRef) {
|
||||
moduleHandleRef.setVisibleLanes(moduleVisibleLanes);
|
||||
}
|
||||
writeLanesToURL(moduleVisibleLanes);
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
void boot();
|
||||
});
|
||||
|
||||
@@ -73,6 +73,7 @@ export function initSidebar() {
|
||||
initInboxBadge();
|
||||
initAdminGroup();
|
||||
initPaliadinLinks();
|
||||
initProjectContextChartLink();
|
||||
initUserViewsGroup();
|
||||
initThemeToggle();
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
@@ -549,6 +550,31 @@ function initPaliadinLinks(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// initProjectContextChartLink (t-paliad-177 Slice 3) reveals an "Als Chart
|
||||
// anzeigen" entry in the sidebar when the user is browsing a project
|
||||
// detail page. Hidden everywhere else, hidden on the chart page itself
|
||||
// (the chart is the destination, not the source).
|
||||
//
|
||||
// Self-contained on URL parsing — no per-page handshake needed. Pages
|
||||
// don't have to know about the sidebar slot; this function walks the
|
||||
// pathname and renders the link if it matches.
|
||||
//
|
||||
// Layout intent: chip sits directly under the "Übersicht" group so it's
|
||||
// visible on every project sub-tab (Verlauf / Team / Parteien / …).
|
||||
function initProjectContextChartLink(): void {
|
||||
const link = document.getElementById("sidebar-project-chart-link") as HTMLAnchorElement | null;
|
||||
if (!link) return;
|
||||
const match = /^\/projects\/([0-9a-fA-F-]{36})(\/.*)?$/.exec(window.location.pathname);
|
||||
if (!match) return;
|
||||
const id = match[1];
|
||||
const rest = match[2] || "";
|
||||
// Hide on the chart page itself — a reciprocal "Zurück zum Verlauf"
|
||||
// affordance lives on the chart page header (separate slice).
|
||||
if (rest === "/chart" || rest === "/chart/") return;
|
||||
link.href = `/projects/${encodeURIComponent(id)}/chart`;
|
||||
link.style.display = "";
|
||||
}
|
||||
|
||||
// initAdminGroup reveals the Admin section in the sidebar when the caller's
|
||||
// /api/me lookup confirms global_role='global_admin'. The markup is in the
|
||||
// DOM with display:none for everyone — flipping it on after the fetch lands
|
||||
|
||||
@@ -4,6 +4,8 @@ import type { FilterSpec, RenderSpec, ViewRunResult, UserView, RenderShape } fro
|
||||
import { renderListShape } from "./views/shape-list";
|
||||
import { renderCardsShape } from "./views/shape-cards";
|
||||
import { renderCalendarShape } from "./views/shape-calendar";
|
||||
import { renderTimelineShape } from "./views/shape-timeline-cv";
|
||||
import type { ChartHandle } from "./views/shape-timeline-chart";
|
||||
|
||||
// /views and /views/{slug} client. Loads the saved or system view, runs
|
||||
// it via /api/views/{slug}/run, and dispatches to the matching render-
|
||||
@@ -143,7 +145,7 @@ async function runAndRender(meta: ViewMeta): Promise<void> {
|
||||
}
|
||||
|
||||
function setActiveShape(shape: RenderShape): void {
|
||||
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar"]) {
|
||||
for (const host of ["views-shape-list", "views-shape-cards", "views-shape-calendar", "views-shape-timeline"]) {
|
||||
const el = document.getElementById(host);
|
||||
if (el) el.hidden = !host.endsWith("-" + shape);
|
||||
}
|
||||
@@ -152,9 +154,17 @@ function setActiveShape(shape: RenderShape): void {
|
||||
});
|
||||
}
|
||||
|
||||
let timelineHandle: ChartHandle | null = null;
|
||||
|
||||
function renderShape(shape: RenderShape, render: RenderSpec, rows: ViewRunResult["rows"]): void {
|
||||
const host = document.getElementById(`views-shape-${shape}`);
|
||||
if (!host) return;
|
||||
// Switching away from timeline → dispose the prior chart handle so we
|
||||
// don't leak resize listeners / SVG nodes between shape flips.
|
||||
if (shape !== "timeline" && timelineHandle) {
|
||||
timelineHandle.dispose();
|
||||
timelineHandle = null;
|
||||
}
|
||||
switch (shape) {
|
||||
case "list":
|
||||
renderListShape(host, rows, render);
|
||||
@@ -165,6 +175,47 @@ function renderShape(shape: RenderShape, render: RenderSpec, rows: ViewRunResult
|
||||
case "calendar":
|
||||
renderCalendarShape(host, rows, render);
|
||||
break;
|
||||
case "timeline": {
|
||||
// Tear down any previous chart inside this host before re-mounting
|
||||
// (the CV adapter clears chart-host innerHTML on its own, but we
|
||||
// need to dispose the prior handle's resize/click listeners too).
|
||||
if (timelineHandle) {
|
||||
timelineHandle.dispose();
|
||||
timelineHandle = null;
|
||||
}
|
||||
const chartHost = document.getElementById("views-timeline-chart-host");
|
||||
if (chartHost) {
|
||||
timelineHandle = renderTimelineShape(chartHost, rows, render);
|
||||
}
|
||||
maybeShowTimelineCaveat();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** First-open caveat banner. sessionStorage flag means the user sees it
|
||||
* once per browser session — dismissive but not annoying. Design §13.4
|
||||
* documents the limitation; this is the user-facing surface. */
|
||||
function maybeShowTimelineCaveat(): void {
|
||||
const FLAG = "paliad-views-timeline-caveat-dismissed";
|
||||
const banner = document.getElementById("views-timeline-caveat");
|
||||
const closeBtn = document.getElementById("views-timeline-caveat-close");
|
||||
if (!banner) return;
|
||||
if (sessionStorage.getItem(FLAG) === "1") {
|
||||
banner.hidden = true;
|
||||
return;
|
||||
}
|
||||
banner.hidden = false;
|
||||
if (closeBtn && !closeBtn.dataset.bound) {
|
||||
closeBtn.addEventListener("click", () => {
|
||||
banner.hidden = true;
|
||||
try {
|
||||
sessionStorage.setItem(FLAG, "1");
|
||||
} catch {
|
||||
/* sessionStorage may be unavailable in strict modes — silently noop */
|
||||
}
|
||||
});
|
||||
closeBtn.dataset.bound = "1";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
274
frontend/src/client/views/chart-export.ts
Normal file
274
frontend/src/client/views/chart-export.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
|
||||
|
||||
// chart-export (t-paliad-177 Slice 2) — client-side export helpers for
|
||||
// the Project Timeline / Chart page.
|
||||
//
|
||||
// Five formats land in Slice 2 (per design §7.1, m's pick on faraday-Q4
|
||||
// to rule out server-side PDF via chromedp):
|
||||
//
|
||||
// SVG — XMLSerializer of the live SVG element
|
||||
// PNG — SVG → <img> → <canvas> at 2× HiDPI, toBlob("image/png")
|
||||
// PDF — window.print() with @media print stylesheet (browser handles
|
||||
// the PDF engine; no chromedp dep on Dokploy)
|
||||
// CSV — flat tabular dump of TimelineEvent[] (UTF-8 BOM for Excel-DE)
|
||||
// JSON — wire envelope verbatim + export-metadata header
|
||||
//
|
||||
// iCal lands in a follow-up commit (C5) and goes via a server-side
|
||||
// endpoint that reuses internal/services/caldav_ical.go (faraday-Q6).
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §7.
|
||||
|
||||
export interface ExportContext {
|
||||
projectId: string;
|
||||
projectTitle: string;
|
||||
svgEl: SVGSVGElement;
|
||||
events: ReadonlyArray<TimelineEvent>;
|
||||
lanes: ReadonlyArray<LaneInfo>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public surface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function exportSVG(ctx: ExportContext): Promise<void> {
|
||||
const svgString = serialiseSVG(ctx.svgEl);
|
||||
const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
|
||||
triggerDownload(blob, filename(ctx, "svg"));
|
||||
}
|
||||
|
||||
export async function exportPNG(ctx: ExportContext): Promise<void> {
|
||||
const svgString = serialiseSVG(ctx.svgEl);
|
||||
const blob = await rasterise(svgString, ctx.svgEl);
|
||||
if (!blob) {
|
||||
throw new Error("PNG raster failed");
|
||||
}
|
||||
triggerDownload(blob, filename(ctx, "png"));
|
||||
}
|
||||
|
||||
export function exportCSV(ctx: ExportContext): void {
|
||||
const rows: string[][] = [csvHeader()];
|
||||
for (const event of ctx.events) {
|
||||
rows.push(csvRow(event, ctx));
|
||||
}
|
||||
// UTF-8 BOM keeps Excel-DE from mis-detecting ANSI; ISO-8601 dates
|
||||
// round-trip correctly into German Excel as text.
|
||||
const text = "" + rows.map(csvLine).join("\r\n") + "\r\n";
|
||||
const blob = new Blob([text], { type: "text/csv;charset=utf-8" });
|
||||
triggerDownload(blob, filename(ctx, "csv"));
|
||||
}
|
||||
|
||||
export function exportJSON(ctx: ExportContext): void {
|
||||
const envelope = {
|
||||
project_id: ctx.projectId,
|
||||
project_title: ctx.projectTitle,
|
||||
exported_at: new Date().toISOString(),
|
||||
events: ctx.events,
|
||||
lanes: ctx.lanes,
|
||||
};
|
||||
const text = JSON.stringify(envelope, null, 2) + "\n";
|
||||
const blob = new Blob([text], { type: "application/json;charset=utf-8" });
|
||||
triggerDownload(blob, filename(ctx, "json"));
|
||||
}
|
||||
|
||||
export function exportPrint(): void {
|
||||
// The @media print stylesheet in global.css does the layout work;
|
||||
// we just invoke the browser's print dialog. User picks "Save as PDF"
|
||||
// (Chrome/Edge), "Drucken in Datei" (Firefox), etc.
|
||||
window.print();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG / PNG plumbing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function serialiseSVG(svgEl: SVGSVGElement): string {
|
||||
// Clone so we can inline computed styles without polluting the live DOM.
|
||||
// For a true cross-environment-portable SVG, we'd compute every used
|
||||
// CSS-var into a literal value. v1 keeps it light: the receiver inherits
|
||||
// colours via document context when opened standalone, and the rendered
|
||||
// bars still work because palette tokens fall through to the .smart-
|
||||
// timeline-chart root selector via inline class. Add a fallback width /
|
||||
// height attribute so headless viewers don't render 0×0.
|
||||
const clone = svgEl.cloneNode(true) as SVGSVGElement;
|
||||
if (!clone.getAttribute("width") && svgEl.getAttribute("width")) {
|
||||
clone.setAttribute("width", svgEl.getAttribute("width") || "1000");
|
||||
}
|
||||
if (!clone.getAttribute("height") && svgEl.getAttribute("height")) {
|
||||
clone.setAttribute("height", svgEl.getAttribute("height") || "400");
|
||||
}
|
||||
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
||||
|
||||
// Inline the chart's computed palette tokens so the standalone SVG
|
||||
// paints the same way when opened in an image viewer (which has no
|
||||
// document.css). Read every --chart-* property off the live element.
|
||||
const computed = window.getComputedStyle(svgEl);
|
||||
const styleLines: string[] = [];
|
||||
for (const prop of [
|
||||
"--chart-mark-deadline",
|
||||
"--chart-mark-appointment",
|
||||
"--chart-mark-milestone",
|
||||
"--chart-mark-projected",
|
||||
"--chart-mark-overdue",
|
||||
"--chart-mark-done",
|
||||
"--chart-today-rule",
|
||||
"--chart-grid-line",
|
||||
"--chart-lane-label",
|
||||
"--chart-tick-label",
|
||||
"--chart-bg",
|
||||
]) {
|
||||
const val = computed.getPropertyValue(prop).trim();
|
||||
if (val) styleLines.push(`${prop}: ${val};`);
|
||||
}
|
||||
if (styleLines.length > 0) {
|
||||
const existing = clone.getAttribute("style") || "";
|
||||
clone.setAttribute("style", existing + styleLines.join(" "));
|
||||
}
|
||||
|
||||
return new XMLSerializer().serializeToString(clone);
|
||||
}
|
||||
|
||||
async function rasterise(svgString: string, svgEl: SVGSVGElement): Promise<Blob | null> {
|
||||
const widthAttr = svgEl.getAttribute("width") || "1000";
|
||||
const heightAttr = svgEl.getAttribute("height") || "400";
|
||||
const width = Number(widthAttr) || 1000;
|
||||
const height = Number(heightAttr) || 400;
|
||||
// 2× device pixel ratio for HiDPI exports (design §7.1 "PNG, 2× HiDPI").
|
||||
const scale = 2;
|
||||
|
||||
const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
try {
|
||||
const img = await loadImage(url);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.round(width * scale);
|
||||
canvas.height = Math.round(height * scale);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
return await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob((b) => resolve(b), "image/png");
|
||||
});
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error("Image load failed"));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSV plumbing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CSV_COLUMNS = [
|
||||
"project_id",
|
||||
"project_title",
|
||||
"kind",
|
||||
"status",
|
||||
"track",
|
||||
"lane_id",
|
||||
"date",
|
||||
"title",
|
||||
"description",
|
||||
"rule_code",
|
||||
"depends_on_rule_code",
|
||||
"depends_on_date",
|
||||
"depends_on_rule_name",
|
||||
"sub_project_id",
|
||||
"sub_project_title",
|
||||
"bubble_up",
|
||||
"deadline_id",
|
||||
"appointment_id",
|
||||
"project_event_id",
|
||||
"project_event_type",
|
||||
] as const;
|
||||
|
||||
function csvHeader(): string[] {
|
||||
return [...CSV_COLUMNS];
|
||||
}
|
||||
|
||||
function csvRow(event: TimelineEvent, ctx: ExportContext): string[] {
|
||||
return [
|
||||
ctx.projectId,
|
||||
ctx.projectTitle,
|
||||
event.kind,
|
||||
event.status,
|
||||
event.track,
|
||||
event.lane_id ?? "",
|
||||
isoOnly(event.date),
|
||||
event.title,
|
||||
event.description ?? "",
|
||||
event.rule_code ?? "",
|
||||
event.depends_on_rule_code ?? "",
|
||||
isoOnly(event.depends_on_date),
|
||||
event.depends_on_rule_name ?? "",
|
||||
event.sub_project_id ?? "",
|
||||
event.sub_project_title ?? "",
|
||||
event.bubble_up ? "true" : "false",
|
||||
event.deadline_id ?? "",
|
||||
event.appointment_id ?? "",
|
||||
event.project_event_id ?? "",
|
||||
event.project_event_type ?? "",
|
||||
];
|
||||
}
|
||||
|
||||
function csvLine(fields: string[]): string {
|
||||
return fields.map(csvEscape).join(",");
|
||||
}
|
||||
|
||||
/** RFC 4180 quoting: double quotes inside the field are doubled; wrap
|
||||
* the whole field in quotes if it contains comma / quote / newline. */
|
||||
function csvEscape(value: string): string {
|
||||
if (/[,"\r\n]/.test(value)) {
|
||||
return '"' + value.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isoOnly(date: string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
return date.slice(0, 10);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Download trigger
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function triggerDownload(blob: Blob, name: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
// Some browsers (Safari < 14) ignore the download attribute unless
|
||||
// the link is in the document tree. Inserting + removing is cheap.
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Give the browser a tick to start the download before we revoke.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function filename(ctx: ExportContext, ext: string): string {
|
||||
// Keep filenames diff-friendly + filesystem-safe. Replace anything that
|
||||
// isn't ASCII alnum/dot/hyphen with "_". Truncate the title to 60 chars.
|
||||
const safeTitle = (ctx.projectTitle || "timeline")
|
||||
.normalize("NFKD")
|
||||
.replace(/[^\x20-\x7e]/g, "")
|
||||
.replace(/[^A-Za-z0-9.-]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_|_$/g, "")
|
||||
.slice(0, 60) || "timeline";
|
||||
const dateStr = new Date().toISOString().slice(0, 10);
|
||||
return `paliad-${safeTitle}-${dateStr}.${ext}`;
|
||||
}
|
||||
@@ -607,17 +607,69 @@ function markAriaLabel(mark: Mark, event: TimelineEvent): string {
|
||||
// Public: mount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Palette presets from design §5.1. Each is a CSS-var override hung off
|
||||
* `.smart-timeline-chart[data-palette="<name>"]`; the renderer never
|
||||
* reads palette state directly. */
|
||||
export type Palette =
|
||||
| "default"
|
||||
| "kind-coded"
|
||||
| "track-coded"
|
||||
| "high-contrast"
|
||||
| "print";
|
||||
|
||||
export const ALL_PALETTES: ReadonlyArray<Palette> = [
|
||||
"default",
|
||||
"kind-coded",
|
||||
"track-coded",
|
||||
"high-contrast",
|
||||
"print",
|
||||
];
|
||||
|
||||
export const ALL_DENSITIES: ReadonlyArray<Density> = [
|
||||
"compact",
|
||||
"standard",
|
||||
"spacious",
|
||||
];
|
||||
|
||||
/** Range presets from design §10 + faraday-Q8 default. The chart caller
|
||||
* drives the active preset via setRange; "all" derives bounds from the
|
||||
* loaded events at repaint time so adding / completing a row reflows. */
|
||||
export type RangePreset = "1y" | "2y" | "all" | "custom";
|
||||
|
||||
export const ALL_RANGE_PRESETS: ReadonlyArray<RangePreset> = [
|
||||
"1y",
|
||||
"2y",
|
||||
"all",
|
||||
"custom",
|
||||
];
|
||||
|
||||
export interface ChartMountOpts {
|
||||
projectId: string;
|
||||
todayISO?: string;
|
||||
density?: Density;
|
||||
/** Optional ISO YYYY-MM-DD overrides for the date range. When omitted,
|
||||
* mount picks `today-1y .. today+1y` per design Q8. */
|
||||
palette?: Palette;
|
||||
/** Initial range preset. Default "1y" (today-1y..today+1y) per design Q8. */
|
||||
rangePreset?: RangePreset;
|
||||
/** When rangePreset === "custom", these supply the bounds. Ignored for
|
||||
* preset values — those derive bounds from the preset + todayISO (or,
|
||||
* for "all", from the loaded events). */
|
||||
rangeFrom?: string;
|
||||
rangeTo?: string;
|
||||
/** Optional callback fired when the user clicks a mark with a known
|
||||
* deep-link target. Receives the underlying TimelineEvent. */
|
||||
onMarkClick?: (event: TimelineEvent) => void;
|
||||
/** Optional callback fired after every refresh() so the host can
|
||||
* re-render dynamic UI (e.g. lane filter chips). */
|
||||
onDataLoaded?: (data: { events: TimelineEvent[]; lanes: LaneInfo[] }) => void;
|
||||
/** Initial visible-lane allowlist. null = show all (default).
|
||||
* Lane ids not present in the response are silently dropped. */
|
||||
visibleLanes?: string[] | null;
|
||||
/** Pre-loaded data — used by Custom Views (Slice 4) where the rows
|
||||
* come from ViewService not /api/projects/{id}/timeline. When set,
|
||||
* mount() skips the initial fetch and paints from this data; the
|
||||
* handle's refresh() still hits the project endpoint (caller can
|
||||
* swap the chart back to project-mode via the standalone /chart URL). */
|
||||
staticData?: { events: TimelineEvent[]; lanes: LaneInfo[] };
|
||||
}
|
||||
|
||||
export interface ChartHandle {
|
||||
@@ -627,6 +679,21 @@ export interface ChartHandle {
|
||||
dispose: () => void;
|
||||
/** Returns the last computed layout (useful for tests / debugging). */
|
||||
getLayout: () => ChartLayout | null;
|
||||
/** Swap palette via data-palette attribute. Pure CSS-var swap — no repaint. */
|
||||
setPalette: (palette: Palette) => void;
|
||||
/** Swap density. Re-runs layout() since lane height / mark radius change. */
|
||||
setDensity: (density: Density) => void;
|
||||
/** Switch range preset. "all" derives bounds from the loaded events;
|
||||
* "custom" expects customFrom + customTo (otherwise it falls back to
|
||||
* today-1y..today+1y). All others are time-shifted from todayISO. */
|
||||
setRange: (preset: RangePreset, customFrom?: string, customTo?: string) => void;
|
||||
/** Set the lane allowlist. null = show all lanes (default). Unknown
|
||||
* ids in the passed array are silently dropped on repaint. */
|
||||
setVisibleLanes: (lanes: string[] | null) => void;
|
||||
/** The raw SVG node — chart-export.ts reads this for SVG / PNG / print. */
|
||||
getSVGElement: () => SVGSVGElement;
|
||||
/** Last-loaded data — chart-export.ts reads this for CSV / JSON / iCal. */
|
||||
getData: () => { events: TimelineEvent[]; lanes: LaneInfo[] };
|
||||
}
|
||||
|
||||
interface TimelineEnvelope {
|
||||
@@ -651,7 +718,7 @@ export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle {
|
||||
// The SVG root we paint into.
|
||||
const svgEl = document.createElementNS(SVG_NS, "svg") as SVGSVGElement;
|
||||
svgEl.classList.add("smart-timeline-chart");
|
||||
svgEl.setAttribute("data-palette", "default");
|
||||
svgEl.setAttribute("data-palette", opts.palette ?? "default");
|
||||
svgEl.setAttribute("data-density", opts.density ?? "standard");
|
||||
host.appendChild(svgEl);
|
||||
|
||||
@@ -659,28 +726,62 @@ export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle {
|
||||
let lastLayout: ChartLayout | null = null;
|
||||
|
||||
const todayISO = opts.todayISO ?? today();
|
||||
const rangeFrom = opts.rangeFrom ?? shiftYears(todayISO, -1);
|
||||
const rangeTo = opts.rangeTo ?? shiftYears(todayISO, 1);
|
||||
let currentDensity: Density = opts.density ?? "standard";
|
||||
let currentRangePreset: RangePreset = opts.rangePreset ?? "1y";
|
||||
let customRangeFrom: string = opts.rangeFrom ?? shiftYears(todayISO, -1);
|
||||
let customRangeTo: string = opts.rangeTo ?? shiftYears(todayISO, 1);
|
||||
let currentVisibleLanes: Set<string> | null = opts.visibleLanes
|
||||
? new Set(opts.visibleLanes)
|
||||
: null;
|
||||
|
||||
function resolveRange(): { from: string; to: string } {
|
||||
switch (currentRangePreset) {
|
||||
case "1y":
|
||||
return { from: shiftYears(todayISO, -1), to: shiftYears(todayISO, 1) };
|
||||
case "2y":
|
||||
return { from: shiftYears(todayISO, -2), to: shiftYears(todayISO, 2) };
|
||||
case "all":
|
||||
return rangeFromEvents(lastEvents, todayISO);
|
||||
case "custom":
|
||||
return { from: customRangeFrom, to: customRangeTo };
|
||||
}
|
||||
}
|
||||
|
||||
function repaint(): void {
|
||||
const rect = host.getBoundingClientRect();
|
||||
// Minimum width keeps the canvas usable when the host is hidden /
|
||||
// about to be sized; resize listener will repaint on real layout.
|
||||
const width = Math.max(640, rect.width || 1000);
|
||||
const density: Density = opts.density ?? "standard";
|
||||
const { from, to } = resolveRange();
|
||||
const viewport: ChartViewport = {
|
||||
width,
|
||||
height: 400,
|
||||
laneLabelWidth: 200,
|
||||
dateAxisHeight: 40,
|
||||
todayISO,
|
||||
rangeFrom,
|
||||
rangeTo,
|
||||
density,
|
||||
rangeFrom: from,
|
||||
rangeTo: to,
|
||||
density: currentDensity,
|
||||
};
|
||||
const chart = layout(lastEvents, [...currentLanes], viewport);
|
||||
// Lane allowlist filter. null = show all; otherwise drop both the
|
||||
// lane rows AND the events whose lane_id sits outside the allowlist.
|
||||
// (We don't fall back to "first lane" here — that's only sensible
|
||||
// when a stale id slips through; an explicit hide is a hide.)
|
||||
let renderLanes = [...currentLanes];
|
||||
let renderEvents: TimelineEvent[] = lastEvents;
|
||||
if (currentVisibleLanes !== null) {
|
||||
const allow = currentVisibleLanes;
|
||||
renderLanes = currentLanes.filter((l) => allow.has(l.id));
|
||||
renderEvents = lastEvents.filter((e) => {
|
||||
// Empty / missing lane_id is treated as "self" — included only
|
||||
// when the synthetic "self" lane is allowed.
|
||||
const id = e.lane_id || "self";
|
||||
return allow.has(id);
|
||||
});
|
||||
}
|
||||
const chart = layout(renderEvents, renderLanes, viewport);
|
||||
lastLayout = chart;
|
||||
paint(chart, svgEl, lastEvents);
|
||||
paint(chart, svgEl, renderEvents);
|
||||
svgEl.setAttribute("width", String(width));
|
||||
svgEl.setAttribute("height", String(chart.chartTop + chart.chartHeight + 32));
|
||||
}
|
||||
@@ -715,7 +816,21 @@ export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle {
|
||||
} else {
|
||||
messageEl.textContent = "";
|
||||
}
|
||||
// Drop stale lane ids from the allowlist — a deleted CCR / child
|
||||
// case shouldn't keep its lane id alive across re-fetches.
|
||||
if (currentVisibleLanes !== null) {
|
||||
const valid = new Set(currentLanes.map((l) => l.id));
|
||||
valid.add("self"); // synthetic lane always allowed
|
||||
const trimmed = new Set<string>();
|
||||
for (const id of currentVisibleLanes) {
|
||||
if (valid.has(id)) trimmed.add(id);
|
||||
}
|
||||
currentVisibleLanes = trimmed.size === 0 ? null : trimmed;
|
||||
}
|
||||
repaint();
|
||||
if (opts.onDataLoaded) {
|
||||
opts.onDataLoaded({ events: lastEvents, lanes: currentLanes });
|
||||
}
|
||||
} catch (err) {
|
||||
messageEl.textContent = "Netzwerkfehler beim Laden der Timeline.";
|
||||
messageEl.classList.add("smart-timeline-chart-message--error");
|
||||
@@ -757,12 +872,51 @@ export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle {
|
||||
svgEl.addEventListener("click", handleClick);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// Kick off initial fetch.
|
||||
void refresh();
|
||||
// If the caller supplied data up front (Custom Views host path), skip
|
||||
// the project-timeline fetch entirely — paint from the supplied rows.
|
||||
// Otherwise kick off the initial /api/projects/{id}/timeline load.
|
||||
if (opts.staticData) {
|
||||
lastEvents = opts.staticData.events;
|
||||
currentLanes = opts.staticData.lanes;
|
||||
if (lastEvents.length === 0) {
|
||||
messageEl.textContent = "Keine Ereignisse im gewählten Zeitraum.";
|
||||
} else {
|
||||
messageEl.textContent = "";
|
||||
}
|
||||
repaint();
|
||||
if (opts.onDataLoaded) {
|
||||
opts.onDataLoaded({ events: lastEvents, lanes: currentLanes });
|
||||
}
|
||||
} else {
|
||||
void refresh();
|
||||
}
|
||||
|
||||
return {
|
||||
refresh,
|
||||
getLayout: () => lastLayout,
|
||||
setPalette: (palette: Palette) => {
|
||||
svgEl.setAttribute("data-palette", palette);
|
||||
},
|
||||
setDensity: (density: Density) => {
|
||||
currentDensity = density;
|
||||
svgEl.setAttribute("data-density", density);
|
||||
repaint();
|
||||
},
|
||||
setRange: (preset: RangePreset, customFrom?: string, customTo?: string) => {
|
||||
currentRangePreset = preset;
|
||||
if (preset === "custom") {
|
||||
if (customFrom) customRangeFrom = customFrom;
|
||||
if (customTo) customRangeTo = customTo;
|
||||
}
|
||||
svgEl.setAttribute("data-range-preset", preset);
|
||||
repaint();
|
||||
},
|
||||
setVisibleLanes: (lanes: string[] | null) => {
|
||||
currentVisibleLanes = lanes ? new Set(lanes) : null;
|
||||
repaint();
|
||||
},
|
||||
getSVGElement: () => svgEl,
|
||||
getData: () => ({ events: lastEvents, lanes: currentLanes }),
|
||||
dispose: () => {
|
||||
svgEl.removeEventListener("click", handleClick);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
@@ -773,6 +927,37 @@ export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle {
|
||||
};
|
||||
}
|
||||
|
||||
/** Resolve the "all" preset bounds from the loaded events. Empty data
|
||||
* falls back to the 1y default so the chart canvas isn't degenerate. */
|
||||
function rangeFromEvents(
|
||||
events: ReadonlyArray<TimelineEvent>,
|
||||
todayISO: string,
|
||||
): { from: string; to: string } {
|
||||
let minMs: number | null = null;
|
||||
let maxMs: number | null = null;
|
||||
for (const ev of events) {
|
||||
if (!ev.date) continue;
|
||||
const ms = parseISODay(ev.date);
|
||||
if (ms === null) continue;
|
||||
if (minMs === null || ms < minMs) minMs = ms;
|
||||
if (maxMs === null || ms > maxMs) maxMs = ms;
|
||||
}
|
||||
if (minMs === null || maxMs === null) {
|
||||
return { from: shiftYears(todayISO, -1), to: shiftYears(todayISO, 1) };
|
||||
}
|
||||
// Pad +30d at the right so the last event isn't flush against the edge.
|
||||
const fromDate = new Date(minMs);
|
||||
const toDate = new Date(maxMs + 30 * 86_400_000);
|
||||
return {
|
||||
from: toISO(fromDate),
|
||||
to: toISO(toDate),
|
||||
};
|
||||
}
|
||||
|
||||
function toISO(d: Date): string {
|
||||
return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function today(): string {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
|
||||
140
frontend/src/client/views/shape-timeline-cv.test.ts
Normal file
140
frontend/src/client/views/shape-timeline-cv.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { adapt } from "./shape-timeline-cv";
|
||||
import type { ViewRow } from "./types";
|
||||
|
||||
// t-paliad-177 Slice 4 — adapter contract tests for ViewRow →
|
||||
// TimelineEvent + LaneInfo. Pure function, no DOM access.
|
||||
// The actual chart-render math is pinned by shape-timeline-chart.test.ts;
|
||||
// this file pins the adapter's lossy translation rules from §13.4.
|
||||
|
||||
const baseRow = (overrides: Partial<ViewRow> = {}): ViewRow => ({
|
||||
kind: "deadline",
|
||||
id: "d1",
|
||||
title: "Test",
|
||||
event_date: "2026-06-15",
|
||||
detail: {},
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("adapt — kind mapping", () => {
|
||||
test("deadline → kind='deadline' + deadline_id", () => {
|
||||
const out = adapt([baseRow({ kind: "deadline", id: "abc" })]);
|
||||
expect(out.events).toHaveLength(1);
|
||||
expect(out.events[0].kind).toBe("deadline");
|
||||
expect(out.events[0].deadline_id).toBe("abc");
|
||||
expect(out.events[0].appointment_id).toBeUndefined();
|
||||
expect(out.events[0].project_event_id).toBeUndefined();
|
||||
});
|
||||
|
||||
test("appointment → kind='appointment' + appointment_id", () => {
|
||||
const out = adapt([baseRow({ kind: "appointment", id: "x" })]);
|
||||
expect(out.events[0].kind).toBe("appointment");
|
||||
expect(out.events[0].appointment_id).toBe("x");
|
||||
});
|
||||
|
||||
test("project_event → kind='milestone' + project_event_id", () => {
|
||||
const out = adapt([baseRow({ kind: "project_event", id: "y" })]);
|
||||
expect(out.events[0].kind).toBe("milestone");
|
||||
expect(out.events[0].project_event_id).toBe("y");
|
||||
});
|
||||
|
||||
test("approval_request is skipped", () => {
|
||||
const out = adapt([
|
||||
baseRow({ kind: "deadline" }),
|
||||
baseRow({ kind: "approval_request" }),
|
||||
baseRow({ kind: "appointment" }),
|
||||
]);
|
||||
expect(out.events).toHaveLength(2);
|
||||
expect(out.events.map((e) => e.kind)).toEqual(["deadline", "appointment"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adapt — lane bucketing by project_id (cross-project chart)", () => {
|
||||
test("one lane per unique project_id, first-seen order", () => {
|
||||
const out = adapt([
|
||||
baseRow({ project_id: "p1", project_title: "Project 1" }),
|
||||
baseRow({ project_id: "p2", project_title: "Project 2" }),
|
||||
baseRow({ project_id: "p1", project_title: "Project 1" }),
|
||||
]);
|
||||
expect(out.lanes).toHaveLength(2);
|
||||
expect(out.lanes[0].id).toBe("p1");
|
||||
expect(out.lanes[0].label).toBe("Project 1");
|
||||
expect(out.lanes[1].id).toBe("p2");
|
||||
});
|
||||
|
||||
test("project_title preferred over project_reference for the label", () => {
|
||||
const out = adapt([
|
||||
baseRow({ project_id: "p1", project_title: "Nice Name", project_reference: "REF-1" }),
|
||||
]);
|
||||
expect(out.lanes[0].label).toBe("Nice Name");
|
||||
});
|
||||
|
||||
test("falls back to project_reference when title missing", () => {
|
||||
const out = adapt([
|
||||
baseRow({ project_id: "p1", project_reference: "REF-1" }),
|
||||
]);
|
||||
expect(out.lanes[0].label).toBe("REF-1");
|
||||
});
|
||||
|
||||
test("missing project_id collapses to synthetic 'self' lane", () => {
|
||||
const out = adapt([baseRow({ project_id: undefined })]);
|
||||
expect(out.lanes).toHaveLength(1);
|
||||
expect(out.lanes[0].id).toBe("self");
|
||||
expect(out.events[0].lane_id).toBe("self");
|
||||
expect(out.events[0].track).toBe("parent");
|
||||
});
|
||||
|
||||
test("event lane_id matches its lane row id", () => {
|
||||
const out = adapt([
|
||||
baseRow({ project_id: "p1", project_title: "A" }),
|
||||
baseRow({ project_id: "p2", project_title: "B" }),
|
||||
]);
|
||||
expect(out.events[0].lane_id).toBe("p1");
|
||||
expect(out.events[1].lane_id).toBe("p2");
|
||||
});
|
||||
});
|
||||
|
||||
describe("adapt — status extraction", () => {
|
||||
test("deadline status 'done' comes through from detail", () => {
|
||||
const out = adapt([
|
||||
baseRow({ kind: "deadline", detail: { status: "done" } }),
|
||||
]);
|
||||
expect(out.events[0].status).toBe("done");
|
||||
});
|
||||
|
||||
test("deadline status 'overdue' comes through", () => {
|
||||
const out = adapt([
|
||||
baseRow({ kind: "deadline", detail: { status: "overdue" } }),
|
||||
]);
|
||||
expect(out.events[0].status).toBe("overdue");
|
||||
});
|
||||
|
||||
test("unknown / missing detail.status defaults to 'open'", () => {
|
||||
const out = adapt([
|
||||
baseRow({ kind: "deadline", detail: { status: "weird-value" } }),
|
||||
baseRow({ kind: "appointment" }),
|
||||
baseRow({ kind: "project_event" }),
|
||||
]);
|
||||
expect(out.events.map((e) => e.status)).toEqual(["open", "open", "open"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("adapt — date passthrough", () => {
|
||||
test("event_date is forwarded to TimelineEvent.date", () => {
|
||||
const out = adapt([baseRow({ event_date: "2026-08-15T00:00:00Z" })]);
|
||||
expect(out.events[0].date).toBe("2026-08-15T00:00:00Z");
|
||||
});
|
||||
|
||||
test("empty event_date becomes null (undated)", () => {
|
||||
const out = adapt([baseRow({ event_date: "" })]);
|
||||
expect(out.events[0].date).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("adapt — empty input", () => {
|
||||
test("empty rows array returns empty events + empty lanes", () => {
|
||||
const out = adapt([]);
|
||||
expect(out.events).toHaveLength(0);
|
||||
expect(out.lanes).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
141
frontend/src/client/views/shape-timeline-cv.ts
Normal file
141
frontend/src/client/views/shape-timeline-cv.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
mount,
|
||||
type ChartHandle,
|
||||
type Density,
|
||||
type Palette,
|
||||
type RangePreset,
|
||||
} from "./shape-timeline-chart";
|
||||
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
|
||||
// shape-timeline-cv (t-paliad-177 Slice 4, faraday-Q7) — Custom Views
|
||||
// host for the chart renderer.
|
||||
//
|
||||
// Adapter contract: ViewRow → TimelineEvent + LaneInfo.
|
||||
// - deadline + appointment + project_event rows render as actual marks.
|
||||
// - approval_request rows are skipped (no chart-meaningful date).
|
||||
// - Lane axis = project_id; the cross-project chart use case (design
|
||||
// §10) groups events by their owning project. Rows without a
|
||||
// project_id collapse into a synthetic "self" lane.
|
||||
// - NO projected rows. ViewService doesn't run the fristenrechner
|
||||
// calculator, so the CV chart shows actuals only. The host page
|
||||
// ships a one-time caveat tooltip (see C3) explaining this.
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §8.3 + §11.5 + §13.4.
|
||||
|
||||
export function renderTimelineShape(
|
||||
host: HTMLElement,
|
||||
rows: ReadonlyArray<ViewRow>,
|
||||
render: RenderSpec,
|
||||
): ChartHandle {
|
||||
// Tear down any previous mount so re-rendering the shape (e.g. shape
|
||||
// chip switch on /views/{slug}) doesn't stack SVGs.
|
||||
host.innerHTML = "";
|
||||
|
||||
const { events, lanes } = adapt(rows);
|
||||
const cfg = render.timeline ?? {};
|
||||
|
||||
// The CV adapter has no per-project "id" to fetch live timeline data
|
||||
// for — we hand mount() a placeholder projectId and the staticData
|
||||
// pre-loaded array so it skips the project endpoint entirely. If the
|
||||
// user clicks a mark, the renderer's default click handler still
|
||||
// resolves /deadlines/{id} / /appointments/{id} from the adapted
|
||||
// event's id field, so deep-links land on the correct entity page.
|
||||
return mount(host, {
|
||||
projectId: "cv",
|
||||
staticData: { events, lanes },
|
||||
palette: (cfg.palette as Palette | undefined) ?? "default",
|
||||
density: (cfg.density as Density | undefined) ?? "standard",
|
||||
rangePreset: (cfg.range_preset as RangePreset | undefined) ?? "1y",
|
||||
rangeFrom: cfg.range_from,
|
||||
rangeTo: cfg.range_to,
|
||||
});
|
||||
}
|
||||
|
||||
export interface AdapterResult {
|
||||
events: TimelineEvent[];
|
||||
lanes: LaneInfo[];
|
||||
}
|
||||
|
||||
/** Exported for tests (shape-timeline-cv.test.ts). Pure — no DOM. */
|
||||
export function adapt(rows: ReadonlyArray<ViewRow>): AdapterResult {
|
||||
const events: TimelineEvent[] = [];
|
||||
// Lane order = first-seen order of project_ids in rows, so the user
|
||||
// sees lanes in the order their data was returned (typically date-
|
||||
// sorted). Deterministic, no surprise re-ordering on re-renders.
|
||||
const laneIndex = new Map<string, LaneInfo>();
|
||||
|
||||
for (const row of rows) {
|
||||
if (row.kind === "approval_request") {
|
||||
// Approval requests have no event_date in the chart sense; they
|
||||
// represent pending decisions, not scheduled work. Skip.
|
||||
continue;
|
||||
}
|
||||
const laneId = row.project_id || "self";
|
||||
if (!laneIndex.has(laneId)) {
|
||||
laneIndex.set(laneId, {
|
||||
id: laneId,
|
||||
label: row.project_title || row.project_reference || laneLabelFallback(laneId),
|
||||
project_id: row.project_id,
|
||||
});
|
||||
}
|
||||
|
||||
const event: TimelineEvent = {
|
||||
kind: toTimelineKind(row.kind),
|
||||
status: extractStatus(row),
|
||||
track: laneId === "self" ? "parent" : "child:" + laneId,
|
||||
date: row.event_date || null,
|
||||
title: row.title,
|
||||
description: row.subtitle,
|
||||
lane_id: laneId,
|
||||
};
|
||||
// Set the right provenance id so the renderer's click handler can
|
||||
// deep-link to /deadlines/{id} / /appointments/{id}.
|
||||
switch (row.kind) {
|
||||
case "deadline":
|
||||
event.deadline_id = row.id;
|
||||
break;
|
||||
case "appointment":
|
||||
event.appointment_id = row.id;
|
||||
break;
|
||||
case "project_event":
|
||||
event.project_event_id = row.id;
|
||||
break;
|
||||
}
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
return { events, lanes: [...laneIndex.values()] };
|
||||
}
|
||||
|
||||
function toTimelineKind(kind: ViewRow["kind"]): TimelineEvent["kind"] {
|
||||
// ViewRow "project_event" maps to chart "milestone" — they're the
|
||||
// same underlying paliad.project_events row, the chart just uses a
|
||||
// different name because milestones are the chart-meaningful subset.
|
||||
if (kind === "project_event") return "milestone";
|
||||
// Defensive: approval_request was filtered earlier, but TS doesn't
|
||||
// know that. Default to "milestone" for any unexpected kind.
|
||||
if (kind === "deadline" || kind === "appointment") return kind;
|
||||
return "milestone";
|
||||
}
|
||||
|
||||
/** Status defaults to "open" — ViewRow doesn't carry chart-status
|
||||
* semantics directly, and the underlying detail json shape varies per
|
||||
* kind. The chart's color saturation maps status → fill / ring style,
|
||||
* so "open" gives every mark a sensible default (filled, full color).
|
||||
* Detail-driven status lookup is a polish job for a future slice. */
|
||||
function extractStatus(row: ViewRow): TimelineEvent["status"] {
|
||||
if (row.kind === "deadline") {
|
||||
const d = row.detail as { status?: string };
|
||||
if (d.status === "done" || d.status === "overdue") {
|
||||
return d.status as TimelineEvent["status"];
|
||||
}
|
||||
}
|
||||
return "open";
|
||||
}
|
||||
|
||||
function laneLabelFallback(id: string): string {
|
||||
if (id === "self") return "(ohne Projekt)";
|
||||
// Truncated UUID is more useful than a bare 36-char string.
|
||||
return id.slice(0, 8);
|
||||
}
|
||||
@@ -69,7 +69,15 @@ export interface FilterSpec {
|
||||
predicates?: Partial<Record<DataSource, Predicates>>;
|
||||
}
|
||||
|
||||
export type RenderShape = "list" | "cards" | "calendar";
|
||||
export type RenderShape = "list" | "cards" | "calendar" | "timeline";
|
||||
|
||||
export interface TimelineCVConfig {
|
||||
palette?: "default" | "kind-coded" | "track-coded" | "high-contrast" | "print";
|
||||
density?: "compact" | "standard" | "spacious";
|
||||
range_preset?: "1y" | "2y" | "all" | "custom";
|
||||
range_from?: string;
|
||||
range_to?: string;
|
||||
}
|
||||
|
||||
export type ListRowAction = "navigate" | "complete_toggle" | "approve" | "none";
|
||||
|
||||
@@ -96,6 +104,7 @@ export interface RenderSpec {
|
||||
list?: ListConfig;
|
||||
cards?: CardsConfig;
|
||||
calendar?: CalendarConfig;
|
||||
timeline?: TimelineCVConfig;
|
||||
}
|
||||
|
||||
// ViewRow — the discriminated row shape from ViewService.RunSpec.
|
||||
|
||||
@@ -140,6 +140,18 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
navItem("/team", ICON_USERS, "nav.team", "Team", currentPath),
|
||||
)}
|
||||
|
||||
{/* t-paliad-177 \u2014 contextual chart link, revealed by sidebar.ts
|
||||
when the user is on a /projects/{id}/* page (but NOT on the
|
||||
chart itself). The href is filled in client-side from the
|
||||
URL path so the same Sidebar TSX serves every page. */}
|
||||
<a href="#"
|
||||
className="sidebar-item sidebar-context-chart"
|
||||
id="sidebar-project-chart-link"
|
||||
style="display:none">
|
||||
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: ICON_GAUGE }} />
|
||||
<span className="sidebar-label" data-i18n="nav.context.project_chart">Als Chart anzeigen</span>
|
||||
</a>
|
||||
|
||||
{/* Ansichten \u2014 single consolidated group (m's 2026-05-08 20:32
|
||||
dogfood: "all views under one — not Ansichten and meine Ansichten").
|
||||
Holds the built-in Fristen + Termine, the user-defined views
|
||||
|
||||
@@ -1442,6 +1442,7 @@ export type I18nKey =
|
||||
| "nav.akten"
|
||||
| "nav.caldav"
|
||||
| "nav.checklisten"
|
||||
| "nav.context.project_chart"
|
||||
| "nav.dashboard"
|
||||
| "nav.downloads"
|
||||
| "nav.einstellungen"
|
||||
@@ -1625,13 +1626,39 @@ export type I18nKey =
|
||||
| "projects.cards.team"
|
||||
| "projects.chart.back"
|
||||
| "projects.chart.control.columns.auto"
|
||||
| "projects.chart.control.density.label"
|
||||
| "projects.chart.control.density.standard"
|
||||
| "projects.chart.control.export.soon"
|
||||
| "projects.chart.control.layout.horizontal"
|
||||
| "projects.chart.control.palette.default"
|
||||
| "projects.chart.control.palette.label"
|
||||
| "projects.chart.control.range.label"
|
||||
| "projects.chart.density.compact"
|
||||
| "projects.chart.density.spacious"
|
||||
| "projects.chart.density.standard"
|
||||
| "projects.chart.error.mount"
|
||||
| "projects.chart.export.csv"
|
||||
| "projects.chart.export.ics"
|
||||
| "projects.chart.export.json"
|
||||
| "projects.chart.export.menu"
|
||||
| "projects.chart.export.png"
|
||||
| "projects.chart.export.print"
|
||||
| "projects.chart.export.svg"
|
||||
| "projects.chart.loading"
|
||||
| "projects.chart.notfound"
|
||||
| "projects.chart.palette.default"
|
||||
| "projects.chart.palette.high_contrast"
|
||||
| "projects.chart.palette.kind_coded"
|
||||
| "projects.chart.palette.print"
|
||||
| "projects.chart.palette.track_coded"
|
||||
| "projects.chart.permalink.copy"
|
||||
| "projects.chart.permalink.title"
|
||||
| "projects.chart.range.1y"
|
||||
| "projects.chart.range.2y"
|
||||
| "projects.chart.range.all"
|
||||
| "projects.chart.range.custom"
|
||||
| "projects.chart.range.from"
|
||||
| "projects.chart.range.to"
|
||||
| "projects.chart.title"
|
||||
| "projects.chip.all"
|
||||
| "projects.chip.has_open_deadlines"
|
||||
@@ -2186,11 +2213,13 @@ export type I18nKey =
|
||||
| "views.shape.calendar"
|
||||
| "views.shape.cards"
|
||||
| "views.shape.list"
|
||||
| "views.shape.timeline"
|
||||
| "views.source.appointment"
|
||||
| "views.source.approval_request"
|
||||
| "views.source.deadline"
|
||||
| "views.source.project_event"
|
||||
| "views.subtitle"
|
||||
| "views.timeline.caveat.body"
|
||||
| "views.title"
|
||||
| "views.toast.inaccessible_n"
|
||||
| "views.toast.inaccessible_one";
|
||||
|
||||
@@ -40,7 +40,7 @@ export function renderProjectsChart(): string {
|
||||
className="back-link"
|
||||
data-i18n="projects.chart.back"
|
||||
>
|
||||
← Zurück zum Projekt
|
||||
← Zurück zum Verlauf
|
||||
</a>
|
||||
|
||||
<div id="projects-chart-loading" className="entity-loading">
|
||||
@@ -64,20 +64,97 @@ export function renderProjectsChart(): string {
|
||||
<span className="chip-inert" data-i18n="projects.chart.control.layout.horizontal" title="Slice 3">
|
||||
Layout: Horizontal
|
||||
</span>
|
||||
<span className="chip-inert" data-i18n="projects.chart.control.columns.auto" title="Slice 3">
|
||||
Spalten: Auto
|
||||
<span className="smart-timeline-chart-picker">
|
||||
<label htmlFor="projects-chart-range" data-i18n="projects.chart.control.range.label">
|
||||
Zeitraum:
|
||||
</label>
|
||||
<select id="projects-chart-range">
|
||||
<option value="1y" data-i18n="projects.chart.range.1y">1 Jahr</option>
|
||||
<option value="2y" data-i18n="projects.chart.range.2y">2 Jahre</option>
|
||||
<option value="all" data-i18n="projects.chart.range.all">Alles anzeigen</option>
|
||||
<option value="custom" data-i18n="projects.chart.range.custom">Eigener Bereich…</option>
|
||||
</select>
|
||||
</span>
|
||||
<span className="chip-inert" data-i18n="projects.chart.control.density.standard" title="Slice 3">
|
||||
Dichte: Standard
|
||||
<span className="smart-timeline-chart-picker smart-timeline-chart-range-custom" id="projects-chart-range-custom" style="display:none">
|
||||
<label htmlFor="projects-chart-range-from" data-i18n="projects.chart.range.from">Von:</label>
|
||||
<input type="date" id="projects-chart-range-from" />
|
||||
<label htmlFor="projects-chart-range-to" data-i18n="projects.chart.range.to">Bis:</label>
|
||||
<input type="date" id="projects-chart-range-to" />
|
||||
</span>
|
||||
<span className="chip-inert" data-i18n="projects.chart.control.palette.default" title="Slice 3">
|
||||
Palette: Standard
|
||||
<span className="smart-timeline-chart-picker">
|
||||
<label htmlFor="projects-chart-density" data-i18n="projects.chart.control.density.label">
|
||||
Dichte:
|
||||
</label>
|
||||
<select id="projects-chart-density">
|
||||
<option value="compact" data-i18n="projects.chart.density.compact">Kompakt</option>
|
||||
<option value="standard" data-i18n="projects.chart.density.standard">Standard</option>
|
||||
<option value="spacious" data-i18n="projects.chart.density.spacious">Großzügig</option>
|
||||
</select>
|
||||
</span>
|
||||
<span className="chip-inert" data-i18n="projects.chart.control.export.soon" title="Slice 2">
|
||||
Export ↓ (Slice 2)
|
||||
<span className="smart-timeline-chart-picker">
|
||||
<label htmlFor="projects-chart-palette" data-i18n="projects.chart.control.palette.label">
|
||||
Palette:
|
||||
</label>
|
||||
<select id="projects-chart-palette">
|
||||
<option value="default" data-i18n="projects.chart.palette.default">Standard</option>
|
||||
<option value="kind-coded" data-i18n="projects.chart.palette.kind_coded">Nach Ereignistyp</option>
|
||||
<option value="track-coded" data-i18n="projects.chart.palette.track_coded">Nach Spur</option>
|
||||
<option value="high-contrast" data-i18n="projects.chart.palette.high_contrast">Hoher Kontrast</option>
|
||||
<option value="print" data-i18n="projects.chart.palette.print">Druck (S/W)</option>
|
||||
</select>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
id="projects-chart-copylink"
|
||||
className="smart-timeline-chart-copylink"
|
||||
data-i18n="projects.chart.permalink.copy"
|
||||
data-i18n-title="projects.chart.permalink.title"
|
||||
title="URL mit allen Filtern in die Zwischenablage kopieren"
|
||||
>
|
||||
🔗 Link kopieren
|
||||
</button>
|
||||
<details className="smart-timeline-chart-export">
|
||||
<summary data-i18n="projects.chart.export.menu">
|
||||
⇓ Export
|
||||
</summary>
|
||||
<menu className="smart-timeline-chart-export-menu">
|
||||
<li>
|
||||
<button type="button" id="projects-chart-export-svg" data-i18n="projects.chart.export.svg">
|
||||
SVG (Vektorgrafik)
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" id="projects-chart-export-png" data-i18n="projects.chart.export.png">
|
||||
PNG (Bild, 2× HiDPI)
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" id="projects-chart-export-print" data-i18n="projects.chart.export.print">
|
||||
PDF (Drucken)
|
||||
</button>
|
||||
</li>
|
||||
<li className="smart-timeline-chart-export-divider" />
|
||||
<li>
|
||||
<button type="button" id="projects-chart-export-csv" data-i18n="projects.chart.export.csv">
|
||||
CSV (Excel-Tabelle)
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" id="projects-chart-export-json" data-i18n="projects.chart.export.json">
|
||||
JSON (Rohdaten)
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button type="button" id="projects-chart-export-ics" data-i18n="projects.chart.export.ics">
|
||||
iCal (.ics — Outlook / Apple)
|
||||
</button>
|
||||
</li>
|
||||
</menu>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div id="projects-chart-lanes-filter" className="smart-timeline-chart-lanes-filter" style="display:none" />
|
||||
|
||||
<div id="projects-chart-host" className="smart-timeline-chart-host" />
|
||||
|
||||
<p id="projects-chart-undated" className="smart-timeline-chart-undated-hint" style="display:none" />
|
||||
|
||||
@@ -14355,3 +14355,308 @@ dialog.quick-add-sheet::backdrop {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* CV-timeline caveat banner — design §13.4. Shown once per session on
|
||||
first open of a Custom View with shape="timeline". sessionStorage
|
||||
dismiss flag handled in client/views.ts. */
|
||||
.views-timeline-caveat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
background: var(--color-bg-lime-tint, #ecfbb6);
|
||||
border: 1px solid var(--color-accent, #c6f41c);
|
||||
border-radius: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.views-timeline-caveat-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0 0.35rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
.views-timeline-caveat-close:focus-visible {
|
||||
outline: 2px solid var(--color-accent, #c6f41c);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---- Palette presets (t-paliad-177 Slice 2, design §5.1) ----
|
||||
Each palette is a pure data-attribute swap of the --chart-* tokens.
|
||||
Renderer code never reads palette state — it just emits classed SVG
|
||||
nodes and the tokens flow in from these blocks. */
|
||||
.smart-timeline-chart[data-palette="kind-coded"] {
|
||||
/* Kind dominates; track tokens stay neutral. */
|
||||
--chart-mark-deadline: #2f6fb5; /* blue */
|
||||
--chart-mark-appointment: #f5a623; /* amber */
|
||||
--chart-mark-milestone: var(--color-accent, #c6f41c);
|
||||
--chart-mark-projected: var(--color-text-subtle, #999);
|
||||
--chart-mark-overdue: #d62828;
|
||||
--chart-mark-done: #2f6fb5;
|
||||
}
|
||||
.smart-timeline-chart[data-palette="track-coded"] {
|
||||
/* Three distinct hues per track tag; kind drives shape only. */
|
||||
--chart-mark-deadline: var(--color-accent, #c6f41c);
|
||||
--chart-mark-appointment: var(--color-accent, #c6f41c);
|
||||
--chart-mark-milestone: #6e8a8c;
|
||||
--chart-mark-projected: #6e8a8c;
|
||||
--chart-mark-done: var(--color-accent, #c6f41c);
|
||||
--chart-mark-overdue: #d62828;
|
||||
}
|
||||
.smart-timeline-chart[data-palette="high-contrast"] {
|
||||
/* Status drives saturation; deadline / appointment / milestone all
|
||||
collapse to the same hue per status. Accessibility-first. */
|
||||
--chart-mark-deadline: #0a3d62;
|
||||
--chart-mark-appointment: #0a3d62;
|
||||
--chart-mark-milestone: #0a3d62;
|
||||
--chart-mark-projected: #aaa;
|
||||
--chart-mark-overdue: #c0392b;
|
||||
--chart-mark-done: #1f7a3e;
|
||||
--chart-today-rule: #0a3d62;
|
||||
}
|
||||
.smart-timeline-chart[data-palette="print"] {
|
||||
/* B&W only; redactable / faxable. Projected uses the hatch pattern
|
||||
from <defs> so a colourless print still distinguishes prediction
|
||||
from actual. */
|
||||
--chart-mark-deadline: #000;
|
||||
--chart-mark-appointment: #555;
|
||||
--chart-mark-milestone: #000;
|
||||
--chart-mark-projected: #777;
|
||||
--chart-mark-overdue: #000;
|
||||
--chart-mark-done: #000;
|
||||
--chart-today-rule: #000;
|
||||
--chart-grid-line: #ccc;
|
||||
--chart-lane-label: #000;
|
||||
--chart-tick-label: #000;
|
||||
}
|
||||
.smart-timeline-chart[data-palette="print"] .chart-mark--deadline.chart-mark--status-open .chart-mark-dot {
|
||||
/* Open deadlines in print mode keep the ring affordance — fill
|
||||
with white so the dot is hollow regardless of background. */
|
||||
fill: #fff;
|
||||
}
|
||||
|
||||
/* Export dropdown — uses native <details>/<summary> so it's keyboard-
|
||||
accessible without JS. The menu only renders when open=true, which
|
||||
the <details> element manages itself. */
|
||||
.smart-timeline-chart-export {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
.smart-timeline-chart-export > summary {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg, #fff);
|
||||
cursor: pointer;
|
||||
list-style: none;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.smart-timeline-chart-export > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.smart-timeline-chart-export[open] > summary {
|
||||
background: var(--color-bg-subtle, #f5f5f5);
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
}
|
||||
.smart-timeline-chart-export-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
margin: 0;
|
||||
padding: 0.35rem 0;
|
||||
list-style: none;
|
||||
background: var(--color-bg, #fff);
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.1);
|
||||
z-index: 100;
|
||||
min-width: 240px;
|
||||
}
|
||||
.smart-timeline-chart-export-menu li {
|
||||
margin: 0;
|
||||
}
|
||||
.smart-timeline-chart-export-menu button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.45rem 1rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.smart-timeline-chart-export-menu button:hover,
|
||||
.smart-timeline-chart-export-menu button:focus-visible {
|
||||
background: var(--color-bg-subtle, #f5f5f5);
|
||||
outline: none;
|
||||
}
|
||||
.smart-timeline-chart-export-divider {
|
||||
height: 1px;
|
||||
background: var(--color-border, #e0e0e0);
|
||||
margin: 0.35rem 0.5rem;
|
||||
}
|
||||
|
||||
/* Palette picker chip group on the chart page. */
|
||||
.smart-timeline-chart-picker {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg, #fff);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.smart-timeline-chart-picker label {
|
||||
color: var(--color-text-muted, #777);
|
||||
}
|
||||
.smart-timeline-chart-picker select {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.smart-timeline-chart-picker select:focus-visible {
|
||||
outline: 2px solid var(--color-accent, #c6f41c);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.smart-timeline-chart-range-custom input[type="date"] {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
/* Lane filter chip group — rendered dynamically after the chart fetches
|
||||
lanes from the server. Hidden when the projection has 0-1 lanes. */
|
||||
.smart-timeline-chart-lanes-filter {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
margin: 0.5rem 0 0.75rem;
|
||||
}
|
||||
.smart-timeline-chart-lanes-label {
|
||||
color: var(--color-text-muted, #777);
|
||||
font-size: 0.85rem;
|
||||
margin-right: 0.25rem;
|
||||
}
|
||||
.smart-timeline-chart-lane-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.7rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-subtle, #f5f5f5);
|
||||
color: var(--color-text-muted, #777);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.smart-timeline-chart-lane-chip[aria-pressed="true"] {
|
||||
background: var(--color-bg-lime-tint, #ecfbb6);
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
color: var(--hlc-midnight, #1a2233);
|
||||
font-weight: 500;
|
||||
}
|
||||
.smart-timeline-chart-lane-chip:focus-visible {
|
||||
outline: 2px solid var(--color-accent, #c6f41c);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Permalink copy button — sits alongside palette / density / range
|
||||
selects on the control row. Flash green for success / amber for
|
||||
failure for 1.8s after a click. */
|
||||
.smart-timeline-chart-copylink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.85rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg, #fff);
|
||||
color: inherit;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 120ms ease, border-color 120ms ease;
|
||||
}
|
||||
.smart-timeline-chart-copylink:hover,
|
||||
.smart-timeline-chart-copylink:focus-visible {
|
||||
background: var(--color-bg-subtle, #f5f5f5);
|
||||
outline: none;
|
||||
}
|
||||
.smart-timeline-chart-copylink.is-success {
|
||||
background: var(--color-bg-lime-tint, #ecfbb6);
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
}
|
||||
.smart-timeline-chart-copylink.is-error {
|
||||
background: #fff4e5;
|
||||
border-color: #f5a623;
|
||||
}
|
||||
|
||||
/* ---- Print stylesheet (t-paliad-177 Slice 2, design §7.4) ----
|
||||
When the user hits "PDF (Drucken)", the browser invokes print() and
|
||||
reads these rules. Strategy:
|
||||
- Force the print palette regardless of the user's screen choice
|
||||
(B&W shows nothing the user didn't intend, redactable).
|
||||
- Hide chrome (sidebar, footer, header, bottom-nav, control chips).
|
||||
- Let the chart fill landscape A4 width.
|
||||
- Add a printed header with project meta on the chart page. */
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 landscape;
|
||||
margin: 1.5cm;
|
||||
}
|
||||
body.has-sidebar > aside.sidebar,
|
||||
body.has-sidebar > .bottom-nav,
|
||||
body.has-sidebar > footer,
|
||||
body.has-sidebar .paliadin-widget,
|
||||
.smart-timeline-chart-page .back-link,
|
||||
.smart-timeline-chart-controls,
|
||||
.smart-timeline-chart-page .entity-loading,
|
||||
.smart-timeline-chart-undated-hint {
|
||||
display: none !important;
|
||||
}
|
||||
.smart-timeline-chart-page main,
|
||||
.smart-timeline-chart-page .container {
|
||||
max-width: none !important;
|
||||
padding: 0 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
.smart-timeline-chart-host {
|
||||
border: none !important;
|
||||
overflow: visible !important;
|
||||
}
|
||||
.smart-timeline-chart {
|
||||
/* Force the print palette tokens regardless of data-palette. */
|
||||
--chart-mark-deadline: #000 !important;
|
||||
--chart-mark-appointment: #555 !important;
|
||||
--chart-mark-milestone: #000 !important;
|
||||
--chart-mark-projected: #777 !important;
|
||||
--chart-mark-overdue: #000 !important;
|
||||
--chart-mark-done: #000 !important;
|
||||
--chart-today-rule: #000 !important;
|
||||
--chart-grid-line: #ccc !important;
|
||||
--chart-lane-label: #000 !important;
|
||||
--chart-tick-label: #000 !important;
|
||||
}
|
||||
.smart-timeline-chart .chart-mark--deadline.chart-mark--status-open .chart-mark-dot {
|
||||
fill: #fff !important;
|
||||
stroke: #000 !important;
|
||||
}
|
||||
.smart-timeline-chart-header h1 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ export function renderViews(): string {
|
||||
<button type="button" className="agenda-chip" data-shape="list" role="tab" data-i18n="views.shape.list">Liste</button>
|
||||
<button type="button" className="agenda-chip" data-shape="cards" role="tab" data-i18n="views.shape.cards">Karten</button>
|
||||
<button type="button" className="agenda-chip" data-shape="calendar" role="tab" data-i18n="views.shape.calendar">Kalender</button>
|
||||
<button type="button" className="agenda-chip" data-shape="timeline" role="tab" data-i18n="views.shape.timeline">Timeline</button>
|
||||
</div>
|
||||
<div className="views-toolbar-spacer" />
|
||||
<a href="#" className="btn-secondary btn-small" id="views-save-as" data-i18n="views.save_as" hidden>
|
||||
@@ -94,6 +95,24 @@ export function renderViews(): string {
|
||||
<div className="views-shape-host views-shape-list" id="views-shape-list" hidden />
|
||||
<div className="views-shape-host views-shape-cards" id="views-shape-cards" hidden />
|
||||
<div className="views-shape-host views-shape-calendar" id="views-shape-calendar" hidden />
|
||||
<div className="views-shape-host views-shape-timeline" id="views-shape-timeline" hidden>
|
||||
{/* CV-chart caveat banner — design §13.4: ViewService
|
||||
doesn't run the fristenrechner calculator, so Custom
|
||||
Views show actual events only. One-time-per-session
|
||||
dismissible (sessionStorage). */}
|
||||
<div className="views-timeline-caveat" id="views-timeline-caveat" hidden>
|
||||
<span data-i18n="views.timeline.caveat.body">
|
||||
Custom Views zeigen nur eingetretene Ereignisse. Für prognostizierte Fristen das Projekt-Chart öffnen.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="views-timeline-caveat-close"
|
||||
id="views-timeline-caveat-close"
|
||||
aria-label="Schließen"
|
||||
>×</button>
|
||||
</div>
|
||||
<div id="views-timeline-chart-host" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<Footer />
|
||||
|
||||
27
internal/db/migrations/078_unified_rule_columns.down.sql
Normal file
27
internal/db/migrations/078_unified_rule_columns.down.sql
Normal file
@@ -0,0 +1,27 @@
|
||||
-- t-paliad-182 down — reverses 078_unified_rule_columns.up.sql.
|
||||
--
|
||||
-- Drops in reverse dependency order: indexes → CHECK constraints →
|
||||
-- FKs → columns. Idempotent (IF EXISTS guards everywhere).
|
||||
|
||||
DROP INDEX IF EXISTS paliad.deadline_rules_lifecycle_state_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rules_spawn_proceeding_type_id_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rules_trigger_event_id_idx;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_lifecycle_state_check;
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_priority_check;
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_combine_op_check;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_draft_of_fkey;
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_spawn_proceeding_type_id_fkey;
|
||||
ALTER TABLE paliad.deadline_rules DROP CONSTRAINT IF EXISTS deadline_rules_trigger_event_id_fkey;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
DROP COLUMN IF EXISTS published_at,
|
||||
DROP COLUMN IF EXISTS draft_of,
|
||||
DROP COLUMN IF EXISTS lifecycle_state,
|
||||
DROP COLUMN IF EXISTS is_court_set,
|
||||
DROP COLUMN IF EXISTS priority,
|
||||
DROP COLUMN IF EXISTS condition_expr,
|
||||
DROP COLUMN IF EXISTS combine_op,
|
||||
DROP COLUMN IF EXISTS spawn_proceeding_type_id,
|
||||
DROP COLUMN IF EXISTS trigger_event_id;
|
||||
173
internal/db/migrations/078_unified_rule_columns.up.sql
Normal file
173
internal/db/migrations/078_unified_rule_columns.up.sql
Normal file
@@ -0,0 +1,173 @@
|
||||
-- t-paliad-182 / Fristen Phase 3 Slice 1 (Step A of
|
||||
-- docs/design-fristen-phase2-2026-05-15.md §3.1).
|
||||
--
|
||||
-- Additive only: extends paliad.deadline_rules with the unified-rule
|
||||
-- columns the Phase 3 calculator + rule editor will use.
|
||||
--
|
||||
-- NO drops in this slice. Legacy columns (is_mandatory, is_optional,
|
||||
-- condition_flag, condition_rule_id) stay live until Slice 9. Compat-
|
||||
-- mode readers consume both shapes during the transition window
|
||||
-- (design §3.2 "Cutover ordering").
|
||||
--
|
||||
-- Column-by-column rationale:
|
||||
-- trigger_event_id — event-rooted dispatch (Pipeline C unification, §2.5).
|
||||
-- spawn_proceeding_type_id — cross-proceeding spawn resolution (Q7, §2.6).
|
||||
-- combine_op — composite-rule arithmetic 'max'/'min' (R.198/R.213).
|
||||
-- condition_expr — jsonb condition grammar replacing condition_flag (Q6, §2.4).
|
||||
-- priority — 4-way enum mandatory|recommended|optional|informational (Q3, §2.3).
|
||||
-- is_court_set — explicit replacement of the runtime heuristic (Q12).
|
||||
-- lifecycle_state — draft|published|archived for the rule editor (Q5, §4.2).
|
||||
-- draft_of — draft self-FK pointing at the published row it replaces.
|
||||
-- published_at — promotion timestamp, NULL while draft.
|
||||
--
|
||||
-- FK type notes:
|
||||
-- trigger_event_id is BIGINT (paliad.trigger_events.id is bigint, mig 028).
|
||||
-- spawn_proceeding_type_id is INTEGER (paliad.proceeding_types.id is
|
||||
-- serial = int4, mig 003).
|
||||
-- draft_of is UUID (self-FK on paliad.deadline_rules.id).
|
||||
-- The design doc (§2.1) calls them "int FK" loosely; the actual schemas
|
||||
-- demand the precise int width, hence bigint/integer here.
|
||||
--
|
||||
-- Indexes:
|
||||
-- FK lookups for trigger_event_id + spawn_proceeding_type_id (sparse,
|
||||
-- most rules have neither — partial WHERE NOT NULL keeps the index
|
||||
-- small).
|
||||
-- lifecycle_state is queried by the admin /admin/rules listing's
|
||||
-- default filter (state='published'); plain btree is fine, no
|
||||
-- WHERE clause so 'draft' / 'archived' rows index too.
|
||||
--
|
||||
-- Idempotent: every ADD COLUMN uses IF NOT EXISTS. Re-applying is a
|
||||
-- no-op. Tracker advances 77 → 78.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. New columns on paliad.deadline_rules
|
||||
-- =============================================================================
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD COLUMN IF NOT EXISTS trigger_event_id bigint,
|
||||
ADD COLUMN IF NOT EXISTS spawn_proceeding_type_id integer,
|
||||
ADD COLUMN IF NOT EXISTS combine_op text,
|
||||
ADD COLUMN IF NOT EXISTS condition_expr jsonb,
|
||||
ADD COLUMN IF NOT EXISTS priority text NOT NULL DEFAULT 'mandatory',
|
||||
ADD COLUMN IF NOT EXISTS is_court_set boolean NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS lifecycle_state text NOT NULL DEFAULT 'published',
|
||||
ADD COLUMN IF NOT EXISTS draft_of uuid,
|
||||
ADD COLUMN IF NOT EXISTS published_at timestamptz;
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.trigger_event_id IS
|
||||
'Optional FK to paliad.trigger_events. When non-NULL, this rule is '
|
||||
'event-rooted (Pipeline C unification, design §2.5). When NULL the '
|
||||
'rule is proceeding-rooted via proceeding_type_id. Exactly one of '
|
||||
'the two must be set after Slice 3 backfill (enforced by a CHECK '
|
||||
'constraint added in Slice 9 after legacy callers retire).';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.spawn_proceeding_type_id IS
|
||||
'When is_spawn=true, points at the target proceeding whose rule set '
|
||||
'the calculator follows when this rule fires (cross-proceeding '
|
||||
'spawn, design §2.6). Backfilled in Slice 7 for the 8 live spawn '
|
||||
'rules.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.combine_op IS
|
||||
'NULL = single-anchor arithmetic. ''max'' / ''min'' = composite-rule '
|
||||
'arithmetic combining (duration_value, duration_unit) with '
|
||||
'(alt_duration_value, alt_duration_unit). Used by R.198 / R.213 '
|
||||
'("31d OR 20 working_days, whichever is longer / shorter").';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.condition_expr IS
|
||||
'jsonb gating expression replacing condition_flag (Q6, design §2.4). '
|
||||
'Grammar: {"flag": "<name>"} | {"op":"and"|"or", "args":[...]} | '
|
||||
'{"op":"not", "args":[<node>]}. NULL or {} = unconditional. '
|
||||
'Backfilled in Slice 2 from condition_flag; new code reads this, '
|
||||
'falls back to condition_flag during the transition window.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.priority IS
|
||||
'Unified 4-way enum (Q3, design §2.3) replacing the is_mandatory + '
|
||||
'is_optional pair. Allowed: mandatory | recommended | optional | '
|
||||
'informational. Default ''mandatory'' on new rows; legacy rows get '
|
||||
'backfilled in Slice 2 from the (is_mandatory, is_optional) pair.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.is_court_set IS
|
||||
'Replaces the runtime heuristic (primary_party=''court'' OR '
|
||||
'event_type IN (...)) with an explicit column (Q12). Default false '
|
||||
'on new rows; Slice 2 backfills from the heuristic so behaviour is '
|
||||
'unchanged at first.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.lifecycle_state IS
|
||||
'Rule-editor lifecycle (Q5, design §4.2). draft = work-in-progress '
|
||||
'admin edit; published = live, calculator-visible; archived = '
|
||||
'historical (kept for audit). Default ''published'' so every '
|
||||
'existing row stays live without an UPDATE.';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.draft_of IS
|
||||
'When lifecycle_state=''draft'', points at the published rule this '
|
||||
'draft will replace on publish. NULL on published or archived '
|
||||
'rows. NULL also on net-new drafts (no prior published peer).';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_rules.published_at IS
|
||||
'Timestamp this row entered lifecycle_state=''published''. NULL '
|
||||
'while draft, populated on publish, retained through archive. '
|
||||
'Distinct from updated_at (which moves on every edit).';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Foreign keys
|
||||
-- =============================================================================
|
||||
--
|
||||
-- DEFERRABLE INITIALLY IMMEDIATE keeps normal-statement semantics
|
||||
-- intact while still letting backfill migrations defer until end-of-
|
||||
-- transaction if they need to (e.g. when Slice 3 inserts a rule row
|
||||
-- whose trigger_event_id references a row inserted in the same tx).
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_trigger_event_id_fkey
|
||||
FOREIGN KEY (trigger_event_id)
|
||||
REFERENCES paliad.trigger_events(id)
|
||||
ON DELETE SET NULL
|
||||
DEFERRABLE INITIALLY IMMEDIATE;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_spawn_proceeding_type_id_fkey
|
||||
FOREIGN KEY (spawn_proceeding_type_id)
|
||||
REFERENCES paliad.proceeding_types(id)
|
||||
ON DELETE SET NULL
|
||||
DEFERRABLE INITIALLY IMMEDIATE;
|
||||
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_draft_of_fkey
|
||||
FOREIGN KEY (draft_of)
|
||||
REFERENCES paliad.deadline_rules(id)
|
||||
ON DELETE SET NULL
|
||||
DEFERRABLE INITIALLY IMMEDIATE;
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. CHECK constraints on enum-style columns
|
||||
-- =============================================================================
|
||||
--
|
||||
-- combine_op: NULL (unset) or one of two values.
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_combine_op_check
|
||||
CHECK (combine_op IS NULL OR combine_op IN ('max', 'min'));
|
||||
|
||||
-- priority: 4-way enum.
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_priority_check
|
||||
CHECK (priority IN ('mandatory', 'recommended', 'optional', 'informational'));
|
||||
|
||||
-- lifecycle_state: 3-way enum.
|
||||
ALTER TABLE paliad.deadline_rules
|
||||
ADD CONSTRAINT deadline_rules_lifecycle_state_check
|
||||
CHECK (lifecycle_state IN ('draft', 'published', 'archived'));
|
||||
|
||||
-- =============================================================================
|
||||
-- 4. Indexes
|
||||
-- =============================================================================
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rules_trigger_event_id_idx
|
||||
ON paliad.deadline_rules (trigger_event_id)
|
||||
WHERE trigger_event_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rules_spawn_proceeding_type_id_idx
|
||||
ON paliad.deadline_rules (spawn_proceeding_type_id)
|
||||
WHERE spawn_proceeding_type_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rules_lifecycle_state_idx
|
||||
ON paliad.deadline_rules (lifecycle_state);
|
||||
15
internal/db/migrations/079_deadline_rule_audit.down.sql
Normal file
15
internal/db/migrations/079_deadline_rule_audit.down.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
-- t-paliad-182 down — reverses 079_deadline_rule_audit.up.sql.
|
||||
--
|
||||
-- Order: trigger → function → policy → indexes → table.
|
||||
|
||||
DROP TRIGGER IF EXISTS deadline_rules_audit_aiud ON paliad.deadline_rules;
|
||||
DROP FUNCTION IF EXISTS paliad.deadline_rule_audit_trigger();
|
||||
|
||||
DROP POLICY IF EXISTS deadline_rule_audit_select ON paliad.deadline_rule_audit;
|
||||
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_audit_pending_export_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_audit_changed_by_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_audit_changed_at_idx;
|
||||
DROP INDEX IF EXISTS paliad.deadline_rule_audit_rule_id_idx;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.deadline_rule_audit;
|
||||
207
internal/db/migrations/079_deadline_rule_audit.up.sql
Normal file
207
internal/db/migrations/079_deadline_rule_audit.up.sql
Normal file
@@ -0,0 +1,207 @@
|
||||
-- t-paliad-182 / Fristen Phase 3 Slice 1 — audit log for the rule editor
|
||||
-- (design §2.8, §3.1 Step A.079).
|
||||
--
|
||||
-- The audit log lands BEFORE the rule editor (Slice 11) so every future
|
||||
-- write to paliad.deadline_rules is captured forever, including the
|
||||
-- Slice 2 backfill UPDATEs. Defence-in-depth: the rule-editor service
|
||||
-- writes Go-authored audit rows with semantic actions ('publish',
|
||||
-- 'archive', 'restore'); this trigger is the backstop for raw SQL.
|
||||
--
|
||||
-- Field-naming mirrors design §2.8 (`changed_by` / `changed_at` /
|
||||
-- `before_json` / `after_json` / `migration_exported`), not the
|
||||
-- audit_log shorthand used elsewhere in Paliad.
|
||||
--
|
||||
-- Schema deviations from design §2.8, documented for the head review:
|
||||
--
|
||||
-- 1. `changed_by` is nullable, not NOT NULL. Reason: the trigger reads
|
||||
-- auth.uid() which is NULL when the writer is `service_role`
|
||||
-- (migrations, server-side Go using the service key, direct DB
|
||||
-- maintenance). NOT NULL would block every Slice-2 backfill UPDATE
|
||||
-- and every migration-applied seed. The Go rule-editor service
|
||||
-- enforces non-NULL changed_by at the application layer when it
|
||||
-- writes its own audit rows.
|
||||
--
|
||||
-- 2. `action` values stored by the trigger are 'create' / 'update' /
|
||||
-- 'delete' (the raw TG_OP semantics). Go-authored audit rows can
|
||||
-- additionally store 'publish' / 'archive' / 'restore' — those are
|
||||
-- lifecycle_state flips at the SQL level and appear as 'update' in
|
||||
-- the trigger's view of the world. The Go layer writes the
|
||||
-- higher-level action *before* the UPDATE, so the human-readable
|
||||
-- action is captured even though the trigger fires a paired
|
||||
-- 'update' row. The audit UI in Slice 11 collapses paired rows.
|
||||
--
|
||||
-- Audit-reason enforcement: the trigger reads
|
||||
-- `current_setting('paliad.audit_reason', true)` (the `true` flag
|
||||
-- returns NULL when unset rather than raising). On UPDATE and DELETE
|
||||
-- the trigger requires a non-empty reason and raises EXCEPTION 'audit
|
||||
-- reason required' if missing. On INSERT the reason is optional
|
||||
-- (defaults to 'create' so seed migrations don't need to set it).
|
||||
--
|
||||
-- Idempotent: re-applying is a no-op. Tracker advances 78 → 79.
|
||||
|
||||
-- =============================================================================
|
||||
-- 1. paliad.deadline_rule_audit
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.deadline_rule_audit (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
|
||||
-- The rule this delta concerns. ON DELETE CASCADE: when a rule row
|
||||
-- gets hard-deleted (rare; lifecycle_state='archived' is the normal
|
||||
-- path), drop its audit chain too — the trail otherwise survives in
|
||||
-- the migration history of the table itself.
|
||||
rule_id uuid NOT NULL
|
||||
REFERENCES paliad.deadline_rules(id) ON DELETE CASCADE,
|
||||
|
||||
-- See header comment §1: nullable so trigger writes from service_role
|
||||
-- contexts (migrations, backfills) don't fail.
|
||||
changed_by uuid REFERENCES auth.users(id) ON DELETE SET NULL,
|
||||
|
||||
changed_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
-- See header comment §2 for the trigger vs Go-layer split.
|
||||
action text NOT NULL
|
||||
CHECK (action IN (
|
||||
'create', 'update', 'delete',
|
||||
'publish', 'archive', 'restore'
|
||||
)),
|
||||
|
||||
-- Row state pre/post change. NULL on create / delete respectively.
|
||||
before_json jsonb,
|
||||
after_json jsonb,
|
||||
|
||||
-- Justification required by the trigger on UPDATE / DELETE; optional
|
||||
-- on INSERT (defaults to 'create' when paliad.audit_reason is unset
|
||||
-- so seed migrations don't need to bother).
|
||||
reason text NOT NULL,
|
||||
|
||||
-- Flips to true when the migration-export endpoint (Slice 11b) folds
|
||||
-- this delta into a checked-in .up.sql. Lets the export endpoint
|
||||
-- skip already-exported rows.
|
||||
migration_exported boolean NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_audit_rule_id_idx
|
||||
ON paliad.deadline_rule_audit (rule_id, changed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_audit_changed_at_idx
|
||||
ON paliad.deadline_rule_audit (changed_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_audit_changed_by_idx
|
||||
ON paliad.deadline_rule_audit (changed_by)
|
||||
WHERE changed_by IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_rule_audit_pending_export_idx
|
||||
ON paliad.deadline_rule_audit (changed_at DESC)
|
||||
WHERE migration_exported = false;
|
||||
|
||||
COMMENT ON TABLE paliad.deadline_rule_audit IS
|
||||
'Append-only audit log for paliad.deadline_rules. Written by the '
|
||||
'AFTER-trigger on the rules table (raw create/update/delete) and '
|
||||
'by the Go rule-editor service (semantic publish/archive/restore). '
|
||||
'Required reason field is the compliance hook for the rule-editor '
|
||||
'design (Q5, §4.7).';
|
||||
|
||||
-- =============================================================================
|
||||
-- 2. Audit trigger
|
||||
-- =============================================================================
|
||||
--
|
||||
-- SECURITY DEFINER so the trigger function runs with the table-owner's
|
||||
-- privileges and bypasses RLS on the audit table. Otherwise an
|
||||
-- authenticated user's UPDATE on a rule would fail when the trigger
|
||||
-- tried to INSERT under their RLS context.
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.deadline_rule_audit_trigger()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = paliad, public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_reason text;
|
||||
v_action text;
|
||||
v_before jsonb;
|
||||
v_after jsonb;
|
||||
v_rule_id uuid;
|
||||
BEGIN
|
||||
v_reason := current_setting('paliad.audit_reason', true);
|
||||
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
v_action := 'create';
|
||||
v_before := NULL;
|
||||
v_after := to_jsonb(NEW);
|
||||
v_rule_id := NEW.id;
|
||||
-- INSERT is allowed without an explicit reason; seed migrations
|
||||
-- and net-new drafts default to a synthetic reason.
|
||||
IF v_reason IS NULL OR v_reason = '' THEN
|
||||
v_reason := 'create';
|
||||
END IF;
|
||||
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
v_action := 'update';
|
||||
v_before := to_jsonb(OLD);
|
||||
v_after := to_jsonb(NEW);
|
||||
v_rule_id := NEW.id;
|
||||
IF v_reason IS NULL OR v_reason = '' THEN
|
||||
RAISE EXCEPTION 'paliad.deadline_rules: audit reason required for UPDATE — '
|
||||
'set paliad.audit_reason via SET LOCAL or set_config()';
|
||||
END IF;
|
||||
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
v_action := 'delete';
|
||||
v_before := to_jsonb(OLD);
|
||||
v_after := NULL;
|
||||
v_rule_id := OLD.id;
|
||||
IF v_reason IS NULL OR v_reason = '' THEN
|
||||
RAISE EXCEPTION 'paliad.deadline_rules: audit reason required for DELETE — '
|
||||
'set paliad.audit_reason via SET LOCAL or set_config()';
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
INSERT INTO paliad.deadline_rule_audit
|
||||
(rule_id, changed_by, action, before_json, after_json, reason)
|
||||
VALUES
|
||||
(v_rule_id, auth.uid(), v_action, v_before, v_after, v_reason);
|
||||
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.deadline_rule_audit_trigger() IS
|
||||
'AFTER-trigger backstop that writes paliad.deadline_rule_audit rows '
|
||||
'for every raw INSERT / UPDATE / DELETE on paliad.deadline_rules. '
|
||||
'UPDATE / DELETE require paliad.audit_reason to be set in the '
|
||||
'session (via SET LOCAL paliad.audit_reason = ...); INSERT defaults '
|
||||
'to ''create'' so seed migrations remain ergonomic.';
|
||||
|
||||
DROP TRIGGER IF EXISTS deadline_rules_audit_aiud ON paliad.deadline_rules;
|
||||
|
||||
CREATE TRIGGER deadline_rules_audit_aiud
|
||||
AFTER INSERT OR UPDATE OR DELETE ON paliad.deadline_rules
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.deadline_rule_audit_trigger();
|
||||
|
||||
-- =============================================================================
|
||||
-- 3. RLS on the audit table
|
||||
-- =============================================================================
|
||||
--
|
||||
-- Read: global_admin only (mirrors mig 057 pattern). Service-layer code
|
||||
-- gates `/admin/rules/{id}/audit` separately; this RLS is defence-in-
|
||||
-- depth for any future auth-context query path.
|
||||
--
|
||||
-- Write: nobody via row-level paths. The trigger function is
|
||||
-- SECURITY DEFINER so it bypasses RLS entirely. Direct INSERTs by
|
||||
-- authenticated users are denied (no INSERT policy). service_role
|
||||
-- bypasses RLS as usual.
|
||||
|
||||
ALTER TABLE paliad.deadline_rule_audit ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY deadline_rule_audit_select
|
||||
ON paliad.deadline_rule_audit FOR SELECT
|
||||
USING (
|
||||
EXISTS (
|
||||
SELECT 1 FROM paliad.users u
|
||||
WHERE u.id = auth.uid()
|
||||
AND u.global_role = 'global_admin'
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
-- t-paliad-182 down — reverses 080_projects_instance_level.up.sql.
|
||||
|
||||
ALTER TABLE paliad.projects DROP COLUMN IF EXISTS instance_level;
|
||||
30
internal/db/migrations/080_projects_instance_level.up.sql
Normal file
30
internal/db/migrations/080_projects_instance_level.up.sql
Normal file
@@ -0,0 +1,30 @@
|
||||
-- t-paliad-182 / Fristen Phase 3 Slice 1 — paliad.projects.instance_level
|
||||
-- (design §2.7, §7).
|
||||
--
|
||||
-- Lets the SmartTimeline + calculator derive the effective proceeding
|
||||
-- code from (proceeding_code, instance_level) — e.g. DE_INF + 'appeal'
|
||||
-- resolves to DE_INF_OLG.
|
||||
--
|
||||
-- Nullable: NULL means "not asked / not relevant" (e.g. EP_GRANT, a
|
||||
-- non-litigation patent project). Allowed values:
|
||||
-- first — first instance (default once the picker UI lands)
|
||||
-- appeal — Berufung / EPA Beschwerde / appellate level
|
||||
-- cassation — BGH-Revision / EPA-EBA / final instance
|
||||
--
|
||||
-- No backfill in this slice. The picker UI (Slice 8) writes the column;
|
||||
-- legacy projects stay NULL and behave as if first instance via the
|
||||
-- calculator's fallback (`NULL OR 'first'` → use base proceeding code).
|
||||
--
|
||||
-- Idempotent: re-applying is a no-op. Tracker advances 79 → 80.
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN IF NOT EXISTS instance_level text
|
||||
CHECK (instance_level IS NULL
|
||||
OR instance_level IN ('first', 'appeal', 'cassation'));
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.instance_level IS
|
||||
'Procedural instance the project sits at: first | appeal | '
|
||||
'cassation. NULL = unset / not applicable. Combined with '
|
||||
'proceeding_type.code + jurisdiction by FristenrechnerService to '
|
||||
'pick the effective proceeding code (e.g. DE_INF + appeal → '
|
||||
'DE_INF_OLG). See design-fristen-phase2-2026-05-15.md §2.7, §7.';
|
||||
21
internal/db/migrations/082_backfill_is_court_set.down.sql
Normal file
21
internal/db/migrations/082_backfill_is_court_set.down.sql
Normal file
@@ -0,0 +1,21 @@
|
||||
-- t-paliad-183 down — reverts the is_court_set flips written by
|
||||
-- 082_backfill_is_court_set.up.sql.
|
||||
--
|
||||
-- "Revert" here means: restore the post-Slice-1 default (false on every
|
||||
-- row). We don't know after the fact which rows were already true
|
||||
-- before the backfill (mig 078 created the column with DEFAULT false on
|
||||
-- every existing row, so post-Slice-1 every row was false — there is
|
||||
-- no pre-existing true population to preserve). Setting back to false
|
||||
-- is therefore equivalent to "undo the backfill".
|
||||
--
|
||||
-- Audit-reason set so the trigger doesn't raise on the down-side
|
||||
-- UPDATEs either.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 082: reset is_court_set to mig 078 default (false)',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = false
|
||||
WHERE is_court_set = true;
|
||||
68
internal/db/migrations/082_backfill_is_court_set.up.sql
Normal file
68
internal/db/migrations/082_backfill_is_court_set.up.sql
Normal file
@@ -0,0 +1,68 @@
|
||||
-- t-paliad-183 / Fristen Phase 3 Slice 2 Step B-1 — backfill
|
||||
-- paliad.deadline_rules.is_court_set from the live runtime heuristic.
|
||||
--
|
||||
-- Heuristic source-of-truth: internal/services/fristenrechner.go
|
||||
-- isCourtDeterminedRule() — at the time of Slice 1 (commit c7fa0d6) the
|
||||
-- body is precisely:
|
||||
--
|
||||
-- primary_party = 'court'
|
||||
-- OR event_type IN ('hearing', 'decision', 'order')
|
||||
--
|
||||
-- The Slice 2 head instruction (msg 1746) suggested padding with
|
||||
-- 'name ILIKE %entscheidung% OR %urteil%'; head's clarification
|
||||
-- (msg 1750) rules that out: replicate the live code exactly. Padding
|
||||
-- would mis-flag party submissions like 'Antrag auf Kostenentscheidung'
|
||||
-- (RoP.151) and 'Stellungnahme zum Hinweisbeschluss' as court-set —
|
||||
-- they are not (the party files them; only their anchor is set by the
|
||||
-- court).
|
||||
--
|
||||
-- Audit footnote for the legal-review pass: ~8 'Zustellung…' rules
|
||||
-- (Zustellung BPatG-Entscheidung, Zustellung LG-Urteil, etc.) carry
|
||||
-- primary_party='both' + event_type='filing'. Semantically the
|
||||
-- Zustellung date IS court-set, but the live heuristic doesn't treat
|
||||
-- them as such and flagging them now would change calculator
|
||||
-- rendering without legal review. Leaving them is_court_set=false
|
||||
-- preserves current behaviour; the legal-review pass mentioned in
|
||||
-- design §2.3 ("flag them informational in a Phase 3 slice") can
|
||||
-- promote them later via a targeted UPDATE.
|
||||
--
|
||||
-- Audit-reason: set_config('paliad.audit_reason', …, true) scopes the
|
||||
-- value to golang-migrate's implicit per-file transaction. The audit
|
||||
-- trigger from mig 079 picks it up via current_setting() and writes
|
||||
-- one paliad.deadline_rule_audit row per flipped rule — the compliance
|
||||
-- trail for the backfill, persisted forever.
|
||||
--
|
||||
-- Idempotent: WHERE is_court_set = false guards re-runs against double-
|
||||
-- counting audit rows.
|
||||
--
|
||||
-- Expected delta on the production corpus (172 rules): 47 rows flipped
|
||||
-- false→true (every primary_party='court' rule also has a matching
|
||||
-- event_type in the current data — the two predicates fully overlap).
|
||||
--
|
||||
-- Tracker note: mig 081 was reserved for proceeding_types display_order
|
||||
-- verification per design §3.1; that was a no-op and not authored.
|
||||
-- Slice 1 shipped 078/079/080; Slice 2 starts at 082. golang-migrate
|
||||
-- only requires ascending order, not contiguity.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'backfill 082: is_court_set from isCourtDeterminedRule heuristic '
|
||||
|| '(primary_party=court OR event_type IN hearing/decision/order) '
|
||||
|| 'per design §2.3 / fristenrechner.go',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET is_court_set = true
|
||||
WHERE is_court_set = false
|
||||
AND (
|
||||
primary_party = 'court'
|
||||
OR event_type IN ('hearing', 'decision', 'order')
|
||||
);
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_set int;
|
||||
BEGIN
|
||||
SELECT count(*) INTO n_set FROM paliad.deadline_rules WHERE is_court_set = true;
|
||||
RAISE NOTICE 'backfill 082: is_court_set=true on % rules', n_set;
|
||||
END $$;
|
||||
17
internal/db/migrations/083_backfill_priority.down.sql
Normal file
17
internal/db/migrations/083_backfill_priority.down.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
-- t-paliad-183 down — reverts the priority flips written by
|
||||
-- 083_backfill_priority.up.sql.
|
||||
--
|
||||
-- "Revert" here means: restore the post-Slice-1 column default
|
||||
-- ('mandatory' on every row). Mig 078 created the column with that
|
||||
-- default; post-Slice-1 every row was 'mandatory' regardless of its
|
||||
-- (is_mandatory, is_optional) pair. Resetting to 'mandatory' is
|
||||
-- therefore equivalent to "undo the backfill".
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 083: reset priority to mig 078 default (mandatory)',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET priority = 'mandatory'
|
||||
WHERE priority <> 'mandatory';
|
||||
110
internal/db/migrations/083_backfill_priority.up.sql
Normal file
110
internal/db/migrations/083_backfill_priority.up.sql
Normal file
@@ -0,0 +1,110 @@
|
||||
-- t-paliad-183 / Fristen Phase 3 Slice 2 Step B-2 — backfill
|
||||
-- paliad.deadline_rules.priority from the legacy (is_mandatory,
|
||||
-- is_optional) pair per DESIGN §2.3 (NOT the inverted mapping in
|
||||
-- head's msg 1746 — head's clarification msg 1750 rules in favour of
|
||||
-- the design doc).
|
||||
--
|
||||
-- Final mapping (design §2.3 + RoP.151 / mig 068 t-paliad-157 semantic):
|
||||
--
|
||||
-- is_mandatory=true, is_optional=false → 'mandatory' (statutory must,
|
||||
-- ☑ pre-checked in
|
||||
-- save modal)
|
||||
-- is_mandatory=true, is_optional=true → 'optional' (statutorily strict
|
||||
-- ONCE IT APPLIES,
|
||||
-- but applies only
|
||||
-- if a party files —
|
||||
-- RoP.151 is the
|
||||
-- canonical case;
|
||||
-- ☐ pre-unchecked)
|
||||
-- is_mandatory=false, is_optional=true → 'recommended' (no live data, but
|
||||
-- defensive default
|
||||
-- so the CHECK
|
||||
-- constraint stays
|
||||
-- satisfied if such
|
||||
-- a row ever lands)
|
||||
-- is_mandatory=false, is_optional=false → 'recommended' (situational filings
|
||||
-- — Berufungserwiderung,
|
||||
-- Replik, Duplik,
|
||||
-- R.19 Preliminary
|
||||
-- Objection, R.116
|
||||
-- EPÜ, Anschluss-
|
||||
-- berufung, etc.
|
||||
-- Default-save with
|
||||
-- override, not
|
||||
-- 'informational'
|
||||
-- which would make
|
||||
-- them never-saveable)
|
||||
--
|
||||
-- Live-data expected delta (172 rules total, mig 078 set every row to
|
||||
-- the default 'mandatory'):
|
||||
-- T/F (153 rows) → 'mandatory' — 153 no-op UPDATEs (already correct)
|
||||
-- T/T ( 1 row) → 'optional' — 1 row flips
|
||||
-- F/F ( 18 rows) → 'recommended' — 18 rows flip
|
||||
-- F/T ( 0 rows) → 'recommended' — 0 rows (no live data)
|
||||
--
|
||||
-- The UPDATE is split into branches with explicit WHERE clauses so the
|
||||
-- audit log records each branch as a distinct backfill action (separate
|
||||
-- audit row chains by (is_mandatory, is_optional) shape). It also keeps
|
||||
-- the migration idempotent: re-running only touches rows whose priority
|
||||
-- doesn't already match the target.
|
||||
--
|
||||
-- Audit-reason cites design §2.3 — that's the persistent rationale in
|
||||
-- the paliad.deadline_rule_audit log.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'backfill 083: priority from (is_mandatory, is_optional) per design §2.3 — '
|
||||
|| 'T/T→optional (RoP.151), F/F→recommended (situational filings)',
|
||||
true);
|
||||
|
||||
-- Branch 1: T/T → 'optional' (RoP.151).
|
||||
UPDATE paliad.deadline_rules
|
||||
SET priority = 'optional'
|
||||
WHERE is_mandatory = true
|
||||
AND is_optional = true
|
||||
AND priority <> 'optional';
|
||||
|
||||
-- Branch 2: F/F → 'recommended'.
|
||||
UPDATE paliad.deadline_rules
|
||||
SET priority = 'recommended'
|
||||
WHERE is_mandatory = false
|
||||
AND is_optional = false
|
||||
AND priority <> 'recommended';
|
||||
|
||||
-- Branch 3: F/T → 'recommended' (defensive; no live rows today).
|
||||
UPDATE paliad.deadline_rules
|
||||
SET priority = 'recommended'
|
||||
WHERE is_mandatory = false
|
||||
AND is_optional = true
|
||||
AND priority <> 'recommended';
|
||||
|
||||
-- Branch 4: T/F → 'mandatory'. Skipped explicitly: the mig 078 column
|
||||
-- default is already 'mandatory', so every T/F row already has the
|
||||
-- correct value. A defensive UPDATE here would write 153 needless
|
||||
-- audit rows. Leave T/F untouched.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_mand int;
|
||||
n_opt int;
|
||||
n_reco int;
|
||||
n_info int;
|
||||
n_null int;
|
||||
BEGIN
|
||||
SELECT count(*) FILTER (WHERE priority = 'mandatory'),
|
||||
count(*) FILTER (WHERE priority = 'optional'),
|
||||
count(*) FILTER (WHERE priority = 'recommended'),
|
||||
count(*) FILTER (WHERE priority = 'informational'),
|
||||
count(*) FILTER (WHERE priority IS NULL)
|
||||
INTO n_mand, n_opt, n_reco, n_info, n_null
|
||||
FROM paliad.deadline_rules;
|
||||
RAISE NOTICE 'backfill 083: priority distribution — '
|
||||
'mandatory=%, optional=%, recommended=%, informational=%, NULL=%',
|
||||
n_mand, n_opt, n_reco, n_info, n_null;
|
||||
-- Hard assertion: priority is NOT NULL by schema (mig 078) and
|
||||
-- every value must lie in the CHECK enum. n_null must be 0.
|
||||
IF n_null > 0 THEN
|
||||
RAISE EXCEPTION 'backfill 083: % rows still have priority IS NULL — '
|
||||
'schema violation', n_null;
|
||||
END IF;
|
||||
END $$;
|
||||
14
internal/db/migrations/084_backfill_condition_expr.down.sql
Normal file
14
internal/db/migrations/084_backfill_condition_expr.down.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- t-paliad-183 down — reverts the condition_expr translations written
|
||||
-- by 084_backfill_condition_expr.up.sql. Mig 078 created the column
|
||||
-- with NULL on every row; resetting non-NULL values to NULL undoes the
|
||||
-- backfill cleanly (condition_flag is the source of truth for the
|
||||
-- legacy code path and stays untouched).
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'rollback 084: reset condition_expr to mig 078 default (NULL)',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules
|
||||
SET condition_expr = NULL
|
||||
WHERE condition_expr IS NOT NULL;
|
||||
111
internal/db/migrations/084_backfill_condition_expr.up.sql
Normal file
111
internal/db/migrations/084_backfill_condition_expr.up.sql
Normal file
@@ -0,0 +1,111 @@
|
||||
-- t-paliad-183 / Fristen Phase 3 Slice 2 Step B-3 — backfill
|
||||
-- paliad.deadline_rules.condition_expr from the legacy
|
||||
-- condition_flag text[] column per DESIGN §2.4 long form (NOT the
|
||||
-- short {"and":[...]} form sketched in head's msg 1746 — head's
|
||||
-- clarification msg 1750 rules in favour of the design doc).
|
||||
--
|
||||
-- Mapping (design §2.4):
|
||||
--
|
||||
-- condition_flag IS NULL OR array_length(_, 1) = 0
|
||||
-- → condition_expr stays NULL (unconditional, every rule renders)
|
||||
--
|
||||
-- array_length = 1, e.g. ['with_ccr']
|
||||
-- → condition_expr = jsonb '{"flag": "with_ccr"}'
|
||||
-- (single flag unwrapped — saves a layer of nesting that
|
||||
-- parses as the same boolean expression)
|
||||
--
|
||||
-- array_length >= 2, e.g. ['with_ccr', 'with_amend']
|
||||
-- → condition_expr = jsonb '{"op":"and","args":[
|
||||
-- {"flag":"with_ccr"},
|
||||
-- {"flag":"with_amend"}
|
||||
-- ]}'
|
||||
-- (long form — same shape the rule editor will emit for OR /
|
||||
-- NOT in future rules so the calculator's parser is uniform)
|
||||
--
|
||||
-- Why long form on >=2: the calculator (Slice 4) reads
|
||||
-- {"op":"<and|or|not>","args":[...]} as the canonical boolean node and
|
||||
-- {"flag":"<name>"} as the leaf. Single-flag unwrap is a parse-time
|
||||
-- shortcut equivalent to a 1-arg AND. The short {"and":[...]} form in
|
||||
-- msg 1746 would require a per-key parser that doesn't generalise to
|
||||
-- OR / NOT. Design §2.4 long form is the load-bearing decision.
|
||||
--
|
||||
-- Live-data expected delta (172 rules total):
|
||||
--
|
||||
-- ['with_ccr'] × 5 rows → {"flag":"with_ccr"}
|
||||
-- ['with_amend'] × 4 rows → {"flag":"with_amend"}
|
||||
-- ['with_cci'] × 4 rows → {"flag":"with_cci"}
|
||||
-- ['with_ccr', 'with_amend'] × 4 rows → {"op":"and","args":[
|
||||
-- {"flag":"with_ccr"},
|
||||
-- {"flag":"with_amend"}
|
||||
-- ]}
|
||||
-- NULL or {} × 155 rows → stays NULL
|
||||
--
|
||||
-- Total touched: 17 rows.
|
||||
--
|
||||
-- Idempotent: WHERE condition_expr IS NULL guards re-runs against
|
||||
-- double-writing audit rows for already-translated rules.
|
||||
--
|
||||
-- jsonb construction: jsonb_build_object + jsonb_agg + a CASE on
|
||||
-- array_length keeps the long-form / unwrapped-flag split inline in
|
||||
-- one UPDATE. Per-flag jsonb leaf is built by a LATERAL unnest over
|
||||
-- the flag array so the args[] order matches the source array.
|
||||
|
||||
SELECT set_config(
|
||||
'paliad.audit_reason',
|
||||
'backfill 084: condition_expr from condition_flag text[] per design §2.4 — '
|
||||
|| 'single flag unwrapped, multi flag long form {op:and, args:[...]}',
|
||||
true);
|
||||
|
||||
UPDATE paliad.deadline_rules dr
|
||||
SET condition_expr = sub.expr
|
||||
FROM (
|
||||
SELECT dr_inner.id AS rule_id,
|
||||
CASE
|
||||
-- Single flag: unwrapped leaf.
|
||||
WHEN array_length(dr_inner.condition_flag, 1) = 1
|
||||
THEN jsonb_build_object('flag', dr_inner.condition_flag[1])
|
||||
|
||||
-- >=2 flags: long-form AND with args[] preserving order.
|
||||
WHEN array_length(dr_inner.condition_flag, 1) >= 2
|
||||
THEN jsonb_build_object(
|
||||
'op', 'and',
|
||||
'args', (
|
||||
SELECT jsonb_agg(jsonb_build_object('flag', f) ORDER BY ord)
|
||||
FROM unnest(dr_inner.condition_flag) WITH ORDINALITY AS u(f, ord)
|
||||
)
|
||||
)
|
||||
|
||||
-- Empty array (array_length=0) or NULL: leave NULL.
|
||||
ELSE NULL
|
||||
END AS expr
|
||||
FROM paliad.deadline_rules dr_inner
|
||||
WHERE dr_inner.condition_flag IS NOT NULL
|
||||
AND array_length(dr_inner.condition_flag, 1) > 0
|
||||
) AS sub
|
||||
WHERE dr.id = sub.rule_id
|
||||
AND dr.condition_expr IS NULL;
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
n_total int;
|
||||
n_with_flag int;
|
||||
n_with_expr int;
|
||||
n_with_both int;
|
||||
BEGIN
|
||||
SELECT count(*),
|
||||
count(*) FILTER (WHERE condition_flag IS NOT NULL AND array_length(condition_flag, 1) > 0),
|
||||
count(*) FILTER (WHERE condition_expr IS NOT NULL),
|
||||
count(*) FILTER (WHERE condition_flag IS NOT NULL AND array_length(condition_flag, 1) > 0
|
||||
AND condition_expr IS NOT NULL)
|
||||
INTO n_total, n_with_flag, n_with_expr, n_with_both
|
||||
FROM paliad.deadline_rules;
|
||||
RAISE NOTICE 'backfill 084: total=%, with_condition_flag=%, with_condition_expr=%, both=%',
|
||||
n_total, n_with_flag, n_with_expr, n_with_both;
|
||||
-- Hard assertion: every rule with a non-empty condition_flag now
|
||||
-- has a non-NULL condition_expr (the inverse of the legacy column).
|
||||
IF n_with_flag <> n_with_both THEN
|
||||
RAISE EXCEPTION 'backfill 084: % rules carry condition_flag but no condition_expr — '
|
||||
'translation incomplete',
|
||||
n_with_flag - n_with_both;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1,16 +1,74 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
// t-paliad-177 Slice 1 — Project Timeline / Chart standalone page.
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// t-paliad-177 — Project Timeline / Chart standalone page.
|
||||
//
|
||||
// Serves the statically-generated dist/projects-chart.html shell for
|
||||
// GET /projects/{id}/chart. The visibility check happens client-side
|
||||
// against the existing /api/projects/{id}/timeline endpoint, which
|
||||
// already gates on project visibility through ProjectionService.For.
|
||||
// Slice 1 served dist/projects-chart.html unconditionally and relied on
|
||||
// the client's first API fetch to enforce visibility. That leaked a 200
|
||||
// for any well-formed UUID a guesser tried (m/paliad#35 Slice 1 edge
|
||||
// case #2). Slice 2 closes the leak — we resolve the project via
|
||||
// ProjectService.GetByID *before* serving the shell so an inaccessible
|
||||
// id returns 404 + the standard notfound chrome.
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
|
||||
|
||||
func handleProjectsChartPage(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 {
|
||||
serveChartNotFound(w)
|
||||
return
|
||||
}
|
||||
if _, err := dbSvc.projects.GetByID(r.Context(), uid, id); err != nil {
|
||||
// ErrNotVisible + any "not found" surface from the service collapses
|
||||
// to the same outward 404 — never tell a guesser whether the id
|
||||
// exists, only whether they can see it.
|
||||
if errors.Is(err, services.ErrNotVisible) {
|
||||
serveChartNotFound(w)
|
||||
return
|
||||
}
|
||||
// Genuine errors (DB hiccup, etc.) — log via writeServiceError but
|
||||
// also fall back to 404 page chrome for the user instead of a raw
|
||||
// 500 string. The JSON path of writeServiceError handles /api/*
|
||||
// only, so we keep its logging side-effect but render the HTML.
|
||||
writeServiceError(httpDevNullJSON{}, err)
|
||||
serveChartNotFound(w)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, "dist/projects-chart.html")
|
||||
}
|
||||
|
||||
func serveChartNotFound(w http.ResponseWriter) {
|
||||
body, err := os.ReadFile("dist/notfound.html")
|
||||
if err != nil {
|
||||
http.Error(w, "404 page not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// httpDevNullJSON is a writer that discards everything writeServiceError
|
||||
// would have emitted — we only want the log line, not a duplicate body
|
||||
// before serveChartNotFound writes the real one.
|
||||
type httpDevNullJSON struct{}
|
||||
|
||||
func (httpDevNullJSON) Header() http.Header { return http.Header{} }
|
||||
func (httpDevNullJSON) Write(b []byte) (int, error) { return len(b), nil }
|
||||
func (httpDevNullJSON) WriteHeader(int) {}
|
||||
|
||||
30
internal/handlers/chart_pages_test.go
Normal file
30
internal/handlers/chart_pages_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// t-paliad-177 Slice 2 — visibility leak fix.
|
||||
//
|
||||
// The end-to-end "GET /chart returns 404 for invisible projects" check
|
||||
// would need a mocked ProjectService + auth.Client; the handler package
|
||||
// has no harness for that today (all existing _test.go files unit-test
|
||||
// pure helpers). Until that harness exists, we pin the contract from
|
||||
// the helper layer: serveChartNotFound writes a 404 + an HTML
|
||||
// Content-Type. The dist/notfound.html lookup falls back to a plain
|
||||
// 404 string in test environments without a built frontend, which is
|
||||
// the documented degraded path.
|
||||
|
||||
func TestServeChartNotFound_Returns404HTML(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
serveChartNotFound(w)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusNotFound)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if body == "" {
|
||||
t.Error("body is empty — should be either the notfound chrome or the plain-text fallback")
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// /timeline/anchor is the click-to-anchor write (Slice 2).
|
||||
// /timeline/skip is the "ist nicht eingetreten" decision (§6.4).
|
||||
protected.HandleFunc("GET /api/projects/{id}/timeline", handleGetProjectTimeline)
|
||||
// t-paliad-177 Slice 2 — iCal feed (deadlines + appointments only).
|
||||
protected.HandleFunc("GET /api/projects/{id}/timeline.ics", handleGetProjectTimelineICS)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/milestone", handleCreateProjectTimelineMilestone)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/anchor", handleProjectTimelineAnchor)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/skip", handleProjectTimelineSkip)
|
||||
|
||||
@@ -96,6 +96,91 @@ func handleGetProjectTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/projects/{id}/timeline.ics
|
||||
//
|
||||
// t-paliad-177 Slice 2 — iCal feed export. Returns a VCALENDAR with one
|
||||
// VEVENT per deadline + appointment row (faraday-Q6: NO projected — a
|
||||
// calendar feed must never carry predicted dates the user never
|
||||
// confirmed). Reuses the formatter from caldav_ical.go so future
|
||||
// CalDAV sync work and chart exports share one source of truth.
|
||||
//
|
||||
// Visibility piggybacks on ProjectionService.For (same gate as
|
||||
// /timeline). Project title is fetched via ProjectService.GetByID and
|
||||
// passed as the X-WR-CALNAME for Outlook / Apple Calendar display.
|
||||
func handleGetProjectTimelineICS(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.projection == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "projection service unavailable",
|
||||
})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
rows, _, err := dbSvc.projection.For(r.Context(), uid, id, services.ProjectionOpts{})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
proj, err := dbSvc.projects.GetByID(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
body := services.FormatTimelineICS(rows, proj.Title)
|
||||
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
// Sanitise the project title for the filename — RFC-7230 disallows
|
||||
// many bytes in header values, and Outlook truncates non-ASCII
|
||||
// disposition filenames inconsistently. ASCII slug + date is portable.
|
||||
w.Header().Set(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="paliad-`+filenameSlug(proj.Title)+`-`+
|
||||
time.Now().UTC().Format("2006-01-02")+`.ics"`,
|
||||
)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
|
||||
func filenameSlug(s string) string {
|
||||
if s == "" {
|
||||
return "timeline"
|
||||
}
|
||||
out := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-', c == '.':
|
||||
out = append(out, c)
|
||||
default:
|
||||
if len(out) > 0 && out[len(out)-1] != '_' {
|
||||
out = append(out, '_')
|
||||
}
|
||||
}
|
||||
}
|
||||
for len(out) > 0 && (out[0] == '_' || out[len(out)-1] == '_') {
|
||||
if out[0] == '_' {
|
||||
out = out[1:]
|
||||
} else {
|
||||
out = out[:len(out)-1]
|
||||
}
|
||||
}
|
||||
if len(out) > 60 {
|
||||
out = out[:60]
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return "timeline"
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// POST /api/projects/{id}/timeline/anchor
|
||||
//
|
||||
// Body: {"rule_code":"inf.sod","actual_date":"2026-08-31","kind":"deadline"}
|
||||
|
||||
@@ -171,6 +171,16 @@ type Project struct {
|
||||
// sibling under the same patent (§4.4 of the design doc).
|
||||
CounterclaimOf *uuid.UUID `db:"counterclaim_of" json:"counterclaim_of,omitempty"`
|
||||
|
||||
// InstanceLevel is the procedural instance the project sits at:
|
||||
// 'first' (default) | 'appeal' | 'cassation'. Combined with the
|
||||
// proceeding code + jurisdiction by FristenrechnerService to pick
|
||||
// the effective proceeding (DE_INF + appeal → DE_INF_OLG, etc.).
|
||||
// NULL = unset / not applicable; the calculator treats NULL as
|
||||
// 'first'. Backfill happens via the project-detail picker UI
|
||||
// (Phase 3 Slice 8); this column ships in Slice 1 ahead of the
|
||||
// service rewrite (mig 080, t-paliad-182).
|
||||
InstanceLevel *string `db:"instance_level" json:"instance_level,omitempty"`
|
||||
|
||||
Metadata json.RawMessage `db:"metadata" json:"metadata"`
|
||||
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
@@ -500,6 +510,100 @@ type DeadlineRule struct {
|
||||
IsActive bool `db:"is_active" json:"is_active"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// ---------------------------------------------------------------
|
||||
// Phase 3 unified-rule columns (mig 078, t-paliad-182).
|
||||
// Populated by Slice 2 backfill; readers are compat-mode (read
|
||||
// both shapes) until Slice 4 cuts the calculator over and Slice 9
|
||||
// drops the legacy columns above (IsMandatory, IsOptional,
|
||||
// ConditionFlag, ConditionRuleID).
|
||||
// ---------------------------------------------------------------
|
||||
|
||||
// TriggerEventID points at paliad.trigger_events when this rule is
|
||||
// event-rooted (Pipeline C unification, design §2.5). NULL on
|
||||
// proceeding-rooted rules. Exactly one of (proceeding_type_id,
|
||||
// trigger_event_id) is set after Slice 3.
|
||||
TriggerEventID *int64 `db:"trigger_event_id" json:"trigger_event_id,omitempty"`
|
||||
|
||||
// SpawnProceedingTypeID is the cross-proceeding spawn target —
|
||||
// when is_spawn=true and this is non-NULL, the calculator follows
|
||||
// the FK and emits the target proceeding's root rule chain. Slice
|
||||
// 7 backfills the 8 live is_spawn=true rows.
|
||||
SpawnProceedingTypeID *int `db:"spawn_proceeding_type_id" json:"spawn_proceeding_type_id,omitempty"`
|
||||
|
||||
// CombineOp is 'max' or 'min' for composite-rule arithmetic
|
||||
// (R.198 / R.213: "31d OR 20 working_days, whichever is longer").
|
||||
// NULL = single-anchor arithmetic.
|
||||
CombineOp *string `db:"combine_op" json:"combine_op,omitempty"`
|
||||
|
||||
// ConditionExpr is the jsonb gating expression replacing
|
||||
// ConditionFlag (design §2.4). Grammar:
|
||||
// {"flag": "<name>"}
|
||||
// {"op":"and"|"or", "args":[<node>, ...]}
|
||||
// {"op":"not", "args":[<node>]}
|
||||
// NULL or {} = unconditional. NullableJSON so a NULL column scans
|
||||
// cleanly (the row mishap that hid approval rows from the inbox
|
||||
// must not recur on rule rows).
|
||||
ConditionExpr NullableJSON `db:"condition_expr" json:"condition_expr,omitempty"`
|
||||
|
||||
// Priority is the 4-way unified enum replacing
|
||||
// (IsMandatory, IsOptional). Values: 'mandatory' (default),
|
||||
// 'recommended', 'optional', 'informational'. Backfilled in
|
||||
// Slice 2; legacy callers read IsMandatory + IsOptional until
|
||||
// Slice 4 cuts them over.
|
||||
Priority string `db:"priority" json:"priority"`
|
||||
|
||||
// IsCourtSet replaces the runtime heuristic
|
||||
// (primary_party='court' OR event_type IN ('hearing','decision',
|
||||
// 'order')). Backfilled in Slice 2; legacy callers read the
|
||||
// heuristic until Slice 4.
|
||||
IsCourtSet bool `db:"is_court_set" json:"is_court_set"`
|
||||
|
||||
// LifecycleState drives the rule-editor flow (design §4.2):
|
||||
// 'draft' (admin work-in-progress) | 'published' (live, calculator-
|
||||
// visible) | 'archived' (historical, retained for audit). Every
|
||||
// pre-Slice-1 row defaults to 'published' via the migration.
|
||||
LifecycleState string `db:"lifecycle_state" json:"lifecycle_state"`
|
||||
|
||||
// DraftOf points at the published rule this draft will replace on
|
||||
// publish. NULL on published / archived rows. NULL also on net-
|
||||
// new drafts that have no prior published peer.
|
||||
DraftOf *uuid.UUID `db:"draft_of" json:"draft_of,omitempty"`
|
||||
|
||||
// PublishedAt records when the row entered LifecycleState='published'.
|
||||
// NULL while draft, set on publish, retained through archive.
|
||||
// Distinct from UpdatedAt (moves on every edit).
|
||||
PublishedAt *time.Time `db:"published_at" json:"published_at,omitempty"`
|
||||
}
|
||||
|
||||
// DeadlineRuleAudit is one row of paliad.deadline_rule_audit — the
|
||||
// append-only audit log for every change to paliad.deadline_rules.
|
||||
// Written by the AFTER-trigger (raw create / update / delete) and by
|
||||
// the Go rule-editor service (semantic publish / archive / restore).
|
||||
// See migration 079 and design-fristen-phase2-2026-05-15.md §2.8.
|
||||
type DeadlineRuleAudit struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
RuleID uuid.UUID `db:"rule_id" json:"rule_id"`
|
||||
ChangedBy *uuid.UUID `db:"changed_by" json:"changed_by,omitempty"`
|
||||
ChangedAt time.Time `db:"changed_at" json:"changed_at"`
|
||||
|
||||
// Action is one of: create | update | delete (trigger-written) |
|
||||
// publish | archive | restore (Go-written by the rule editor).
|
||||
Action string `db:"action" json:"action"`
|
||||
|
||||
// BeforeJSON is the row state pre-change (NULL on 'create').
|
||||
// AfterJSON is the row state post-change (NULL on 'delete').
|
||||
BeforeJSON NullableJSON `db:"before_json" json:"before_json,omitempty"`
|
||||
AfterJSON NullableJSON `db:"after_json" json:"after_json,omitempty"`
|
||||
|
||||
// Reason is required on update / delete (the trigger raises if
|
||||
// paliad.audit_reason is unset). On create the trigger defaults
|
||||
// to 'create' so seed migrations don't need to bother.
|
||||
Reason string `db:"reason" json:"reason"`
|
||||
|
||||
// MigrationExported flips to true once the Slice 11b export
|
||||
// endpoint folds this delta into a checked-in .up.sql.
|
||||
MigrationExported bool `db:"migration_exported" json:"migration_exported"`
|
||||
}
|
||||
|
||||
// ProceedingType is one of INF/REV/CCR/APM/APP/AMD/ZPO_CIVIL (matter
|
||||
|
||||
@@ -25,6 +25,7 @@ const (
|
||||
calProductID = "-//Paliad//Paliad Appointments//EN"
|
||||
calVersion = "2.0"
|
||||
icalDateUTC = "20060102T150405Z"
|
||||
icalDateOnly = "20060102"
|
||||
)
|
||||
|
||||
// terminUID is the canonical CalDAV UID for a Paliad Appointment. Paliad-owned
|
||||
@@ -34,6 +35,14 @@ func terminUID(id string) string {
|
||||
return "paliad-appointment-" + id + "@paliad.de"
|
||||
}
|
||||
|
||||
// deadlineUID is the canonical iCal UID for a Paliad Deadline exported via
|
||||
// the chart's iCal feed (t-paliad-177 Slice 2). Distinct prefix from
|
||||
// terminUID so subscribers can't confuse the two — and so a re-export
|
||||
// updates the same calendar entry instead of duplicating it.
|
||||
func deadlineUID(id string) string {
|
||||
return "paliad-deadline-" + id + "@paliad.de"
|
||||
}
|
||||
|
||||
// extractAppointmentID returns the Paliad Appointment id (uuid string) embedded in a
|
||||
// terminUID, or "" when the UID isn't ours.
|
||||
func extractAppointmentID(uid string) string {
|
||||
@@ -83,6 +92,73 @@ func formatAppointment(t *models.Appointment) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// FormatTimelineICS renders a single VCALENDAR with one VEVENT per
|
||||
// timeline row that is a real actual (kind == "deadline" or
|
||||
// "appointment"). Projected / milestone rows are deliberately skipped
|
||||
// (design §7.8, faraday-Q6 / m's pick: trust-erosion otherwise — a
|
||||
// calendar should never fire predicted dates the user never confirmed).
|
||||
//
|
||||
// Deadlines render as all-day events (DTSTART;VALUE=DATE) because the
|
||||
// substrate marshals due_date as UTC-midnight; appointments render as
|
||||
// timestamped UTC events. Both UIDs are stable across re-exports so an
|
||||
// Outlook subscriber sees deduped entries on every refresh.
|
||||
func FormatTimelineICS(events []TimelineEvent, projectTitle string) string {
|
||||
var b strings.Builder
|
||||
w := func(line string) {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\r\n")
|
||||
}
|
||||
w("BEGIN:VCALENDAR")
|
||||
w("PRODID:" + calProductID)
|
||||
w("VERSION:" + calVersion)
|
||||
if projectTitle != "" {
|
||||
w("X-WR-CALNAME:" + escapeText("Paliad — "+projectTitle))
|
||||
}
|
||||
now := time.Now().UTC().Format(icalDateUTC)
|
||||
for _, ev := range events {
|
||||
if ev.Date == nil {
|
||||
continue
|
||||
}
|
||||
switch ev.Kind {
|
||||
case "deadline":
|
||||
w("BEGIN:VEVENT")
|
||||
if ev.DeadlineID != nil {
|
||||
w("UID:" + deadlineUID(ev.DeadlineID.String()))
|
||||
} else {
|
||||
// Synthetic UID — shouldn't happen for actuals, but be defensive.
|
||||
w("UID:paliad-timeline-" + now + "@paliad.de")
|
||||
}
|
||||
w("DTSTAMP:" + now)
|
||||
w("DTSTART;VALUE=DATE:" + ev.Date.UTC().Format(icalDateOnly))
|
||||
w("SUMMARY:" + escapeText(ev.Title))
|
||||
if ev.Description != "" {
|
||||
w("DESCRIPTION:" + escapeText(ev.Description))
|
||||
}
|
||||
w("END:VEVENT")
|
||||
case "appointment":
|
||||
w("BEGIN:VEVENT")
|
||||
if ev.AppointmentID != nil {
|
||||
w("UID:" + terminUID(ev.AppointmentID.String()))
|
||||
} else {
|
||||
w("UID:paliad-timeline-" + now + "@paliad.de")
|
||||
}
|
||||
w("DTSTAMP:" + now)
|
||||
w("DTSTART:" + ev.Date.UTC().Format(icalDateUTC))
|
||||
w("SUMMARY:" + escapeText(ev.Title))
|
||||
if ev.Description != "" {
|
||||
w("DESCRIPTION:" + escapeText(ev.Description))
|
||||
}
|
||||
w("END:VEVENT")
|
||||
default:
|
||||
// milestone / projected / off_script are visualisation-only —
|
||||
// never written to a calendar feed (design §7.8 + faraday-Q6).
|
||||
continue
|
||||
}
|
||||
}
|
||||
w("END:VCALENDAR")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func escapeText(s string) string {
|
||||
r := strings.NewReplacer(
|
||||
`\`, `\\`,
|
||||
|
||||
122
internal/services/caldav_ical_timeline_test.go
Normal file
122
internal/services/caldav_ical_timeline_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// t-paliad-177 Slice 2 — pins FormatTimelineICS behavior.
|
||||
//
|
||||
// Trust contract: lawyers subscribe the .ics URL in Outlook / Apple
|
||||
// Calendar; predicted dates must NOT appear (faraday-Q6 / m's pick),
|
||||
// and re-export must update (not duplicate) prior entries.
|
||||
|
||||
func TestFormatTimelineICS_OnlyDeadlinesAndAppointments(t *testing.T) {
|
||||
due := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
start := time.Date(2026, 7, 1, 9, 30, 0, 0, time.UTC)
|
||||
dID := uuid.New()
|
||||
aID := uuid.New()
|
||||
|
||||
events := []TimelineEvent{
|
||||
{Kind: "deadline", Status: "open", Date: &due, Title: "Klageerwiderung", DeadlineID: &dID},
|
||||
{Kind: "appointment", Status: "open", Date: &start, Title: "Hearing", AppointmentID: &aID},
|
||||
{Kind: "milestone", Status: "done", Date: &due, Title: "Filed"},
|
||||
{Kind: "projected", Status: "predicted", Date: &due, Title: "Predicted R.29c"},
|
||||
{Kind: "projected", Status: "court_set", Date: &start, Title: "Court set HV"},
|
||||
}
|
||||
out := FormatTimelineICS(events, "Siemens ./. Huawei")
|
||||
|
||||
// Sanity: VCALENDAR boundaries.
|
||||
if !strings.HasPrefix(out, "BEGIN:VCALENDAR\r\n") {
|
||||
t.Fatalf("missing VCALENDAR start: %q", firstLines(out, 3))
|
||||
}
|
||||
if !strings.HasSuffix(out, "END:VCALENDAR\r\n") {
|
||||
t.Errorf("missing VCALENDAR end")
|
||||
}
|
||||
|
||||
// Should emit exactly 2 VEVENTs (1 deadline + 1 appointment), nothing for the 3 skipped kinds.
|
||||
if got := strings.Count(out, "BEGIN:VEVENT"); got != 2 {
|
||||
t.Errorf("VEVENT count = %d, want 2 (deadline + appointment only)", got)
|
||||
}
|
||||
|
||||
// Deadline → VALUE=DATE.
|
||||
if !strings.Contains(out, "DTSTART;VALUE=DATE:20260615") {
|
||||
t.Errorf("deadline DTSTART should be all-day VALUE=DATE format; got:\n%s", out)
|
||||
}
|
||||
// Appointment → UTC timestamp.
|
||||
if !strings.Contains(out, "DTSTART:20260701T093000Z") {
|
||||
t.Errorf("appointment DTSTART should be UTC timestamp; got:\n%s", out)
|
||||
}
|
||||
|
||||
// UIDs distinct + namespaced.
|
||||
if !strings.Contains(out, "UID:paliad-deadline-"+dID.String()+"@paliad.de") {
|
||||
t.Errorf("missing canonical deadline UID")
|
||||
}
|
||||
if !strings.Contains(out, "UID:paliad-appointment-"+aID.String()+"@paliad.de") {
|
||||
t.Errorf("missing canonical appointment UID")
|
||||
}
|
||||
|
||||
// X-WR-CALNAME from project title (escaped — ' . / ' contains no special chars but check it's there).
|
||||
if !strings.Contains(out, "X-WR-CALNAME:Paliad — Siemens ./. Huawei") {
|
||||
t.Errorf("X-WR-CALNAME missing or wrong: searching in:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimelineICS_UndatedRowsSkipped(t *testing.T) {
|
||||
dID := uuid.New()
|
||||
events := []TimelineEvent{
|
||||
{Kind: "deadline", Status: "open", Date: nil, Title: "Datum offen", DeadlineID: &dID},
|
||||
}
|
||||
out := FormatTimelineICS(events, "X")
|
||||
if strings.Contains(out, "BEGIN:VEVENT") {
|
||||
t.Errorf("undated deadlines must not emit a VEVENT (no DTSTART would be invalid iCal)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimelineICS_TitleEscaping(t *testing.T) {
|
||||
due := time.Date(2026, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
dID := uuid.New()
|
||||
events := []TimelineEvent{
|
||||
{
|
||||
Kind: "deadline", Status: "open", Date: &due,
|
||||
Title: `Frist mit ; und , und \ und ` + "\n" + "Newline",
|
||||
Description: `Beschreibung mit Komma,`,
|
||||
DeadlineID: &dID,
|
||||
},
|
||||
}
|
||||
out := FormatTimelineICS(events, "")
|
||||
// RFC 5545 §3.3.11: ; , \ \n must be escaped.
|
||||
if !strings.Contains(out, `\;`) {
|
||||
t.Errorf("missing escaped semicolon")
|
||||
}
|
||||
if !strings.Contains(out, `\,`) {
|
||||
t.Errorf("missing escaped comma")
|
||||
}
|
||||
if !strings.Contains(out, `\\`) {
|
||||
t.Errorf("missing escaped backslash")
|
||||
}
|
||||
if !strings.Contains(out, `\n`) {
|
||||
t.Errorf("missing escaped newline")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatTimelineICS_EmptyInputProducesValidEmptyCalendar(t *testing.T) {
|
||||
out := FormatTimelineICS(nil, "Empty Matter")
|
||||
if !strings.HasPrefix(out, "BEGIN:VCALENDAR\r\n") {
|
||||
t.Errorf("empty input should still produce a valid VCALENDAR header")
|
||||
}
|
||||
if strings.Contains(out, "BEGIN:VEVENT") {
|
||||
t.Errorf("empty input should produce 0 VEVENTs")
|
||||
}
|
||||
if !strings.HasSuffix(out, "END:VCALENDAR\r\n") {
|
||||
t.Errorf("empty input should still close the VCALENDAR")
|
||||
}
|
||||
}
|
||||
|
||||
func firstLines(s string, n int) string {
|
||||
parts := strings.SplitN(s, "\r\n", n+1)
|
||||
return strings.Join(parts[:min(n, len(parts))], "\r\n")
|
||||
}
|
||||
@@ -21,12 +21,25 @@ func NewDeadlineRuleService(db *sqlx.DB) *DeadlineRuleService {
|
||||
return &DeadlineRuleService{db: db}
|
||||
}
|
||||
|
||||
// ruleColumns lists every column scanned into models.DeadlineRule.
|
||||
//
|
||||
// Compat-mode (t-paliad-182 Phase 3 Slice 1): the SELECT reads BOTH
|
||||
// the legacy shape (is_mandatory, is_optional, condition_flag,
|
||||
// condition_rule_id) and the unified Phase 3 shape (trigger_event_id,
|
||||
// spawn_proceeding_type_id, combine_op, condition_expr, priority,
|
||||
// is_court_set, lifecycle_state, draft_of, published_at). Existing
|
||||
// callers stay on the legacy fields; the new fields are NULL or carry
|
||||
// their migration default until Slice 2 backfills them. Slice 4 cuts
|
||||
// the calculator over to the new fields, Slice 9 drops the legacy
|
||||
// columns.
|
||||
const ruleColumns = `id, proceeding_type_id, parent_id, code, name, name_en,
|
||||
description, primary_party, event_type, is_mandatory, duration_value,
|
||||
duration_unit, timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
condition_rule_id, condition_flag, alt_duration_value, alt_duration_unit, alt_rule_code,
|
||||
anchor_alt, concept_id, legal_source, is_spawn, spawn_label, is_optional, is_active,
|
||||
created_at, updated_at`
|
||||
created_at, updated_at,
|
||||
trigger_event_id, spawn_proceeding_type_id, combine_op, condition_expr,
|
||||
priority, is_court_set, lifecycle_state, draft_of, published_at`
|
||||
|
||||
const proceedingTypeColumns = `id, code, name, name_en, description, jurisdiction,
|
||||
category, default_color, sort_order, is_active`
|
||||
|
||||
384
internal/services/deadline_rule_service_test.go
Normal file
384
internal/services/deadline_rule_service_test.go
Normal file
@@ -0,0 +1,384 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
// TestDeadlineRuleService_UnifiedColumns_CompatRead exercises the Phase 3
|
||||
// Slice 1 (mig 078–080, t-paliad-182) additive-schema landing.
|
||||
//
|
||||
// What it validates:
|
||||
//
|
||||
// 1. Every Phase 3 column (trigger_event_id, spawn_proceeding_type_id,
|
||||
// combine_op, condition_expr, priority, is_court_set,
|
||||
// lifecycle_state, draft_of, published_at) is present on
|
||||
// paliad.deadline_rules after migrations apply and scans cleanly
|
||||
// into models.DeadlineRule.
|
||||
//
|
||||
// 2. The default migration values land: priority='mandatory',
|
||||
// is_court_set=false, lifecycle_state='published' on every pre-
|
||||
// Slice-1 row. New rows default the same way.
|
||||
//
|
||||
// 3. The audit trigger fires on UPDATE — exactly one
|
||||
// paliad.deadline_rule_audit row is written for an UPDATE that
|
||||
// supplies a reason via SET LOCAL paliad.audit_reason.
|
||||
//
|
||||
// 4. The audit trigger raises when paliad.audit_reason is unset on
|
||||
// UPDATE — Slice 2 backfills MUST set the reason or they fail
|
||||
// loudly.
|
||||
//
|
||||
// 5. paliad.projects.instance_level (mig 080) accepts NULL and the
|
||||
// three CHECK-allowed values, and rejects anything else.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset, mirroring audit_service_test.go.
|
||||
func TestDeadlineRuleService_UnifiedColumns_CompatRead(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
svc := NewDeadlineRuleService(pool)
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 1. SELECT every column via the service's ruleColumns list. The list
|
||||
// must end the test green even though it now includes the Phase 3
|
||||
// columns; if a scan error pops up we know a column name or Go
|
||||
// type slipped.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
rules, err := svc.List(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if len(rules) == 0 {
|
||||
t.Fatal("no rules returned; seed-data missing?")
|
||||
}
|
||||
|
||||
// 2. Every row scans cleanly. Priority + is_court_set values depend on
|
||||
// whether Slice 2 (mig 082–084) has applied: pre-Slice-2 they carry
|
||||
// the mig 078 defaults (priority='mandatory', is_court_set=false);
|
||||
// post-Slice-2 they carry the backfilled values per design §2.3.
|
||||
// LifecycleState is set by mig 078 to 'published' for every row and
|
||||
// is unaffected by Slice 2.
|
||||
allowedPriorities := map[string]bool{
|
||||
"mandatory": true, "recommended": true, "optional": true, "informational": true,
|
||||
}
|
||||
for _, r := range rules {
|
||||
if !allowedPriorities[r.Priority] {
|
||||
t.Errorf("rule %s: priority=%q not in enum", r.ID, r.Priority)
|
||||
}
|
||||
if r.LifecycleState != "published" {
|
||||
t.Errorf("rule %s: lifecycle_state=%q, want default 'published'", r.ID, r.LifecycleState)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 3 + 4. Audit trigger behaviour. Use a throwaway row in its own tx
|
||||
// so SET LOCAL is scoped to this test.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Pick any existing rule; we'll UPDATE its updated_at field with a
|
||||
// no-op-equivalent change (twice — once with reason, once without).
|
||||
target := rules[0]
|
||||
|
||||
// Count the audit rows for this rule before we touch it.
|
||||
var beforeCount int
|
||||
if err := pool.GetContext(ctx, &beforeCount,
|
||||
`SELECT count(*) FROM paliad.deadline_rule_audit WHERE rule_id = $1`, target.ID); err != nil {
|
||||
t.Fatalf("count audit rows pre-update: %v", err)
|
||||
}
|
||||
|
||||
// 3a. UPDATE WITH reason set — should succeed and write one audit row.
|
||||
tx, err := pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin tx: %v", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`SELECT set_config('paliad.audit_reason', 'test: compat-read audit smoke', true)`); err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("set audit reason: %v", err)
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rules SET updated_at = now() WHERE id = $1`, target.ID); err != nil {
|
||||
tx.Rollback()
|
||||
t.Fatalf("update with reason: %v", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
t.Fatalf("commit update-with-reason tx: %v", err)
|
||||
}
|
||||
|
||||
var afterCount int
|
||||
if err := pool.GetContext(ctx, &afterCount,
|
||||
`SELECT count(*) FROM paliad.deadline_rule_audit WHERE rule_id = $1`, target.ID); err != nil {
|
||||
t.Fatalf("count audit rows post-update: %v", err)
|
||||
}
|
||||
if afterCount != beforeCount+1 {
|
||||
t.Errorf("audit-row count: before=%d, after=%d, want before+1", beforeCount, afterCount)
|
||||
}
|
||||
|
||||
// Look up the audit row we just wrote: latest by changed_at, action='update'.
|
||||
var (
|
||||
auditAction string
|
||||
auditReason string
|
||||
auditBefore json.RawMessage
|
||||
auditAfter json.RawMessage
|
||||
)
|
||||
if err := pool.QueryRowxContext(ctx,
|
||||
`SELECT action, reason, before_json, after_json
|
||||
FROM paliad.deadline_rule_audit
|
||||
WHERE rule_id = $1
|
||||
ORDER BY changed_at DESC
|
||||
LIMIT 1`, target.ID).Scan(&auditAction, &auditReason, &auditBefore, &auditAfter); err != nil {
|
||||
t.Fatalf("read latest audit row: %v", err)
|
||||
}
|
||||
if auditAction != "update" {
|
||||
t.Errorf("audit action=%q, want 'update'", auditAction)
|
||||
}
|
||||
if auditReason != "test: compat-read audit smoke" {
|
||||
t.Errorf("audit reason=%q, want the set_config value", auditReason)
|
||||
}
|
||||
if len(auditBefore) == 0 || len(auditAfter) == 0 {
|
||||
t.Errorf("audit before/after json missing: before=%q after=%q", auditBefore, auditAfter)
|
||||
}
|
||||
|
||||
// 4. UPDATE WITHOUT reason — trigger must raise.
|
||||
tx2, err := pool.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("begin tx2: %v", err)
|
||||
}
|
||||
_, err = tx2.ExecContext(ctx,
|
||||
`UPDATE paliad.deadline_rules SET updated_at = now() WHERE id = $1`, target.ID)
|
||||
tx2.Rollback()
|
||||
if err == nil {
|
||||
t.Error("UPDATE without paliad.audit_reason should have raised, but succeeded")
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 5. paliad.projects.instance_level CHECK.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
userID := uuid.New()
|
||||
projectID := uuid.New()
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, 'instance-level-test@hlc.com')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, role, lang)
|
||||
VALUES ($1, 'instance-level-test@hlc.com', 'Instance Test', 'munich', 'associate', 'de')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, path, title, status, created_by, instance_level)
|
||||
VALUES ($1, 'project', $1::text, 'Instance Test', 'active', $2, 'appeal')`,
|
||||
projectID, userID); err != nil {
|
||||
t.Fatalf("seed paliad.projects with instance_level='appeal': %v", err)
|
||||
}
|
||||
|
||||
// Update to each allowed value should succeed; bogus value must fail.
|
||||
for _, lvl := range []string{"first", "cassation", "appeal"} {
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.projects SET instance_level = $1 WHERE id = $2`, lvl, projectID); err != nil {
|
||||
t.Errorf("update instance_level=%q: %v", lvl, err)
|
||||
}
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.projects SET instance_level = 'final' WHERE id = $1`, projectID); err == nil {
|
||||
t.Error("instance_level='final' should violate CHECK constraint, but succeeded")
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.projects SET instance_level = NULL WHERE id = $1`, projectID); err != nil {
|
||||
t.Errorf("NULL instance_level should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDeadlineRuleService_BackfillIntegrity exercises the Phase 3 Slice 2
|
||||
// (mig 082–084, t-paliad-183) backfills against the live corpus.
|
||||
//
|
||||
// What it validates:
|
||||
//
|
||||
// 1. is_court_set (mig 082): every rule with primary_party='court' OR
|
||||
// event_type IN ('hearing','decision','order') is true; every other
|
||||
// rule is false. Replicates isCourtDeterminedRule() exactly.
|
||||
//
|
||||
// 2. priority (mig 083): zero rules with NULL priority (CHECK guards
|
||||
// the schema, this is belt-and-braces). The four mapping branches
|
||||
// hold per design §2.3 — T/F→'mandatory', T/T→'optional',
|
||||
// F/T→'recommended', F/F→'recommended'.
|
||||
//
|
||||
// 3. condition_expr (mig 084): every rule with a non-empty
|
||||
// condition_flag has a non-NULL condition_expr; every rule with
|
||||
// NULL/empty condition_flag has NULL condition_expr. Single-flag
|
||||
// rules carry {"flag":"<name>"} (unwrapped); multi-flag rules
|
||||
// carry {"op":"and","args":[{"flag":"<a>"},...]} long form.
|
||||
//
|
||||
// Skipped when TEST_DATABASE_URL is unset.
|
||||
func TestDeadlineRuleService_BackfillIntegrity(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 1. is_court_set matches the live heuristic exactly.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
var mismatchCourt int
|
||||
if err := pool.GetContext(ctx, &mismatchCourt, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE is_court_set <> (
|
||||
primary_party = 'court'
|
||||
OR event_type IN ('hearing', 'decision', 'order')
|
||||
)`); err != nil {
|
||||
t.Fatalf("count court-mismatch rows: %v", err)
|
||||
}
|
||||
if mismatchCourt != 0 {
|
||||
t.Errorf("is_court_set diverges from heuristic on %d rules (mig 082 incomplete)", mismatchCourt)
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 2. priority backfill matches design §2.3.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
var nullPriority int
|
||||
if err := pool.GetContext(ctx, &nullPriority,
|
||||
`SELECT count(*) FROM paliad.deadline_rules WHERE priority IS NULL`); err != nil {
|
||||
t.Fatalf("count NULL priority rows: %v", err)
|
||||
}
|
||||
if nullPriority != 0 {
|
||||
t.Errorf("found %d rules with NULL priority — mig 083 incomplete or CHECK bypassed", nullPriority)
|
||||
}
|
||||
|
||||
type prioRow struct {
|
||||
IsMandatory bool `db:"is_mandatory"`
|
||||
IsOptional bool `db:"is_optional"`
|
||||
Priority string `db:"priority"`
|
||||
N int `db:"n"`
|
||||
}
|
||||
var prioBuckets []prioRow
|
||||
if err := pool.SelectContext(ctx, &prioBuckets, `
|
||||
SELECT is_mandatory, is_optional, priority, count(*) AS n
|
||||
FROM paliad.deadline_rules
|
||||
GROUP BY is_mandatory, is_optional, priority
|
||||
ORDER BY is_mandatory, is_optional, priority`); err != nil {
|
||||
t.Fatalf("bucket priorities: %v", err)
|
||||
}
|
||||
expectedPriority := func(isMand, isOpt bool) string {
|
||||
switch {
|
||||
case isMand && !isOpt:
|
||||
return "mandatory"
|
||||
case isMand && isOpt:
|
||||
return "optional"
|
||||
default: // F/T and F/F both map to 'recommended' per design §2.3.
|
||||
return "recommended"
|
||||
}
|
||||
}
|
||||
for _, row := range prioBuckets {
|
||||
want := expectedPriority(row.IsMandatory, row.IsOptional)
|
||||
if row.Priority != want {
|
||||
t.Errorf("(is_mandatory=%v, is_optional=%v) → priority=%q on %d rules, want %q",
|
||||
row.IsMandatory, row.IsOptional, row.Priority, row.N, want)
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// 3. condition_expr backfill matches design §2.4.
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Every non-empty condition_flag has a non-NULL condition_expr.
|
||||
var orphans int
|
||||
if err := pool.GetContext(ctx, &orphans, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE condition_flag IS NOT NULL
|
||||
AND array_length(condition_flag, 1) > 0
|
||||
AND condition_expr IS NULL`); err != nil {
|
||||
t.Fatalf("count condition_flag orphans: %v", err)
|
||||
}
|
||||
if orphans != 0 {
|
||||
t.Errorf("%d rules carry condition_flag but no condition_expr — mig 084 incomplete", orphans)
|
||||
}
|
||||
|
||||
// Every NULL/empty condition_flag has NULL condition_expr (no spurious writes).
|
||||
var spurious int
|
||||
if err := pool.GetContext(ctx, &spurious, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE (condition_flag IS NULL OR array_length(condition_flag, 1) IS NULL)
|
||||
AND condition_expr IS NOT NULL`); err != nil {
|
||||
t.Fatalf("count condition_expr spurious: %v", err)
|
||||
}
|
||||
if spurious != 0 {
|
||||
t.Errorf("%d rules carry condition_expr without condition_flag — mig 084 over-wrote", spurious)
|
||||
}
|
||||
|
||||
// Single-flag shape: condition_expr = {"flag":"<name>"} matches
|
||||
// condition_flag[1]. Use jsonb -> to extract the flag scalar.
|
||||
var singleMismatch int
|
||||
if err := pool.GetContext(ctx, &singleMismatch, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE array_length(condition_flag, 1) = 1
|
||||
AND condition_expr ->> 'flag' IS DISTINCT FROM condition_flag[1]`); err != nil {
|
||||
t.Fatalf("count single-flag mismatch: %v", err)
|
||||
}
|
||||
if singleMismatch != 0 {
|
||||
t.Errorf("%d single-flag rules have condition_expr.flag ≠ condition_flag[1]", singleMismatch)
|
||||
}
|
||||
|
||||
// Multi-flag shape: condition_expr.op='and', args length = flag count,
|
||||
// each args[i].flag = condition_flag[i+1] (1-indexed).
|
||||
var multiMismatch int
|
||||
if err := pool.GetContext(ctx, &multiMismatch, `
|
||||
SELECT count(*)
|
||||
FROM paliad.deadline_rules
|
||||
WHERE array_length(condition_flag, 1) >= 2
|
||||
AND (
|
||||
condition_expr ->> 'op' IS DISTINCT FROM 'and'
|
||||
OR jsonb_array_length(condition_expr -> 'args') IS DISTINCT FROM array_length(condition_flag, 1)
|
||||
)`); err != nil {
|
||||
t.Fatalf("count multi-flag mismatch: %v", err)
|
||||
}
|
||||
if multiMismatch != 0 {
|
||||
t.Errorf("%d multi-flag rules have malformed condition_expr (op/args shape)", multiMismatch)
|
||||
}
|
||||
}
|
||||
@@ -24,11 +24,19 @@ const (
|
||||
ShapeList RenderShape = "list"
|
||||
ShapeCards RenderShape = "cards"
|
||||
ShapeCalendar RenderShape = "calendar"
|
||||
// ShapeTimeline (t-paliad-177 Slice 4, faraday-Q7): cross-project
|
||||
// horizontal chart rendered by frontend/src/client/views/shape-
|
||||
// timeline-cv.ts on top of the same SVG renderer that powers
|
||||
// /projects/{id}/chart. Lane axis = project_id. Adapter is lossy:
|
||||
// ProjectionService projected rows are NOT surfaced (ViewService
|
||||
// doesn't run the calculator). UI tooltip on first open documents
|
||||
// the limitation.
|
||||
ShapeTimeline RenderShape = "timeline"
|
||||
)
|
||||
|
||||
// AllShapes lists every supported shape. Used by the validator and by
|
||||
// the in-page shape switcher.
|
||||
var AllShapes = []RenderShape{ShapeList, ShapeCards, ShapeCalendar}
|
||||
var AllShapes = []RenderShape{ShapeList, ShapeCards, ShapeCalendar, ShapeTimeline}
|
||||
|
||||
// RenderSpec is the top-level render description.
|
||||
//
|
||||
@@ -36,10 +44,25 @@ var AllShapes = []RenderShape{ShapeList, ShapeCards, ShapeCalendar}
|
||||
// is selected, so flipping back to a previously-used shape preserves
|
||||
// its tweaks (Q5 design decision).
|
||||
type RenderSpec struct {
|
||||
Shape RenderShape `json:"shape"`
|
||||
List *ListConfig `json:"list,omitempty"`
|
||||
Cards *CardsConfig `json:"cards,omitempty"`
|
||||
Shape RenderShape `json:"shape"`
|
||||
List *ListConfig `json:"list,omitempty"`
|
||||
Cards *CardsConfig `json:"cards,omitempty"`
|
||||
Calendar *CalendarConfig `json:"calendar,omitempty"`
|
||||
Timeline *TimelineConfig `json:"timeline,omitempty"`
|
||||
}
|
||||
|
||||
// TimelineConfig is the per-shape config for shape=timeline. Mirrors the
|
||||
// URL-state knobs of the standalone /projects/{id}/chart page: a saved
|
||||
// CV-timeline view bakes the user's chosen palette / density / range
|
||||
// preset into render_spec so reopening the view restores the same
|
||||
// visual. None are required — empty defaults match the standalone
|
||||
// chart's defaults (default palette, standard density, 1y range).
|
||||
type TimelineConfig struct {
|
||||
Palette string `json:"palette,omitempty"`
|
||||
Density string `json:"density,omitempty"`
|
||||
RangePreset string `json:"range_preset,omitempty"`
|
||||
RangeFrom string `json:"range_from,omitempty"`
|
||||
RangeTo string `json:"range_to,omitempty"`
|
||||
}
|
||||
|
||||
// ListConfig is the per-shape config for shape=list. Powers both the
|
||||
@@ -144,7 +167,7 @@ func (s *RenderSpec) Validate() error {
|
||||
return fmt.Errorf("%w: render_spec is required", ErrInvalidInput)
|
||||
}
|
||||
switch s.Shape {
|
||||
case ShapeList, ShapeCards, ShapeCalendar:
|
||||
case ShapeList, ShapeCards, ShapeCalendar, ShapeTimeline:
|
||||
// fine
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown render_spec.shape %q", ErrInvalidInput, s.Shape)
|
||||
@@ -165,6 +188,49 @@ func (s *RenderSpec) Validate() error {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if s.Timeline != nil {
|
||||
if err := s.Timeline.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// KnownTimelinePalettes / Densities / Ranges mirror the frontend enums
|
||||
// in shape-timeline-chart.ts. Anything outside this set is rejected so
|
||||
// a stray value from an old build / hostile editor can't sneak into
|
||||
// stored render_spec rows.
|
||||
var (
|
||||
knownTimelinePalettes = []string{
|
||||
"default", "kind-coded", "track-coded", "high-contrast", "print",
|
||||
}
|
||||
knownTimelineDensities = []string{
|
||||
"compact", "standard", "spacious",
|
||||
}
|
||||
knownTimelineRanges = []string{
|
||||
"1y", "2y", "all", "custom",
|
||||
}
|
||||
)
|
||||
|
||||
func (c *TimelineConfig) validate() error {
|
||||
if c.Palette != "" && !slices.Contains(knownTimelinePalettes, c.Palette) {
|
||||
return fmt.Errorf("%w: unknown timeline.palette %q", ErrInvalidInput, c.Palette)
|
||||
}
|
||||
if c.Density != "" && !slices.Contains(knownTimelineDensities, c.Density) {
|
||||
return fmt.Errorf("%w: unknown timeline.density %q", ErrInvalidInput, c.Density)
|
||||
}
|
||||
if c.RangePreset != "" && !slices.Contains(knownTimelineRanges, c.RangePreset) {
|
||||
return fmt.Errorf("%w: unknown timeline.range_preset %q", ErrInvalidInput, c.RangePreset)
|
||||
}
|
||||
// RangeFrom / RangeTo are free-form ISO dates — the frontend regex-
|
||||
// checks them; here we only verify they're plain ASCII length-bounded
|
||||
// so a giant string can't bloat the jsonb column.
|
||||
if len(c.RangeFrom) > 32 {
|
||||
return fmt.Errorf("%w: timeline.range_from too long", ErrInvalidInput)
|
||||
}
|
||||
if len(c.RangeTo) > 32 {
|
||||
return fmt.Errorf("%w: timeline.range_to too long", ErrInvalidInput)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ func TestRenderSpec_HappyPath(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestRenderSpec_ShapeMustBeKnown(t *testing.T) {
|
||||
cases := []RenderShape{ShapeList, ShapeCards, ShapeCalendar}
|
||||
cases := []RenderShape{ShapeList, ShapeCards, ShapeCalendar, ShapeTimeline}
|
||||
for _, sh := range cases {
|
||||
t.Run(string(sh), func(t *testing.T) {
|
||||
s := RenderSpec{Shape: sh}
|
||||
@@ -26,6 +26,36 @@ func TestRenderSpec_ShapeMustBeKnown(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_TimelineConfigValidates(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
cfg TimelineConfig
|
||||
ok bool
|
||||
}{
|
||||
{"empty defaults are fine", TimelineConfig{}, true},
|
||||
{"known palette", TimelineConfig{Palette: "kind-coded"}, true},
|
||||
{"known density", TimelineConfig{Density: "compact"}, true},
|
||||
{"known range preset", TimelineConfig{RangePreset: "2y"}, true},
|
||||
{"custom range with bounds", TimelineConfig{RangePreset: "custom", RangeFrom: "2026-01-01", RangeTo: "2026-12-31"}, true},
|
||||
{"unknown palette rejects", TimelineConfig{Palette: "neon"}, false},
|
||||
{"unknown density rejects", TimelineConfig{Density: "tiny"}, false},
|
||||
{"unknown range rejects", TimelineConfig{RangePreset: "10y"}, false},
|
||||
{"oversized range_from rejects", TimelineConfig{RangeFrom: string(make([]byte, 64))}, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
s := RenderSpec{Shape: ShapeTimeline, Timeline: &tc.cfg}
|
||||
err := s.Validate()
|
||||
if tc.ok && err != nil {
|
||||
t.Fatalf("expected ok, got error: %v", err)
|
||||
}
|
||||
if !tc.ok && !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_UnknownShapeRejects(t *testing.T) {
|
||||
s := RenderSpec{Shape: "kanban"}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
|
||||
Reference in New Issue
Block a user