Compare commits
34 Commits
mai/farada
...
mai/tesla/
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c915639b9 | |||
| 83a3d27fe0 | |||
| 79f6be3fc9 | |||
| b455df265e | |||
| 7d9935de60 | |||
| e9bcf3a7b6 | |||
| 1ad78918bc | |||
| 5e1f1fecf6 | |||
| 731e762919 | |||
| 581fbe7d92 | |||
| 8f5b83ec93 | |||
| 7c4bc39115 | |||
| adf377c2ca | |||
| f5eb84718a | |||
| 1255ee049f | |||
| 0105d35f0c | |||
| 0531e5dbf6 | |||
| 0099e2f28c | |||
| 3ba5727deb | |||
| d8f7745f86 | |||
| 98a51faa66 | |||
| b24063bee1 | |||
| d1314a46f9 | |||
| 968b0bc2da | |||
| cd1a70d08c | |||
| bdb3d8a425 | |||
| 30f7031e99 | |||
| 8e9cde6d52 | |||
| a3adb6b13b | |||
| ed4e731333 | |||
| b0a6b0998f | |||
|
|
54b227ce7b | ||
|
|
c2f1c29b10 | ||
|
|
17e96b7a1c |
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.**
|
||||
569
docs/design-tools-cleanup-2026-05-12.md
Normal file
569
docs/design-tools-cleanup-2026-05-12.md
Normal file
@@ -0,0 +1,569 @@
|
||||
# Design — Tools surface cleanup (Fristenrechner vs Verfahrensablauf split)
|
||||
|
||||
**Author:** kelvin (inventor)
|
||||
**Date:** 2026-05-12
|
||||
**Task:** t-paliad-178
|
||||
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live (before designing)
|
||||
|
||||
CLAUDE.md / memory / the task brief can all drift. Each anchor below is verified against the live codebase or DB on `mai/kelvin/inventor-tools-surface` (baseline commit `54b227c`).
|
||||
|
||||
- **One route + one TSX serve both nav entries today.** `/tools/fristenrechner` is the only registered page route (`internal/handlers/handlers.go:162`). Both sidebar entries (Fristenrechner + Verfahrensablauf) target the same Bun-built `dist/fristenrechner.html` and disambiguate purely through `?path=a` and a client-side active-class fix-up (`frontend/src/client/sidebar.ts:447 fixVerfahrensablaufActive`). Confirmed: the live HTML pulled from paliad.de (auth-gated → 302 to login, served-bytes match) is the shell rendered by `frontend/src/fristenrechner.tsx:87 renderFristenrechner`.
|
||||
- **The client runtime is 3 559 lines, not the 2 700+ quoted in the task brief.** `frontend/src/client/fristenrechner.ts` carries Step 1 / Step 2 / Step 3a / Pathway A wizard / Pathway B cascade + filter / search + cascade engines / column + timeline result-card renderers in one IIFE bundle (`Pathway` type at line 2315, `showPathway()` at line 2370, `showBMode()` at line 2406). Any "separate route" path must either lift code out of this bundle into a shared module or accept a larger duplicated bundle on the new page.
|
||||
- **Sidebar deep-link `?path=a` lands on Pathway A directly, NOT on the Akte picker.** I traced `initPathwayFork → readPathwayFromURL → showPathway("a")`: it sets `step1.style.display = "none"`, `step2.hidden = true`, `step3a.hidden = true`, `pathway-a.hidden = false`. The user sees the wizard's "Verfahrensart wählen" tile picker first. The task brief's phrasing — "still drops users at Step 1 (Akte-Picker)" — is the perceived UX from the wizard's own internal "wizard-step-1" labelled "Verfahrensart wählen". Mental model: two surfaces with the same nav label "Step 1" muddy intent; the fix m wants is structural (a dedicated route), not a JS bug fix.
|
||||
- **`paliad.projects.court` is a free-text column, NOT an FK to `paliad.courts`.** Confirmed in `information_schema.columns`. Live values: `LG München I` (1 row), `UPC` (2), `UPC CoA` (1). The task brief's "project has a court FK" is **wrong**; only `proceeding_type_id` is a real FK. The design must NOT silently auto-pick a `paliad.courts.id` from `projects.court` — fuzzy mapping is best-effort + always overridable, never silent.
|
||||
- **`paliad.projects.proceeding_type_id` points at `category='litigation'` rows (7 codes: INF, REV, CCR, APM, APP, AMD, ZPO_CIVIL).** The Fristenrechner wizard accepts `category='fristenrechner'` codes (20 codes: UPC_INF, DE_INF, EPA_OPP, …). These overlap conceptually (`INF` is the abstract noun behind both `UPC_INF` and `DE_INF`) but are different rows. Auto-derivation needs a small mapping: `litigation_code × jurisdiction → fristenrechner_code`. Example: `INF + UPC → UPC_INF`. `INF + DE → DE_INF` (first instance). The instance dimension (LG / OLG / BGH) is **not** on `paliad.projects` today, so DE_INF_OLG / DE_INF_BGH cannot be inferred — only the first-instance code can be.
|
||||
- **`paliad.projects` carries no `priority_date` or `trigger_date` column.** It does have `filing_date` and `grant_date`. Only EP_GRANT.ep_grant.publish (Art. 93 EPÜ) is anchored on `priority_date` today (via `anchor_alt`). For Akte-driven prefill, `priority_date` stays blank by default and the user fills it.
|
||||
- **`paliad.projects.our_side` and `paliad.projects.counterclaim_of` exist** (already exploited by t-paliad-164 perspective-chip predefine and the parent-counterclaim link respectively). These two columns are the actual hooks for "consolidated timeline" vs "side-by-side lanes" — see §6.
|
||||
- **`deadline_rules.condition_flag` is a real text[] column with exactly 4 distinct value-sets in production:** `[with_amend]` (4 rows), `[with_cci]` (4), `[with_ccr]` (5), `[with_ccr, with_amend]` (4). Only `UPC_INF` (proceeding_type_id=8) and `UPC_REV` (proceeding_type_id=9) carry variant-flagged rules. Every other proceeding type renders a single canonical timeline today. **This is the hard data bound on the variant-chip design** — chips beyond these three flags would have no rules to flip and must be marked "future".
|
||||
- **Court-specific rule overrides do not exist as a mechanism.** `CourtID` in `CalcOptions` (`internal/services/fristenrechner.go:107`) only switches the holiday calendar (via `courts.CountryRegime`). There is no per-court rule branch. "UPC LD Mü vs LD Düsseldorf" overrides are NOT a thing — they'd need a new column on `deadline_rules`.
|
||||
- **Expedited-vs-standard distinctions do not exist** either. No `condition_flag` row matches an expedited concept. Adding one is a schema-and-seed change, out of scope here.
|
||||
- **Result rendering today** lives in `renderTimelineBody` and `renderColumnsBody` (`frontend/src/client/fristenrechner.ts:637 / :664`). The user toggles between the two with a radio (`#fristen-view-toggle`). Both renderers take a single `DeadlineResponse` and emit DOM strings; neither knows about "two timelines side by side". A consolidated-vs-lane view (§5–§6) is a renderer-level change, not a backend one.
|
||||
- **The Step 1/Step 2/Step 3a/Pathway A/B layout shipped under t-paliad-133 + t-paliad-168.** The "Verfahrensablauf einsehen" card (Step 2 third option, lines 215-223 of fristenrechner.tsx) was added in t-paliad-168 specifically to give the abstract-browse case a discoverable entry. If Verfahrensablauf moves to its own route, the third card becomes redundant (§9).
|
||||
|
||||
If any of these conflict with what the task brief asserts, **the live state wins** and the brief is the bug — flagged in §13 for m.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision + scope
|
||||
|
||||
m's framing (verbatim from the task brief):
|
||||
|
||||
> Users want to **either** (1) determine a deadline — possibly Akte-scoped, possibly abstract — **or** (2) browse a typical Verfahrensablauf abstractly with variant options.
|
||||
|
||||
The two intents are **fundamentally different**:
|
||||
|
||||
- **Determine a deadline** ends with a save (or a print, or a manual transcription) of a *specific* date attached to *something* — a project, or a sticky-note in the user's head.
|
||||
- **Browse a Verfahrensablauf** ends with the user understanding the *shape* of a proceeding — no date binding required.
|
||||
|
||||
Today both intents collapse onto one URL because the wizard infrastructure is shared. The cost: two sidebar entries pointing at the same shell, an active-class fix-up script (`fixVerfahrensablaufActive`), and a Step 1 ("Welche Akte?") frame that doesn't match the abstract-browse intent.
|
||||
|
||||
### Scope of this design
|
||||
|
||||
1. **Page surface split** — separate routes per intent. `/tools/fristenrechner` keeps the deadline-determination intent (Akte-scoped *or* abstract). `/tools/verfahrensablauf` becomes the dedicated abstract-browse surface with variant chips + side-by-side compare.
|
||||
2. **Step 0 "Abstrakt oder Akte?"** as the FIRST affordance on `/tools/fristenrechner`. Pick → narrows downstream inputs.
|
||||
3. **Akte-driven auto-derivation** — map project columns to wizard inputs and flag the gaps.
|
||||
4. **Variant chips + consolidated-vs-lane view** for `/tools/verfahrensablauf`.
|
||||
5. **Side-by-side compare** on `/tools/verfahrensablauf` (max 2 timelines for v1).
|
||||
6. **Sidebar labels + URL conventions** post-split.
|
||||
7. **Mobile responsive** plan.
|
||||
8. **What gets dropped** (Step 2 browse card, sidebar fix-up script).
|
||||
|
||||
### Explicitly out of scope (per task brief)
|
||||
|
||||
- Deadline-rule data-model changes (court-specific overrides, expedited-flag, new condition_flag values). Audited in §0, propose nothing here.
|
||||
- t-paliad-166 Determinator B1 cascade redesign — separate ticket, on-hold. Pathway B continues to exist inside `/tools/fristenrechner`; we note interplay in §11 but do not pre-empt.
|
||||
- t-paliad-157 Fristenrechner interactive-UX pair session — on-hold. The cleanup here may inform it, but we don't dictate it.
|
||||
- Project Verlauf tab (`/projects/{id}` → Verlauf). Stays as-is. SmartTimeline renders concrete-per-case via `internal/services/projection_service.go`; no Tool-side mirror.
|
||||
- New backend services. The split runs on the existing `POST /api/tools/fristenrechner` + `POST /api/tools/event-deadlines` endpoints; we add at most one helper for Akte → fristenrechner-code mapping.
|
||||
- Backend rule changes — touch the substrate only enough to verify what the design needs is already there.
|
||||
|
||||
---
|
||||
|
||||
## 2. Page surfaces + route split
|
||||
|
||||
m has already chosen **Option A** in the task brief: split by intent, separate URLs. The design here implements that choice. For honesty I also note the alternatives I considered and why A still wins after audit.
|
||||
|
||||
### 2.1 Three options weighed
|
||||
|
||||
| Option | URL shape | Trade-off | Verdict |
|
||||
|---|---|---|---|
|
||||
| **A — Two routes** | `/tools/fristenrechner` + `/tools/verfahrensablauf` | Clean mental model. Sidebar entries map 1:1 to URLs. `fixVerfahrensablaufActive` dies. Two HTML files; shared client code lifted into a module. | **Picked.** Aligns with intent split. |
|
||||
| **B — One route, `?mode=` fork** | `/tools/fristenrechner?mode=calc` vs `?mode=browse` | Single HTML bundle, no shared-module lift. But: sidebar entries still alias the same page; muddled intent stays in the user's head; we'd still need a Step 0 inside the calc mode. | Rejected by m. Verifies on second look: it just moves `?path=a` to `?mode=browse`, doesn't fix the problem. |
|
||||
| **C — Move into Patentglossar** | Verfahrensablauf renders inline on glossary pages | Discoverability shrinks. Glossary entries are concept-bounded; Verfahrensablauf is procedure-bounded. The two indexes don't map. | Rejected by m. |
|
||||
|
||||
### 2.2 Code-reuse strategy under Option A
|
||||
|
||||
The honest cost of splitting routes is shared-client-code duplication. Today `client/fristenrechner.ts` (3 559 LoC) bundles everything. The Verfahrensablauf-only surface needs:
|
||||
|
||||
- The proceeding-type tile picker (`UPC_TYPES`, `DE_TYPES`, `EPA_TYPES`, `DPMA_TYPES` arrays in `fristenrechner.tsx`).
|
||||
- The timeline + columns result renderers (`renderTimelineBody`, `renderColumnsBody`).
|
||||
- The `POST /api/tools/fristenrechner` calc invocation.
|
||||
- Court picker + holiday-calendar pickup (read-only).
|
||||
- DE/EN i18n for the timeline rows.
|
||||
|
||||
It does NOT need:
|
||||
|
||||
- Step 1 Akte picker / ad-hoc chip / Step 1 summary.
|
||||
- Step 2 file/happened/browse cards.
|
||||
- Step 3a outgoing-intent chooser.
|
||||
- Pathway B cascade + filter + perspective + inbox chips (~1 200 LoC).
|
||||
- Save-to-Akte modal.
|
||||
- Trigger-event mode (`mode-event-panel`).
|
||||
|
||||
**Plan:** lift the deadline-timeline core (proceeding picker + calc + render) into `frontend/src/client/views/verfahrensablauf-core.ts`. Both pages import it. Pathway B + Save modal + Step machinery stay in `client/fristenrechner.ts`. Estimated lifted surface: ~700–900 LoC. New code on `verfahrensablauf.ts` (variant chips + lane mode + compare): ~400–600 LoC.
|
||||
|
||||
This keeps the IIFE per-page bundle pattern intact (one entry per route in `frontend/build.ts:228`). No runtime npm dep added.
|
||||
|
||||
### 2.3 The two pages in one sentence each
|
||||
|
||||
- **`/tools/fristenrechner`** — Deadline determination. Optional Akte scope. Ends in "save / print / done".
|
||||
- **`/tools/verfahrensablauf`** — Procedural shape browser. No Akte. Ends in "now I understand the shape".
|
||||
|
||||
### 2.4 Sidebar
|
||||
|
||||
```text
|
||||
Werkzeuge
|
||||
Fristenrechner → /tools/fristenrechner
|
||||
Verfahrensablauf → /tools/verfahrensablauf
|
||||
Kostenrechner → /tools/kostenrechner
|
||||
…
|
||||
```
|
||||
|
||||
`fixVerfahrensablaufActive` deletes; the SSR-time `navItem` helper handles both active classes natively because the hrefs differ on pathname.
|
||||
|
||||
---
|
||||
|
||||
## 3. Step 0 "Abstrakt oder Akte?" on `/tools/fristenrechner`
|
||||
|
||||
m's lock-in: Step 0 comes FIRST. Today's Step 1 (Akte picker) forces the user to either commit to an Akte or escape via ad-hoc chips before anything else moves. Step 0 makes the binary choice explicit.
|
||||
|
||||
### 3.1 Affordance — three sketches considered
|
||||
|
||||
**Sketch A — Radio toggle (Recommended).**
|
||||
A pair-of-toggle at the top of the page, wide on desktop, stacked on mobile. The currently-active half expands into its full picker; the inactive half collapses to a slim header that the user can click to flip.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Schritt 0 — Wie wollen Sie die Frist bestimmen? │
|
||||
│ │
|
||||
│ ◉ Mit Akte verknüpfen ○ Abstrakt — ohne Akte │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ │
|
||||
│ 🔍 Akte suchen… │
|
||||
│ [Akte 1 · CLI-2024 — Foo GmbH vs Bar Ltd. — UPC LD Mü] │
|
||||
│ [Akte 2 · …] │
|
||||
│ ──── │
|
||||
│ + Neue Akte anlegen │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
When the user picks "Abstrakt":
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Schritt 0 — Wie wollen Sie die Frist bestimmen? │
|
||||
│ │
|
||||
│ ○ Mit Akte verknüpfen ◉ Abstrakt — ohne Akte │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ │
|
||||
│ Verfahrensart wählen: │
|
||||
│ [UPC] [DE] [EPA] [DPMA] ← jurisdiction picker (4 tabs) │
|
||||
│ (then proceeding-type tiles within the chosen tab) │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**Why I'd recommend this:** the toggle is a single decision, declared up-front, with the consequence visible inline. No modal dismissal cost. Keyboard navigation natural. On mobile it stacks to two stacked rows where the active row expands and the inactive row stays a touch-target.
|
||||
|
||||
**Sketch B — Two big cards.** Like today's Step 2 cards but at the very top. Pro: pretty + tappable. Con: click-and-commit feels heavier than a toggle; "going back" reads as undoing a choice instead of flipping it.
|
||||
|
||||
**Sketch C — Modal-before-render.** Most decisive, also most annoying — the user can't even see the page before the dialog clears. Reject. (Modals interrupt; we want the user oriented before they're asked.)
|
||||
|
||||
### 3.2 URL state
|
||||
|
||||
Step 0 binds to `?mode=akte|abstract` in the URL.
|
||||
|
||||
- `?mode=akte&project=<uuid>` — Akte selected. Court / proceeding-type / our_side auto-derived (§4).
|
||||
- `?mode=abstract&forum=upc|de|epa|dpma` — abstract. Jurisdiction tab selected; proceeding-type tiles below.
|
||||
- `?mode=` absent — render Step 0 with no preselection.
|
||||
|
||||
Deep-link from `/projects/{id}` → "Frist berechnen" button passes `?mode=akte&project=<id>` and lands on Step 0 with Akte branch already filled.
|
||||
|
||||
`localStorage["paliad.fristen.mode"]` remembers the user's last choice for soft re-entry (the `PATHWAY_STORAGE_KEY` pattern already exists).
|
||||
|
||||
### 3.3 Removal of today's Step 2 fork (file / happened / browse)
|
||||
|
||||
With Step 0 making the intent binary, the file-vs-happened branching collapses into one wizard with two anchor sources:
|
||||
|
||||
- **Akte mode** — wizard pre-filled. After calc, the save CTA is "An Akte hängen". `?path=` machinery shrinks because Pathway A vs Pathway B becomes a wizard *step* (incoming-event vs outgoing-event), not a top-level path.
|
||||
- **Abstract mode** — wizard takes proceeding-type + date as today. After calc, save CTA disabled (no Akte to save against); `Drucken` remains.
|
||||
|
||||
The "Verfahrensablauf einsehen" card is gone from `/tools/fristenrechner` (its purpose lives on `/tools/verfahrensablauf` now — §9).
|
||||
|
||||
Pathway B (the cascade) is **kept** as a separate entry-flow inside Akte-mode for "Etwas ist passiert" — the t-paliad-166 redesign is on-hold and we don't pre-empt it. In abstract mode Pathway B is reachable via a "Frist aufgrund Ereignis (Determinator)" link in the result panel; the cascade itself unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 4. Akte-driven auto-derivation
|
||||
|
||||
When `mode=akte&project=<uuid>`, the wizard prefills as much as it honestly can from `paliad.projects`. The rest stays empty + visible.
|
||||
|
||||
### 4.1 Mapping table
|
||||
|
||||
| Wizard input | Project source | Confidence | Behaviour |
|
||||
|---|---|---|---|
|
||||
| **proceeding_type_code** (UPC_INF, DE_INF, …) | `proceeding_types.code` via `projects.proceeding_type_id` + jurisdiction disambiguation | medium-high | Best-effort pick + the proceeding-tile picker stays visible with the picked tile pre-selected. User can flip. |
|
||||
| **trigger_date** | None today | low | Always empty. User fills. |
|
||||
| **priority_date** (EP_GRANT only) | `projects.grant_date` or `projects.filing_date` (parent patent project's filing) | low-medium | Pre-fill only when the chosen proceeding is `EP_GRANT`. Field stays visible + editable. |
|
||||
| **court_id** | `projects.court` (free text) — fuzzy match against `paliad.courts.code` | low | Pre-select if string-match is exact-or-trivial-canon (e.g. `"UPC"` → `upc-cd-...`? **No** — too ambiguous; leave blank); else leave blank. Picker visible + required for UPC where holiday calendar differs. |
|
||||
| **our_side** (perspective chip) | `projects.our_side` | high | Already wired (t-paliad-164). Predefine + show "vorgegeben durch Akte" hint. |
|
||||
| **condition_flag** (with_ccr, with_cci, with_amend) | None today | low | Stays user-driven. Flag checkboxes appear conditionally on UPC_INF/UPC_REV. |
|
||||
| **counterclaim sibling info** | `projects.counterclaim_of` | medium | If set, the result panel shows a small "Verbundenes Verfahren: <parent>" line with a deep-link to the parent's Verlauf tab. Informational only — doesn't change calc. |
|
||||
|
||||
### 4.2 Litigation → fristenrechner code mapping
|
||||
|
||||
`projects.proceeding_type_id` points to `category='litigation'` rows. The wizard wants `category='fristenrechner'`. The mapping is multi-key:
|
||||
|
||||
| litigation code | jurisdiction | resolved fristenrechner code |
|
||||
|---|---|---|
|
||||
| `INF` | UPC | `UPC_INF` (id 8) |
|
||||
| `INF` | DE | `DE_INF` (id 12) — first instance only; OLG/BGH not derivable |
|
||||
| `REV` | UPC | `UPC_REV` (id 9) |
|
||||
| `REV` | DE | `DE_NULL` (id 13) |
|
||||
| `CCR` | UPC | `UPC_REV` (id 9) + `with_cci` flag suggested |
|
||||
| `APM` | UPC | `UPC_PI` (id 10) |
|
||||
| `APP` | UPC | `UPC_APP` (id 11) |
|
||||
| `AMD` | UPC | (no direct fristenrechner code; suggest UPC_INF with `with_amend`) |
|
||||
| `ZPO_CIVIL` | DE | `DE_INF` (id 12) — fallback |
|
||||
|
||||
The jurisdiction comes from `proceeding_types.jurisdiction` (UPC / DE / EPA / DPMA) on the project's own proceeding_type row, not from `projects.country` directly (which is a different axis — country of patent, not of forum).
|
||||
|
||||
Implementation: a helper `services.ResolveFristenrechnerCodeForProject(projectID)` returning `(code, confidence, reason)` so the UI can render "Vorgeschlagen: UPC_INF (aus Akte abgeleitet — Sie können umstellen)". Where confidence is `low`, no preselect — user picks.
|
||||
|
||||
### 4.3 Court free-text — no silent FK promotion
|
||||
|
||||
`projects.court` is a free-text field. Live values include `"UPC"` (ambiguous: which division?), `"UPC CoA"` (matches `upc-coa-luxembourg`), `"LG München I"` (matches `de-lg-muenchen1`). I deliberately do NOT auto-pick a `paliad.courts.id` from this string in v1: the cost of a wrong silent pick (a holiday-calendar mismatch invalidating a calculated date) is high; the benefit of saving one click is low. The Court picker stays visible and **required** for UPC proceedings (already today's behaviour via the `isCourtDeterminedRule` check in `internal/services/fristenrechner.go:779`).
|
||||
|
||||
If the free-text value matches a canonical `paliad.courts.code` exactly (case-insensitive), we *highlight* the matching option but do not auto-select. The user clicks to confirm.
|
||||
|
||||
Follow-up ticket worth filing (out of scope here): migrate `projects.court` from text to `court_id` FK. That'd land a real auto-derivation. Until then, this design treats it as a hint.
|
||||
|
||||
### 4.4 Edge case — Akte without a proceeding_type_id
|
||||
|
||||
11 of 11 live projects today have no `proceeding_type_id` set yet. Behaviour: the wizard renders with all proceeding-type tiles selectable, no preselect, no hint. Functionally identical to abstract mode but with the Akte locked for save-CTA. No error state — silent graceful degradation.
|
||||
|
||||
---
|
||||
|
||||
## 5. Variant chips on `/tools/verfahrensablauf`
|
||||
|
||||
The new dedicated route renders proceeding-shape with the user toggling "what variant am I looking at?". Variants are the live `condition_flag` mechanism.
|
||||
|
||||
### 5.1 Variants that exist today (audited live)
|
||||
|
||||
Only **UPC_INF** (id 8) and **UPC_REV** (id 9) carry `condition_flag` rules. The flags themselves:
|
||||
|
||||
- `with_ccr` — Klägerseite, infringement claim met with revocation counterclaim. Adds `inf.def_to_ccr`, `inf.reply`, `inf.reply_def_ccr`, `inf.rejoin`, `inf.rejoin_reply_ccr` (5 rules) to UPC_INF.
|
||||
- `with_cci` — Beklagtenseite on revocation answered with infringement counterclaim. Adds `rev.cc_inf`, `rev.def_cci`, `rev.reply_def_cci`, `rev.rejoin_cci` (4 rules) to UPC_REV.
|
||||
- `with_amend` — Patent amendment proposed. Adds `inf.app_to_amend`, `inf.def_to_amend`, `inf.reply_def_amd`, `inf.rejoin_amd` to UPC_INF; `rev.app_to_amend`, `rev.def_to_amend`, `rev.reply_def_amd`, `rev.rejoin_amd` to UPC_REV. Composes with `with_ccr` / `with_cci`.
|
||||
|
||||
Every other proceeding type (DE_INF, DE_NULL, EPA_OPP, EPA_APP, EP_GRANT, DPMA_*, UPC_APP, UPC_PI, UPC_DAMAGES, UPC_DISCOVERY, UPC_COST_APPEAL, UPC_APP_ORDERS) has zero `condition_flag` rules — only one canonical timeline.
|
||||
|
||||
### 5.2 Chip set per proceeding
|
||||
|
||||
Chips are conditionally rendered based on which flags exist on the selected proceeding's `condition_flag` rule rows.
|
||||
|
||||
```
|
||||
UPC_INF: [Standard] [+ Widerklage Nichtigkeit (with_ccr)] [+ Patentänderung (with_amend)]
|
||||
UPC_REV: [Standard] [+ Verletzungs-Widerklage (with_cci)] [+ Patentänderung (with_amend)]
|
||||
DE_INF, DE_NULL, EPA_OPP, …: (no chips, single timeline)
|
||||
```
|
||||
|
||||
Chips are **toggleable** (multi-select), not radio. Each chip toggles its flag on/off; the timeline reflows. Composite combinations (`with_ccr + with_amend`) render the union of rules. Toggling all chips off renders the base proceeding (no `condition_flag` rules).
|
||||
|
||||
Future flags (court-specific, expedited) — chips are **disabled and dimmed** with a tooltip "wird noch nicht unterstützt" when the proceeding has nothing to offer. We do NOT pre-render dead chips for proceedings without variants.
|
||||
|
||||
### 5.3 Consolidated vs lane view — the toggle m asked for
|
||||
|
||||
m's example: an infringement action triggers a counterclaim for revocation. Two ways to render:
|
||||
|
||||
**Consolidated** — One timeline. CCR-related events (the `with_ccr` flag) interleave with base UPC_INF events along the same vertical timeline. Colour-coded by `primary_party` (claimant / defendant / court). This is the current behaviour when `?flags=with_ccr` is set.
|
||||
|
||||
**Lane** — Two parallel columns. Column 1 = UPC_INF base timeline. Column 2 = UPC_REV timeline (the counterclaim's own proceeding). Rules anchored on shared trigger dates align horizontally.
|
||||
|
||||
Toggle UI sits beside the variant chips:
|
||||
|
||||
```
|
||||
[Standard] [+ Widerklage] | View: ◉ Konsolidiert ○ Spalten
|
||||
```
|
||||
|
||||
In v1, the lane view is only available when the user has selected a variant that implies a *second proceeding* — i.e., `UPC_INF + with_ccr` shows UPC_INF || UPC_REV side-by-side, `UPC_REV + with_cci` shows UPC_REV || UPC_INF. Same backend data, different paint.
|
||||
|
||||
For variants that DON'T imply a second proceeding (`with_amend` alone), the lane toggle is hidden — there's only one timeline.
|
||||
|
||||
### 5.4 URL state
|
||||
|
||||
`/tools/verfahrensablauf?proceeding=UPC_INF&flags=with_ccr,with_amend&view=lane&trigger_date=2026-05-12`
|
||||
|
||||
Trigger date is optional — without it, the timeline renders with relative offsets ("+3 Monate", "+6 Wochen") instead of absolute dates. This is the "browse shape" mode. With a trigger date the timeline becomes concrete.
|
||||
|
||||
`view=consolidated` (default) or `view=lane` toggles paint.
|
||||
|
||||
---
|
||||
|
||||
## 6. Side-by-side compare
|
||||
|
||||
The second variant axis. m wants to compare *two different proceeding types* OR *two variants of the same proceeding* side-by-side.
|
||||
|
||||
### 6.1 Affordance
|
||||
|
||||
A "Vergleichen" button next to the variant chips. Click → second proceeding picker slides in, second variant-chip row appears, two timelines render side-by-side.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Verfahren A: [UPC_INF ▾] Flags: [✓ with_ccr] [ with_amend]│
|
||||
│ Verfahren B: [UPC_REV ▾] Flags: [✓ with_cci] [ with_amend]│
|
||||
│ Trigger A: [2026-05-12] Trigger B: [synced ✓] │
|
||||
│ ────────────────────────────────────────────────────────────│
|
||||
│ │
|
||||
│ Timeline A ║ Timeline B │
|
||||
│ ┌─ Klageerhebung ║ ┌─ Nichtigkeitsklage │
|
||||
│ │ 2026-05-12 ║ │ 2026-05-12 │
|
||||
│ ├─ Klageerwiderung ║ ├─ Klageerwiderung │
|
||||
│ │ 2026-08-12 (3M) ║ │ 2026-08-12 (3M) │
|
||||
│ … │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 Decisions
|
||||
|
||||
- **Max 2 timelines for v1.** Three+ would push the layout below mobile readability and add picker friction. The `counterclaim_of` example always pairs two proceedings; that's the common case.
|
||||
- **Synchronised date axis** by default (Trigger A = Trigger B). Toggle "Unabhängige Trigger-Daten" reveals a second date input. Synced is the right default because the most common compare is "what happens in both proceedings starting from the same Klageerhebung date".
|
||||
- **Independent variant chips per timeline.** Variant A's flags don't affect Variant B. The chips render per-column.
|
||||
- **Wide-screen primary.** Lane and compare views require ≥720px to be readable. Below that, stack vertically (Timeline A above Timeline B, full-width each). The synced-trigger constraint stays; users on small screens still get the compare, just stacked.
|
||||
- **Permalink-shareable.** `?compare=1&a_proceeding=UPC_INF&a_flags=with_ccr&b_proceeding=UPC_REV&b_flags=with_cci&trigger=2026-05-12&synced=true` — every chip + variant + trigger captured in URL. Copy-paste produces an identical render.
|
||||
|
||||
### 6.3 Lane view vs Compare view — are they the same thing?
|
||||
|
||||
Conceptually similar (two columns), but UX-distinct:
|
||||
|
||||
- **Lane view** is "one variant that implies two proceedings rendered together". The two columns are *logically linked* (e.g., `UPC_INF + with_ccr` always shows the same UPC_REV alongside).
|
||||
- **Compare view** is "the user picked two arbitrary proceedings + variants to look at together". The two columns are *independently chosen*.
|
||||
|
||||
In renderer terms they share the same DOM layout (CSS grid with 2 columns). The state differs: lane view's second proceeding is computed from the variant flag; compare view's second proceeding is user-picked. We implement them as one renderer with two state-entry points.
|
||||
|
||||
---
|
||||
|
||||
## 7. Sidebar nav labels + URL conventions
|
||||
|
||||
### 7.1 Labels (post-cleanup)
|
||||
|
||||
Today: **Fristenrechner** + **Verfahrensablauf**.
|
||||
|
||||
Recommendation: keep the labels as-is. m's brief suggested alternatives ("Frist berechnen" / "Verfahrensabläufe") — I think the current labels are tighter:
|
||||
|
||||
- "Fristenrechner" is a known brand-term in the firm vocabulary (per the German-tool-names-as-brands convention in CLAUDE.md).
|
||||
- "Verfahrensablauf" reads as a noun "the procedural flow", which matches the abstract-browse intent better than the plural "Verfahrensabläufe" (which reads as "the catalogue of all flows").
|
||||
|
||||
But I flag this for m in §13 — the call is brand-strategic, not technical.
|
||||
|
||||
### 7.2 URL conventions
|
||||
|
||||
| Route | Key params | Purpose |
|
||||
|---|---|---|
|
||||
| `/tools/fristenrechner` | `mode=akte\|abstract` | Pick branch |
|
||||
| `/tools/fristenrechner?mode=akte&project=<uuid>` | + `path=outgoing\|happened` | Akte deadline determination |
|
||||
| `/tools/fristenrechner?mode=abstract&forum=upc&proceeding=UPC_INF&trigger_date=…` | + `flags=…` | Abstract deadline determination |
|
||||
| `/tools/verfahrensablauf` | `proceeding=…&flags=…&view=…&trigger_date=…` | Browse one proceeding-shape |
|
||||
| `/tools/verfahrensablauf?compare=1&a_proceeding=…&b_proceeding=…&…` | (per §6.2) | Compare two |
|
||||
|
||||
The `?path=a` query param dies entirely. The `fixVerfahrensablaufActive` function deletes. The localStorage key `paliad.fristen.pathway` is preserved (still used by Akte-mode Pathway A/B inside `/tools/fristenrechner`); it gets a sibling `paliad.fristen.mode`.
|
||||
|
||||
### 7.3 Bookmarkability + share
|
||||
|
||||
Both pages produce permalinks. Copy URL → paste in another browser → identical view (with same auth gate). The compare-view URL is particularly load-bearing for the "send your colleague a precomputed timeline" use case — it's how a PA quickly shows a counterpart "this is the shape we're looking at".
|
||||
|
||||
---
|
||||
|
||||
## 8. Mobile + responsive
|
||||
|
||||
Existing breakpoints in the codebase: 640px / 720px / 768px / 1023px (`frontend/src/styles/global.css`).
|
||||
|
||||
### 8.1 `/tools/fristenrechner`
|
||||
|
||||
- **≥720px:** Step 0 toggle horizontal. Akte search results in a list.
|
||||
- **<720px:** Step 0 toggle stacks (radio rows top-to-bottom). Akte list full-width.
|
||||
- **<480px:** Proceeding-tile picker (UPC / DE / EPA / DPMA tabs + tiles) wraps tiles to one column.
|
||||
|
||||
### 8.2 `/tools/verfahrensablauf`
|
||||
|
||||
- **≥1023px:** Lane view + compare view render side-by-side (CSS grid 2-col).
|
||||
- **720–1022px:** Lane view side-by-side; compare view stacks (Timeline A above Timeline B, full-width).
|
||||
- **<720px:** Both lane and compare stack vertically. Variant chips wrap to 2-3 rows.
|
||||
- **<480px:** Single-column always. Compare-view "Vergleichen" button still works but stacks the result rows.
|
||||
|
||||
### 8.3 Variant chips on mobile
|
||||
|
||||
Chips wrap with `flex-wrap`. Maximum 3 chips per row on a 360px viewport (each chip ≤ 110px wide); composite proceedings (UPC_INF, UPC_REV) fit 3 chips so this works.
|
||||
|
||||
### 8.4 What does NOT collapse on mobile
|
||||
|
||||
- The trigger-date input. Stays a single date picker (browser-native; iOS / Android already render their own UI).
|
||||
- The proceeding picker. Stays tiled (large tap targets).
|
||||
- The result rows (column + timeline views). Render unchanged from today; mobile already handles them.
|
||||
|
||||
---
|
||||
|
||||
## 9. What gets dropped
|
||||
|
||||
| Today | Post-cleanup |
|
||||
|---|---|
|
||||
| **Step 2 "Verfahrensablauf einsehen" card** | Deleted. The abstract-browse case has its own route. |
|
||||
| **Sidebar `?path=a` deep-link** | Deleted. `/tools/verfahrensablauf` replaces it. |
|
||||
| **`fixVerfahrensablaufActive()` function** | Deleted. Both sidebar entries map 1:1 to URLs; native SSR active-class works. |
|
||||
| **`localStorage["paliad.fristen.pathway"]`** | Preserved as-is. Still used inside Akte-mode Pathway A/B. |
|
||||
| **The Step 1/Step 2 fork on `/tools/fristenrechner`** | Replaced by Step 0 (Akte vs Abstract). Step 2's "file vs happened vs browse" becomes a wizard-internal branch, not a top-level page state. |
|
||||
| **Step 3a "outgoing-intent chooser" (File / Draft / Enter)** | Kept inside Akte-mode. The Draft option (`fristen-step3a-draft`) stays disabled as today (placeholder). |
|
||||
|
||||
The deletions sum to maybe 200–300 LoC out of `client/fristenrechner.ts`. The lift of `verfahrensablauf-core.ts` is the bigger reshape; net LoC churn around +500 / -300.
|
||||
|
||||
---
|
||||
|
||||
## 10. Slicing for the coder pass
|
||||
|
||||
Four slices, each independently mergeable. Slice 1 ships the structural split; Slices 2–4 layer features.
|
||||
|
||||
### Slice 1 — Route + shell split (foundation)
|
||||
|
||||
- New route `/tools/verfahrensablauf` registered in `internal/handlers/handlers.go`.
|
||||
- New handler `handleVerfahrensablaufPage` serves `dist/verfahrensablauf.html`.
|
||||
- New TSX `frontend/src/verfahrensablauf.tsx` — renders the proceeding-tile picker + result panel. No variant chips yet; no compare yet. Just the abstract-browse case factored out.
|
||||
- New client `frontend/src/client/verfahrensablauf.ts` — minimal: picker → calc → render. Imports from a new shared module `client/views/verfahrensablauf-core.ts`.
|
||||
- Sidebar `Sidebar.tsx:163-164` updated: second nav entry's href flips from `/tools/fristenrechner?path=a` to `/tools/verfahrensablauf`.
|
||||
- `client/sidebar.ts:447 fixVerfahrensablaufActive` deleted (and its call site at the bottom of `initSidebar`).
|
||||
- Step 2 "Verfahrensablauf einsehen" card markup in `frontend/src/fristenrechner.tsx` + its handler in `client/fristenrechner.ts` deleted.
|
||||
- Step 2's "browse" event handler at `fristen-step2-browse` removed; the path="a" branch in `showPathway` still exists for Akte-mode wizard re-use.
|
||||
- DE/EN i18n keys: `tools.verfahrensablauf.title`, `tools.verfahrensablauf.subtitle`, plus all the proceeding-tile labels (already exist — reused).
|
||||
- Build: add `renderVerfahrensablauf` import and `bun:write` step in `frontend/build.ts`.
|
||||
- Tests: Playwright smoke — `/tools/verfahrensablauf` renders, sidebar nav links work, no 404s, the old `?path=a` URL 302s to `/tools/verfahrensablauf` (back-compat for any bookmarked links).
|
||||
|
||||
**What does NOT change in Slice 1:** the existing `/tools/fristenrechner` page works exactly as today (Step 1 / Step 2 / Step 3a / Pathway A / Pathway B). Step 0 is Slice 2.
|
||||
|
||||
### Slice 2 — Step 0 on `/tools/fristenrechner`
|
||||
|
||||
- New Step 0 toggle component in `fristenrechner.tsx` (above today's Step 1).
|
||||
- `?mode=akte|abstract` URL param + `paliad.fristen.mode` localStorage hook.
|
||||
- "Abstract" branch reveals a new compact proceeding-tile picker inside the Step 0 frame (or scrolls to today's wizard-step-1).
|
||||
- "Akte" branch renders today's Step 1 (Akte search + ad-hoc chips).
|
||||
- Akte-driven auto-derivation (§4): a new service `ResolveFristenrechnerCodeForProject(projectID)` and frontend hook that preselects the proceeding tile + `our_side` chip + Court hint (highlight only, not pre-select).
|
||||
- Tests: Playwright smoke for the four state transitions (akte → abstract, abstract → akte, akte+project → akte-no-project, deep-link `?mode=abstract&forum=upc`).
|
||||
|
||||
### Slice 3 — Variant chips + consolidated/lane view
|
||||
|
||||
- Variant-chip strip on `/tools/verfahrensablauf` (`with_ccr`, `with_cci`, `with_amend` conditional on proceeding).
|
||||
- `?flags=` URL param.
|
||||
- Lane-vs-consolidated toggle. Lane view auto-enables when the variant implies a second proceeding (UPC_INF+with_ccr → UPC_REV; UPC_REV+with_cci → UPC_INF).
|
||||
- Lane renderer in `views/verfahrensablauf-core.ts` (CSS grid 2-col, shared trigger-date axis).
|
||||
- Tests: Playwright smoke for variant toggles + lane render + lane on mobile (stack).
|
||||
|
||||
### Slice 4 — Side-by-side compare
|
||||
|
||||
- "Vergleichen" button + second-proceeding picker.
|
||||
- `?compare=1&a_proceeding=…&b_proceeding=…&…` URL state.
|
||||
- Synced-trigger toggle; independent-trigger fallback.
|
||||
- Permalink test (copy URL → fresh tab → same render).
|
||||
- Mobile fallback (stacked).
|
||||
- Tests: Playwright smoke for compare entry, both timelines render, permalink roundtrip.
|
||||
|
||||
Each slice merges to main independently. Slice 1 is the bottleneck; once it's in, Slices 2–4 can ship in any order (Slice 2 only touches `/tools/fristenrechner`, Slices 3+4 only touch `/tools/verfahrensablauf`).
|
||||
|
||||
---
|
||||
|
||||
## 11. Tradeoffs flagged
|
||||
|
||||
### 11.1 Code duplication vs route clarity
|
||||
|
||||
The split forces ~700–900 LoC of client code into a shared module (`views/verfahrensablauf-core.ts`). That's lift work without user-visible benefit. The alternative (one big page with `?mode=`) saves the lift but keeps the muddled mental model that triggered this redesign in the first place. **Decision: pay the lift cost.** It's a one-time refactor; the navigation clarity is durable.
|
||||
|
||||
### 11.2 Step 0 vs Step 1 — perceived "extra step"
|
||||
|
||||
Today's flow: Akte picker (Step 1) → choose-intent cards (Step 2) → wizard. Tomorrow's flow: mode toggle (Step 0) → Akte picker OR abstract picker → wizard. Same number of clicks for the Akte case. One *fewer* click for the abstract case (you go straight to proceeding tiles instead of clicking "Verfahrensablauf einsehen" first). Net win.
|
||||
|
||||
### 11.3 Court free-text means imperfect auto-derivation
|
||||
|
||||
We can't reliably auto-pick `court_id` from `projects.court` until that column becomes an FK. The design leans on "highlight matching options" rather than silent preselect. The cost is one extra click. **File a follow-up ticket** to migrate `projects.court` → `court_id` FK; until then, no silent FK promotion.
|
||||
|
||||
### 11.4 Pathway B (Determinator cascade) stays inside Akte-mode
|
||||
|
||||
t-paliad-166 will redesign Pathway B as a row-by-row cascade. We don't pre-empt that. Pathway B remains reachable from Akte-mode's "Etwas ist passiert" card. In Abstract mode it's reachable through a "Frist aufgrund Ereignis" link in the result panel. Both paths stay; only the entry surface changes.
|
||||
|
||||
### 11.5 Variant chips disabled for non-UPC proceedings
|
||||
|
||||
Only UPC_INF and UPC_REV have `condition_flag` rules today. DE_INF, DE_NULL, EPA_OPP, etc. show no chips. This is honest — the data isn't there. If users ask for German "with/without counterclaim" variants, that's a `condition_flag` seed-data ticket, not a UX redesign.
|
||||
|
||||
### 11.6 Lane view assumes the second proceeding exists
|
||||
|
||||
`UPC_INF + with_ccr` lanes to `UPC_REV`. But `UPC_REV` itself is a full proceeding with its own deadlines anchored on a *separate* trigger date (the CCR filing date, not the SoC date). For v1 we render the second lane with the *same trigger date* as the primary — which is wrong-but-useful: the user sees the *shape* of the counterclaim's flow but the dates are nominal. A future iteration adds a "second trigger date" input for the lane. **Document this in the UI** with a small caveat: "Annahme: Widerklage zur gleichen Zeit eingelegt".
|
||||
|
||||
### 11.7 No state preserved across the route boundary
|
||||
|
||||
If a user is mid-calc on `/tools/fristenrechner` and clicks the sidebar's `/tools/verfahrensablauf`, their wizard state is lost. We don't try to bridge the two — they're different intents. The URL captures everything important; the user can pop back via the browser back button.
|
||||
|
||||
### 11.8 Print mode is the only export
|
||||
|
||||
No PDF, no SVG, no CSV export in this design. The existing `#fristen-print-btn` + `@media print` stylesheet handles it. m's broader chart-export design (`docs/design-project-chart-2026-05-09.md`) covers the export ambition for the project-level chart; this Tool-level surface keeps it simple.
|
||||
|
||||
---
|
||||
|
||||
## 12. Files implementer will touch (Slice 1 only)
|
||||
|
||||
This is the bottleneck slice. Slices 2–4 each add their own scope but Slice 1 defines the structural change.
|
||||
|
||||
**Backend (Go):**
|
||||
|
||||
- `internal/handlers/handlers.go:162` — add `protected.HandleFunc("GET /tools/verfahrensablauf", handleVerfahrensablaufPage)`.
|
||||
- `internal/handlers/fristenrechner.go` — add `handleVerfahrensablaufPage` (1-liner, serves `dist/verfahrensablauf.html`). Or split into its own file `internal/handlers/verfahrensablauf.go` for tidiness.
|
||||
- `internal/handlers/handlers.go` — add back-compat 302: `/tools/fristenrechner?path=a` → `/tools/verfahrensablauf` (preserves bookmarked links). A small middleware or an `init` redirect handler suffices.
|
||||
|
||||
**Frontend (TSX + TS):**
|
||||
|
||||
- `frontend/src/verfahrensablauf.tsx` — new file. ~250 LoC. Renders header + jurisdiction-tab picker + proceeding-tile picker + result panel container. No variant chips, no compare yet (those are Slices 3+4). Reuses `<PWAHead>`, `<Sidebar>`, `<Footer>`.
|
||||
- `frontend/src/client/verfahrensablauf.ts` — new file. ~150 LoC for Slice 1. Wires the picker → POST `/api/tools/fristenrechner` → render via shared module.
|
||||
- `frontend/src/client/views/verfahrensablauf-core.ts` — new file. The lifted code: `renderTimelineBody`, `renderColumnsBody`, the `calculateDeadlines` fetch wrapper, court picker, view-toggle. Imported by both `client/fristenrechner.ts` and `client/verfahrensablauf.ts`.
|
||||
- `frontend/src/client/fristenrechner.ts` — delete the Step 2 "browse" card handler (lines 2715-2717 today). Remove the `?path=a` interpretation as a top-level entry (still keep `path="a"` as an Akte-mode wizard pathway). Import calc + render from `views/verfahrensablauf-core.ts`.
|
||||
- `frontend/src/fristenrechner.tsx` — delete the `fristen-step2-browse` card markup (lines 215-223 today).
|
||||
- `frontend/src/components/Sidebar.tsx:163-164` — change href from `/tools/fristenrechner?path=a` to `/tools/verfahrensablauf`. Adjust the `currentPath` comparison to match the new pathname.
|
||||
- `frontend/src/client/sidebar.ts:447 fixVerfahrensablaufActive` — delete the function + its call site.
|
||||
|
||||
**Build:**
|
||||
|
||||
- `frontend/build.ts` — add `renderVerfahrensablauf` import (line 5-6 area), add `client/verfahrensablauf.ts` to `entrypoints` array (line 228 area), add the `Bun.write(join(DIST, "verfahrensablauf.html"), renderVerfahrensablauf())` step (line 355 area).
|
||||
|
||||
**i18n:**
|
||||
|
||||
- `frontend/src/client/i18n.ts` + `i18n-keys.ts` — add `tools.verfahrensablauf.title`, `tools.verfahrensablauf.subtitle`, `nav.verfahrensablauf` (already exists; re-verify the key still points at the right label).
|
||||
|
||||
**Tests:**
|
||||
|
||||
- Playwright smoke covering: `/tools/verfahrensablauf` renders, sidebar nav link active class lights up correctly without `fixVerfahrensablaufActive`, `/tools/fristenrechner?path=a` 302s, the calc roundtrip works on both routes, build artefacts emit both `fristenrechner.html` and `verfahrensablauf.html`.
|
||||
|
||||
**Out of Slice 1 (deferred to Slices 2-4):**
|
||||
|
||||
- Step 0 toggle on `/tools/fristenrechner` (Slice 2).
|
||||
- Akte-driven auto-derivation helper service (Slice 2).
|
||||
- Variant chips, lane view (Slice 3).
|
||||
- Compare view (Slice 4).
|
||||
|
||||
---
|
||||
|
||||
## 13. Open questions for m
|
||||
|
||||
1. **Sidebar label.** Keep "Verfahrensablauf" (current) or switch to "Verfahrensabläufe" (plural — reads as catalogue) or something else? Current label is unambiguous; plural risks reading as a list page.
|
||||
|
||||
2. **Akte-mode mapping with no `proceeding_type_id`.** 11/11 live projects have NULL proceeding_type_id. Akte-mode silently degrades to "pick proceeding manually". OK? Or should Akte-mode require a proceeding_type_id and force the user to set it on the project first?
|
||||
|
||||
3. **Court free-text → FK migration.** I'm flagging this as a follow-up but not designing it here. Want me to file a separate ticket so it's tracked, or fold it into Slice 2's scope?
|
||||
|
||||
4. **Lane view caveat for v1.** The second lane uses the same trigger date as the primary (so dates are nominal-but-wrong for a real-world CCR filed weeks later). UI caveat "Annahme: Widerklage zur gleichen Zeit eingelegt" is honest but adds clutter. Acceptable or do we hold lane view back until trigger-2 input lands?
|
||||
|
||||
5. **Compare view max columns.** v1 caps at 2. Three+ would be a richer compare ("UPC_INF vs DE_INF vs EPA_OPP for the same patent") but layout-hostile on anything <1280px. Confirm 2 for v1?
|
||||
|
||||
6. **Back-compat for `?path=a`.** I propose a 302 redirect so old bookmarked URLs work. Alternative: 410 Gone (harsh) or 200-with-deprecation-banner (chatty). 302 is the conventional move; confirm?
|
||||
|
||||
7. **Drop the "Verfahrensablauf einsehen" card from Step 2 entirely** vs keep it as a deep-link shortcut to `/tools/verfahrensablauf` from inside the Fristenrechner flow? I'm proposing drop; m signals?
|
||||
|
||||
8. **DE_INF / EPA_OPP / DPMA variants.** Today no `condition_flag` rules. Future seed-data tickets (out of scope here): with/without expedited, with/without amendment for EPA opposition, etc. Want a follow-up ticket filed for the seed-data work or wait for user feedback?
|
||||
|
||||
9. **Pathway B (Determinator) entry point in Abstract mode.** I propose a small "Frist aufgrund Ereignis" link in the result panel. Or hide it entirely from abstract mode? Today Pathway B is reachable from anywhere via `?path=b`.
|
||||
|
||||
10. **Implementer choice.** I'd recommend a coder familiar with `frontend/src/client/fristenrechner.ts` for Slice 1 since the bundle split is the load-bearing risk. Curie (t-paliad-086), cronus (t-paliad-088, t-paliad-110), noether (t-paliad-165) have all touched the file. Head decides.
|
||||
|
||||
---
|
||||
|
||||
**DESIGN READY FOR REVIEW**
|
||||
|
||||
Slice 1 is the structural foundation (route split, sidebar cleanup, code lift). Slices 2-4 layer Step 0 / variant chips / compare on top. Awaiting m's go/no-go before coder shift.
|
||||
@@ -4,6 +4,7 @@ import { renderIndex } from "./src/index";
|
||||
import { renderLogin } from "./src/login";
|
||||
import { renderKostenrechner } from "./src/kostenrechner";
|
||||
import { renderFristenrechner } from "./src/fristenrechner";
|
||||
import { renderVerfahrensablauf } from "./src/verfahrensablauf";
|
||||
import { renderDownloads } from "./src/downloads";
|
||||
import { renderLinks } from "./src/links";
|
||||
import { renderGlossary } from "./src/glossary";
|
||||
@@ -15,6 +16,7 @@ import { renderCourts } from "./src/courts";
|
||||
import { renderProjects } from "./src/projects";
|
||||
import { renderProjectsNew } from "./src/projects-new";
|
||||
import { renderProjectsDetail } from "./src/projects-detail";
|
||||
import { renderProjectsChart } from "./src/projects-chart";
|
||||
import { renderEvents } from "./src/events";
|
||||
import { renderDeadlinesNew } from "./src/deadlines-new";
|
||||
import { renderDeadlinesDetail } from "./src/deadlines-detail";
|
||||
@@ -234,6 +236,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/login.ts"),
|
||||
join(import.meta.dir, "src/client/kostenrechner.ts"),
|
||||
join(import.meta.dir, "src/client/fristenrechner.ts"),
|
||||
join(import.meta.dir, "src/client/verfahrensablauf.ts"),
|
||||
join(import.meta.dir, "src/client/downloads.ts"),
|
||||
join(import.meta.dir, "src/client/links.ts"),
|
||||
join(import.meta.dir, "src/client/glossary.ts"),
|
||||
@@ -245,6 +248,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/projects.ts"),
|
||||
join(import.meta.dir, "src/client/projects-new.ts"),
|
||||
join(import.meta.dir, "src/client/projects-detail.ts"),
|
||||
join(import.meta.dir, "src/client/projects-chart.ts"),
|
||||
join(import.meta.dir, "src/client/events.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-new.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-detail.ts"),
|
||||
@@ -354,6 +358,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "login.html"), renderLogin("login.js"));
|
||||
await Bun.write(join(DIST, "kostenrechner.html"), renderKostenrechner());
|
||||
await Bun.write(join(DIST, "fristenrechner.html"), renderFristenrechner());
|
||||
await Bun.write(join(DIST, "verfahrensablauf.html"), renderVerfahrensablauf());
|
||||
await Bun.write(join(DIST, "downloads.html"), renderDownloads());
|
||||
await Bun.write(join(DIST, "links.html"), renderLinks());
|
||||
await Bun.write(join(DIST, "glossary.html"), renderGlossary());
|
||||
@@ -365,6 +370,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "projects.html"), renderProjects());
|
||||
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
|
||||
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
|
||||
await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart());
|
||||
// t-paliad-115 — shared EventsPage at the canonical /events URL.
|
||||
// One HTML output; defaultType="all" baked in. Sidebar Fristen /
|
||||
// Termine entries point at /events?type=… and events.ts re-highlights
|
||||
|
||||
@@ -71,7 +71,10 @@ export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
|
||||
try {
|
||||
let result: ViewRunResult;
|
||||
if (opts.customRunner) {
|
||||
result = await opts.customRunner(effective);
|
||||
// Hand the runner a frozen snapshot of the bar state so it can
|
||||
// read axes the EffectiveSpec doesn't round-trip (SmartTimeline
|
||||
// timeline_status / timeline_track on the Verlauf surface).
|
||||
result = await opts.customRunner(effective, Object.freeze({ ...state }));
|
||||
} else {
|
||||
const slug = opts.systemViewSlug as string; // ctor guard guarantees this
|
||||
const r = await fetch(`/api/views/${encodeURIComponent(slug)}/run`, {
|
||||
@@ -202,6 +205,11 @@ export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
|
||||
if (lastEffective) return lastEffective;
|
||||
return computeEffective(opts.baseFilter, opts.baseRender, state);
|
||||
},
|
||||
getState() {
|
||||
// Hand back a frozen snapshot so callers can't smuggle mutations
|
||||
// back into the bar's owned state — the bar is the single writer.
|
||||
return Object.freeze({ ...state });
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
toolbar.remove();
|
||||
|
||||
@@ -112,12 +112,14 @@ export interface MountOpts {
|
||||
systemViewSlug?: string;
|
||||
|
||||
// Custom runner. When set, the bar bypasses the substrate POST and
|
||||
// hands the effective spec to this function instead. Used by surfaces
|
||||
// that haven't migrated to the substrate yet (Verlauf tab still hits
|
||||
// /api/projects/{id}/events to keep subtree expansion + cursor
|
||||
// pagination, t-paliad-170). Must be either this OR systemViewSlug —
|
||||
// the bar throws if both / neither are provided.
|
||||
customRunner?: (effective: EffectiveSpec) => Promise<ViewRunResult>;
|
||||
// hands the effective spec + raw BarState to this function instead.
|
||||
// Used by surfaces that need axes the EffectiveSpec doesn't round-trip
|
||||
// (e.g. SmartTimeline's timeline_status / timeline_track, t-paliad-176).
|
||||
// The state argument is a frozen snapshot — same shape getState()
|
||||
// returns on the handle, but available on the very first run before
|
||||
// the handle has been assigned. Must be either this OR systemViewSlug
|
||||
// — the bar throws if both / neither are provided.
|
||||
customRunner?: (effective: EffectiveSpec, state: Readonly<BarState>) => Promise<ViewRunResult>;
|
||||
|
||||
// Per-surface override of the time-axis chip presets. Order is
|
||||
// preserved. Default presets are forward-looking (next_*+past_30d+any)
|
||||
@@ -150,4 +152,10 @@ export interface BarHandle {
|
||||
// Read-only effective spec at this moment (post URL + localStorage
|
||||
// overlay). Pages use this to construct deep-link URLs etc.
|
||||
getEffective(): EffectiveSpec;
|
||||
// Read-only raw BarState. Surfaces with axes the EffectiveSpec doesn't
|
||||
// round-trip (timeline_status / timeline_track on the SmartTimeline
|
||||
// surface — the substrate FilterSpec has no per-source predicate for
|
||||
// those) read state directly to drive client-side filtering. Returns
|
||||
// a frozen snapshot; callers must not mutate.
|
||||
getState(): Readonly<BarState>;
|
||||
}
|
||||
|
||||
@@ -1,67 +1,27 @@
|
||||
// Fristenrechner client-side logic
|
||||
// 3-step wizard: select proceeding -> enter date -> view timeline
|
||||
//
|
||||
// Rendering primitives (renderTimelineBody / renderColumnsBody /
|
||||
// deadlineCardHtml / formatDate / partyBadge / court picker) live in
|
||||
// `./views/verfahrensablauf-core` and are shared with the
|
||||
// /tools/verfahrensablauf page (t-paliad-179 Slice 1). This module owns
|
||||
// the Step1/2/3a wizard, Pathway A/B, Akte save flow, anchor-override
|
||||
// click-to-edit — none of which Verfahrensablauf wants.
|
||||
|
||||
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
interface AdjustmentHoliday {
|
||||
Date: string;
|
||||
Name: string;
|
||||
IsVacation: boolean;
|
||||
IsClosure: boolean;
|
||||
}
|
||||
|
||||
interface AdjustmentReason {
|
||||
kind: "weekend" | "public_holiday" | "vacation";
|
||||
holidays?: AdjustmentHoliday[];
|
||||
vacation_name?: string;
|
||||
vacation_start?: string;
|
||||
vacation_end?: string;
|
||||
original_weekday?: string;
|
||||
}
|
||||
|
||||
interface CalculatedDeadline {
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
party: string;
|
||||
isMandatory: boolean;
|
||||
ruleRef: string;
|
||||
legalSource?: string;
|
||||
notes?: string;
|
||||
notesEN?: string;
|
||||
dueDate: string;
|
||||
originalDate: string;
|
||||
wasAdjusted: boolean;
|
||||
adjustmentReason?: AdjustmentReason;
|
||||
isRootEvent: boolean;
|
||||
isCourtSet: boolean;
|
||||
// True when isCourtSet is "unbestimmt" — the rule chains off a
|
||||
// court-determined parent (e.g. RoP.151 = 1 Monat ab
|
||||
// Hauptentscheidung) rather than being itself court-set. The UI
|
||||
// renders "unbestimmt" instead of "wird vom Gericht bestimmt".
|
||||
isCourtSetIndirect?: boolean;
|
||||
// True when the deadline is conditional on a user act (filing a
|
||||
// cost-decision request, choosing to appeal, etc.). Pre-unchecked
|
||||
// in the save modal so the user must opt in.
|
||||
isOptional?: boolean;
|
||||
isOverridden?: boolean;
|
||||
}
|
||||
|
||||
interface DeadlineResponse {
|
||||
proceedingType: string;
|
||||
proceedingName: string;
|
||||
triggerDate: string;
|
||||
deadlines: CalculatedDeadline[];
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
claimant: "party-claimant",
|
||||
defendant: "party-defendant",
|
||||
court: "party-court",
|
||||
both: "party-both",
|
||||
};
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
type DeadlineResponse,
|
||||
calculateDeadlines,
|
||||
escAttr,
|
||||
escHtml,
|
||||
formatDate,
|
||||
populateCourtPicker as populateCourtPickerCore,
|
||||
renderColumnsBody,
|
||||
renderTimelineBody,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
@@ -106,92 +66,29 @@ onLangChange(() => {
|
||||
}
|
||||
});
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return "\u2014";
|
||||
const d = new Date(dateStr + "T00:00:00");
|
||||
if (getLang() === "en") {
|
||||
// ISO date (YYYY-MM-DD) \u2014 unambiguous for both US and intl readers, since
|
||||
// en-GB renders dd/mm/yyyy which US users misread as mm/dd/yyyy.
|
||||
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${weekday}, ${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
return d.toLocaleDateString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
// formatDate / partyBadge / formatDateSpan / localizeVacationName /
|
||||
// localizeWeekday / renderAdjustmentReason / formatAdjustedNote moved to
|
||||
// ./views/verfahrensablauf-core so /tools/verfahrensablauf can share them.
|
||||
// (t-paliad-179 Slice 1)
|
||||
|
||||
function partyBadge(party: string): string {
|
||||
const cls = PARTY_CLASS[party] || "party-both";
|
||||
return `<span class="party-badge ${cls}">${tDyn("deadlines.party." + party)}</span>`;
|
||||
}
|
||||
|
||||
// Short date span like "27.7.–28.8." (DE) or "27 Jul – 28 Aug" (EN). Used in
|
||||
// the vacation adjustment label, where the explicit weekday + year would
|
||||
// just be noise — the surrounding sentence carries the full year via the
|
||||
// dueDate / originalDate that the note brackets.
|
||||
function formatDateSpan(startISO: string, endISO: string): string {
|
||||
const start = new Date(startISO + "T00:00:00");
|
||||
const end = new Date(endISO + "T00:00:00");
|
||||
if (getLang() === "en") {
|
||||
const fmt = (d: Date) => d.toLocaleDateString("en-US", { day: "numeric", month: "short" });
|
||||
return `${fmt(start)} – ${fmt(end)}`;
|
||||
}
|
||||
const fmt = (d: Date) => `${d.getDate()}.${d.getMonth() + 1}.`;
|
||||
return `${fmt(start)}–${fmt(end)}`;
|
||||
}
|
||||
|
||||
// Vacation names come straight from paliad.holidays (e.g. "UPC judicial
|
||||
// vacation"). The Fristenrechner doesn't translate them: they're proper
|
||||
// names of court-set closures, not generic strings, and rotating them via
|
||||
// i18n.ts duplicates state that should live in the DB. Rename in the seed
|
||||
// if the wording needs to change.
|
||||
function localizeVacationName(name: string): string {
|
||||
return name;
|
||||
}
|
||||
|
||||
function localizeWeekday(en: string): string {
|
||||
if (en === "Saturday") return t("deadlines.adjusted.weekend.saturday");
|
||||
if (en === "Sunday") return t("deadlines.adjusted.weekend.sunday");
|
||||
return en;
|
||||
}
|
||||
|
||||
// Backend-shaped reason → human-readable phrase ("UPC-Gerichtsferien
|
||||
// (27.7.–28.8.)" / "Karfreitag holiday" / "Wochenende"). See t-paliad-119.
|
||||
function renderAdjustmentReason(r: AdjustmentReason): string {
|
||||
if (r.kind === "vacation" && r.vacation_name && r.vacation_start && r.vacation_end) {
|
||||
const span = formatDateSpan(r.vacation_start, r.vacation_end);
|
||||
return tDyn("deadlines.adjusted.vacation")
|
||||
.replace("{name}", localizeVacationName(r.vacation_name))
|
||||
.replace("{span}", span);
|
||||
}
|
||||
if (r.kind === "public_holiday" && r.holidays && r.holidays.length > 0) {
|
||||
return tDyn("deadlines.adjusted.holiday").replace("{name}", r.holidays[0].Name);
|
||||
}
|
||||
if (r.kind === "weekend" && r.original_weekday) {
|
||||
return localizeWeekday(r.original_weekday);
|
||||
}
|
||||
return t("deadlines.adjusted.weekend");
|
||||
}
|
||||
|
||||
// "Verschoben wegen X: A → B" (DE) / "Shifted (X): A → B" (EN). Falls back
|
||||
// to the legacy "Wochenende/Feiertag" string when the backend hasn't sent a
|
||||
// structured reason — keeps older API responses readable.
|
||||
function formatAdjustedNote(dl: CalculatedDeadline): string {
|
||||
const arrow = `${formatDate(dl.originalDate)} → ${formatDate(dl.dueDate)}`;
|
||||
const reason = dl.adjustmentReason
|
||||
? renderAdjustmentReason(dl.adjustmentReason)
|
||||
: t("deadlines.adjusted.reason");
|
||||
if (getLang() === "en") {
|
||||
return `${t("deadlines.adjusted")} (${reason}): ${arrow}`;
|
||||
}
|
||||
return `${t("deadlines.adjusted")} wegen ${reason}: ${arrow}`;
|
||||
}
|
||||
|
||||
let selectedType = "";
|
||||
|
||||
@@ -247,35 +144,19 @@ async function calculate() {
|
||||
? courtPicker.value
|
||||
: "";
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/tools/fristenrechner", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
priorityDate: priorityDate || undefined,
|
||||
flags: flags.length > 0 ? flags : undefined,
|
||||
anchorOverrides: Object.keys(overrides).length > 0 ? overrides : undefined,
|
||||
courtId: courtId || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
if (seq !== procCalcSeq) return;
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
console.error("API error:", err);
|
||||
return;
|
||||
}
|
||||
|
||||
const data: DeadlineResponse = await resp.json();
|
||||
if (seq !== procCalcSeq) return;
|
||||
lastResponse = data;
|
||||
renderProcedureResults(data);
|
||||
showStep(3);
|
||||
} catch (e) {
|
||||
console.error("Fetch error:", e);
|
||||
}
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
priorityDate,
|
||||
flags,
|
||||
anchorOverrides: overrides,
|
||||
courtId,
|
||||
});
|
||||
if (seq !== procCalcSeq) return;
|
||||
if (!data) return;
|
||||
lastResponse = data;
|
||||
renderProcedureResults(data);
|
||||
showStep(3);
|
||||
}
|
||||
|
||||
interface ProjectOption {
|
||||
@@ -296,16 +177,6 @@ interface ProjectOption {
|
||||
our_side?: "claimant" | "defendant" | "court" | "both" | null;
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
async function fetchProjects(): Promise<ProjectOption[]> {
|
||||
try {
|
||||
const resp = await fetch("/api/projects");
|
||||
@@ -500,8 +371,8 @@ function renderProcedureResults(data: DeadlineResponse) {
|
||||
</div>`;
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data)
|
||||
: renderTimelineBody(data);
|
||||
? renderColumnsBody(data, { editable: true })
|
||||
: renderTimelineBody(data, { showParty: true, editable: true });
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
printBtn.style.display = "block";
|
||||
@@ -572,186 +443,8 @@ function openInlineDateEditor(span: HTMLElement) {
|
||||
if (editor.value) editor.select();
|
||||
}
|
||||
|
||||
function deadlineCardHtml(dl: CalculatedDeadline, opts: { showParty: boolean }): string {
|
||||
// Click-to-edit on dated rows + court-set placeholders: lets the user
|
||||
// override the calculated date (e.g. court extended the deadline) or
|
||||
// fill in a court-set decision date once known. Downstream rules
|
||||
// re-anchor on the override via anchorOverrides → /api/tools/fristenrechner.
|
||||
// Root-event rows (the trigger anchor itself) are NOT editable — the
|
||||
// trigger date input is the canonical place to change that.
|
||||
const editable = !dl.isRootEvent && dl.code !== "";
|
||||
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
|
||||
const editAttrs = editable
|
||||
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
|
||||
: "";
|
||||
// "wird vom Gericht bestimmt" only fits direct court-set rules
|
||||
// (Urteil / Beschluss / Anordnung). Indirect rules (chained off a
|
||||
// court-set parent, e.g. RoP.151) render "unbestimmt" instead — the
|
||||
// date isn't directly determined by the court, it's derived from
|
||||
// the parent's date that the court will set. m's 2026-05-08 call.
|
||||
const courtLabelKey = dl.isCourtSetIndirect
|
||||
? "deadlines.court.indirect"
|
||||
: "deadlines.court.set";
|
||||
const dateStr = dl.isCourtSet
|
||||
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
|
||||
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
|
||||
|
||||
const mandatoryBadge = dl.isMandatory
|
||||
? ""
|
||||
: '<span class="optional-badge">optional</span>';
|
||||
|
||||
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
|
||||
|
||||
const adjustedNote = dl.wasAdjusted
|
||||
? `<div class="timeline-adjusted">\u26a0 ${formatAdjustedNote(dl)}</div>`
|
||||
: "";
|
||||
|
||||
const ruleRef = dl.ruleRef
|
||||
? `<span class="timeline-rule">${dl.ruleRef}</span>`
|
||||
: "";
|
||||
|
||||
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
const notes = noteText
|
||||
? `<div class="timeline-notes">${noteText}</div>`
|
||||
: "";
|
||||
|
||||
const meta = (opts.showParty || ruleRef)
|
||||
? `<div class="timeline-meta">
|
||||
${opts.showParty ? partyBadge(dl.party) : ""}
|
||||
${ruleRef}
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
return `<div class="timeline-item-header">
|
||||
<span class="timeline-name">
|
||||
${dlName}
|
||||
${mandatoryBadge}
|
||||
</span>
|
||||
${dateStr}
|
||||
</div>
|
||||
${meta}
|
||||
${adjustedNote}
|
||||
${notes}`;
|
||||
}
|
||||
|
||||
function renderTimelineBody(data: DeadlineResponse): string {
|
||||
let html = '<div class="timeline">';
|
||||
for (const dl of data.deadlines) {
|
||||
html += `
|
||||
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
|
||||
<div class="timeline-dot-col">
|
||||
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
|
||||
<div class="timeline-line"></div>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
${deadlineCardHtml(dl, { showParty: true })}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
}
|
||||
|
||||
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
|
||||
// (defendant). Each grid row corresponds to a distinct dueDate, so events on
|
||||
// the same day line up across columns. Deadlines with party=both render in
|
||||
// BOTH the Proactive and Reactive cells of their row with a "beide Seiten"
|
||||
// caption so the duplication is legible as intentional. Undated events
|
||||
// (Urteil, Beschluss, court-set placeholders) trail the dated rows; each
|
||||
// gets its own row in the backend's sequence_order so e.g. Urteil precedes
|
||||
// Berufungseinlegung visually instead of stacking in one bucket.
|
||||
function renderColumnsBody(data: DeadlineResponse): string {
|
||||
type Cell = CalculatedDeadline[];
|
||||
type Row = { proactive: Cell; court: Cell; reactive: Cell };
|
||||
|
||||
const UNSCHEDULED_PREFIX = "__unscheduled__";
|
||||
const rowsMap = new Map<string, Row>();
|
||||
const ensureRow = (key: string): Row => {
|
||||
let r = rowsMap.get(key);
|
||||
if (!r) {
|
||||
r = { proactive: [], court: [], reactive: [] };
|
||||
rowsMap.set(key, r);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
data.deadlines.forEach((dl, idx) => {
|
||||
// Dated rows share a row by date; undated rows each get their own row,
|
||||
// keyed by index so the backend's sequence_order is preserved in the
|
||||
// dateless tail.
|
||||
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
|
||||
const row = ensureRow(key);
|
||||
switch (dl.party) {
|
||||
case "claimant":
|
||||
row.proactive.push(dl);
|
||||
break;
|
||||
case "defendant":
|
||||
row.reactive.push(dl);
|
||||
break;
|
||||
case "court":
|
||||
row.court.push(dl);
|
||||
break;
|
||||
case "both":
|
||||
// Mirrored: same card lands in Proactive AND Reactive at this date.
|
||||
row.proactive.push(dl);
|
||||
row.reactive.push(dl);
|
||||
break;
|
||||
default:
|
||||
// Unknown party: keep visible by parking in the Court column.
|
||||
row.court.push(dl);
|
||||
}
|
||||
});
|
||||
|
||||
// Dated keys (YYYY-MM-DD) sort chronologically by lexicographic compare.
|
||||
// Unscheduled keys carry the sequence-order index in their padded suffix
|
||||
// so they likewise sort by source order. Concatenate so the dateless tail
|
||||
// sits below the dated rows.
|
||||
const datedKeys: string[] = [];
|
||||
const unscheduledKeys: string[] = [];
|
||||
for (const k of rowsMap.keys()) {
|
||||
if (k.startsWith(UNSCHEDULED_PREFIX)) unscheduledKeys.push(k);
|
||||
else datedKeys.push(k);
|
||||
}
|
||||
datedKeys.sort();
|
||||
unscheduledKeys.sort();
|
||||
const keys = [...datedKeys, ...unscheduledKeys];
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
|
||||
}
|
||||
const cards = items
|
||||
.map((dl) => {
|
||||
const mirrorTag = dl.party === "both"
|
||||
? `<div class="fr-col-mirror">\u2194 ${escHtml(t("deadlines.party.both.label"))}</div>`
|
||||
: "";
|
||||
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
|
||||
${deadlineCardHtml(dl, { showParty: false })}
|
||||
${mirrorTag}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="fr-col-cell">${cards}</div>`;
|
||||
};
|
||||
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
|
||||
|
||||
for (const key of keys) {
|
||||
const row = rowsMap.get(key)!;
|
||||
html += renderCell(row.proactive);
|
||||
html += renderCell(row.court);
|
||||
html += renderCell(row.reactive);
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
}
|
||||
// deadlineCardHtml / renderTimelineBody / renderColumnsBody moved to
|
||||
// ./views/verfahrensablauf-core (t-paliad-179 Slice 1).
|
||||
|
||||
function reset() {
|
||||
selectedType = "";
|
||||
@@ -812,7 +505,7 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
if (revCciRow) revCciRow.style.display = selectedType === "UPC_REV" ? "" : "none";
|
||||
|
||||
syncInfAmendEnabled();
|
||||
populateCourtPicker(selectedType);
|
||||
populateCourtPickerCore("court-picker-row", "court-picker", selectedType);
|
||||
|
||||
// Hide the four group blocks; show the compact summary in their place.
|
||||
setProceedingPickerCollapsed(true, name);
|
||||
@@ -821,99 +514,9 @@ function selectProceeding(btn: HTMLButtonElement) {
|
||||
scheduleProcCalc(0);
|
||||
}
|
||||
|
||||
// Court picker — t-paliad-122. Visible only for proceeding types that can
|
||||
// land in multiple courts with different holiday calendars (today: every
|
||||
// UPC-flavoured proceeding type, since UPC LDs span DE/FR/IT/NL/BE/FI/PT/
|
||||
// AT/SI/DK + Stockholm RD + 3 CD seats). For DE-only proceedings (DE_NULL,
|
||||
// DE_NULL_BGH, DE_INF_BGH, DPMA_*, EPA_*, EP_GRANT) the court is fixed by
|
||||
// the proceeding type — no picker, server resolves the default.
|
||||
//
|
||||
// The picker calls /api/tools/courts?courtType=UPC-LD on first need and
|
||||
// caches the response per-type. Defaulting to upc-ld-muenchen matches HLC's
|
||||
// most common venue and keeps current behaviour for users who don't choose.
|
||||
interface CourtRow {
|
||||
id: string;
|
||||
code: string;
|
||||
nameDE: string;
|
||||
nameEN: string;
|
||||
country: string;
|
||||
regime?: string;
|
||||
courtType: string;
|
||||
}
|
||||
|
||||
const courtCache = new Map<string, CourtRow[]>();
|
||||
|
||||
function courtTypesFor(proceedingType: string): string[] {
|
||||
// Map proceeding code to compatible court types. UPC proceedings → UPC-LD
|
||||
// (most common); appeals → UPC-CoA; central-division revocations → UPC-CD.
|
||||
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
|
||||
return ["UPC-CoA"];
|
||||
}
|
||||
if (proceedingType === "UPC_REV") {
|
||||
return ["UPC-CD", "UPC-LD"]; // CD is the default revocation forum, LD when joined with infringement
|
||||
}
|
||||
if (proceedingType.startsWith("UPC_")) {
|
||||
return ["UPC-LD"];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function defaultCourtFor(proceedingType: string): string {
|
||||
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
|
||||
return "upc-coa-luxembourg";
|
||||
}
|
||||
if (proceedingType === "UPC_REV") {
|
||||
return "upc-cd-paris";
|
||||
}
|
||||
return "upc-ld-muenchen";
|
||||
}
|
||||
|
||||
async function fetchCourts(courtType: string): Promise<CourtRow[]> {
|
||||
if (courtCache.has(courtType)) return courtCache.get(courtType)!;
|
||||
try {
|
||||
const resp = await fetch(`/api/tools/courts?courtType=${encodeURIComponent(courtType)}`);
|
||||
if (!resp.ok) return [];
|
||||
const rows = (await resp.json()) as CourtRow[];
|
||||
courtCache.set(courtType, rows);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function populateCourtPicker(proceedingType: string): Promise<void> {
|
||||
const row = document.getElementById("court-picker-row");
|
||||
const select = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
if (!row || !select) return;
|
||||
|
||||
const types = courtTypesFor(proceedingType);
|
||||
if (types.length === 0) {
|
||||
row.style.display = "none";
|
||||
select.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
// Load all compatible court types and concatenate (CD before LD for REV).
|
||||
const lists = await Promise.all(types.map(t => fetchCourts(t)));
|
||||
const courts = lists.flat();
|
||||
if (courts.length <= 1) {
|
||||
// Single compatible court — no point asking the user. Server's
|
||||
// jurisdiction default lands the same place.
|
||||
row.style.display = "none";
|
||||
select.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const lang = getLang();
|
||||
const defaultID = defaultCourtFor(proceedingType);
|
||||
select.innerHTML = courts.map(c => {
|
||||
const name = lang === "en" ? c.nameEN : c.nameDE;
|
||||
return `<option value="${escAttr(c.id)}"${c.id === defaultID ? " selected" : ""}>${escHtml(name)}</option>`;
|
||||
}).join("");
|
||||
row.style.display = "";
|
||||
}
|
||||
|
||||
// inf-amend-flag is only meaningful when ccr-flag is on (R.30 application
|
||||
// Court-picker primitives (CourtRow / courtCache / courtTypesFor /
|
||||
// defaultCourtFor / fetchCourts / populateCourtPicker) moved to
|
||||
// ./views/verfahrensablauf-core (t-paliad-179 Slice 1).
|
||||
// is filed within the Defence to CCR). When ccr-flag flips off, also
|
||||
// untick inf-amend-flag so the calc payload stays coherent.
|
||||
function syncInfAmendEnabled() {
|
||||
@@ -2709,12 +2312,9 @@ function initPathwayFork() {
|
||||
document.getElementById("fristen-step2-happened")?.addEventListener("click", () => {
|
||||
navigateToPathway("b", "tree");
|
||||
});
|
||||
// t-paliad-168 — Verfahrensablauf einsehen (browse / learn). Drops
|
||||
// straight into Pathway A's proceeding-tile picker. The save CTA
|
||||
// disables itself in this mode (see isBrowseOrAdhocMode below).
|
||||
document.getElementById("fristen-step2-browse")?.addEventListener("click", () => {
|
||||
navigateToPathway("a");
|
||||
});
|
||||
// t-paliad-179 Slice 1: the "Verfahrensablauf einsehen" Step 2 card
|
||||
// has been retired — the abstract-browse intent lives on its own
|
||||
// route at /tools/verfahrensablauf now. No third-card handler here.
|
||||
|
||||
// Step 3a cards — File / Draft / Enter. File drops into the existing
|
||||
// Pathway A wizard; Enter routes to the manual-create form;
|
||||
|
||||
@@ -198,6 +198,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.title": "Fristenrechner \u2014 Paliad",
|
||||
"deadlines.heading": "Fristenrechner",
|
||||
"deadlines.subtitle": "Berechnung von Verfahrensfristen f\u00fcr UPC-, deutsche und EPA-Verfahren.",
|
||||
|
||||
// Verfahrensablauf (t-paliad-179 Slice 1)
|
||||
"tools.verfahrensablauf.title": "Verfahrensablauf \u2014 Paliad",
|
||||
"tools.verfahrensablauf.heading": "Verfahrensablauf",
|
||||
"tools.verfahrensablauf.subtitle": "Typischen Verfahrensablauf einsehen \u2014 Verfahrensart w\u00e4hlen, Datum optional setzen.",
|
||||
|
||||
"deadlines.step1": "Verfahrensart w\u00e4hlen",
|
||||
"deadlines.step2": "Ausgangsdatum eingeben",
|
||||
"deadlines.step3": "Ergebnis",
|
||||
@@ -1150,6 +1156,44 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.back": "\u2190 Zur\u00fcck zur \u00dcbersicht",
|
||||
"projects.detail.loading": "L\u00e4dt\u2026",
|
||||
"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 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.",
|
||||
"projects.chart.control.layout.horizontal": "Layout: Horizontal",
|
||||
"projects.chart.control.columns.auto": "Spalten: Auto",
|
||||
"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",
|
||||
@@ -2162,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.",
|
||||
@@ -2501,6 +2547,12 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.title": "Deadline Calculator \u2014 Paliad",
|
||||
"deadlines.heading": "Patent Deadline Calculator",
|
||||
"deadlines.subtitle": "Calculate procedural deadlines for UPC, German, and EPA proceedings.",
|
||||
|
||||
// Verfahrensablauf (t-paliad-179 Slice 1)
|
||||
"tools.verfahrensablauf.title": "Procedure Roadmap \u2014 Paliad",
|
||||
"tools.verfahrensablauf.heading": "Procedure Roadmap",
|
||||
"tools.verfahrensablauf.subtitle": "Browse the typical proceeding shape \u2014 pick a proceeding type, optionally set a trigger date.",
|
||||
|
||||
"deadlines.step1": "Select Proceeding Type",
|
||||
"deadlines.step2": "Enter Trigger Date",
|
||||
"deadlines.step3": "Result",
|
||||
@@ -3441,6 +3493,44 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.back": "\u2190 Back to overview",
|
||||
"projects.detail.loading": "Loading\u2026",
|
||||
"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 Activity",
|
||||
"projects.chart.loading": "Loading\u2026",
|
||||
"projects.chart.notfound": "Project not found or no access.",
|
||||
"projects.chart.error.mount": "Chart could not be initialised.",
|
||||
"projects.chart.control.layout.horizontal": "Layout: horizontal",
|
||||
"projects.chart.control.columns.auto": "Columns: auto",
|
||||
"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",
|
||||
@@ -4449,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.",
|
||||
|
||||
489
frontend/src/client/projects-chart.ts
Normal file
489
frontend/src/client/projects-chart.ts
Normal file
@@ -0,0 +1,489 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
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
|
||||
// project metadata (for title + breadcrumb), mounts the SVG renderer
|
||||
// inside #projects-chart-host. Slice 1 keeps the controls inert; Slice 3
|
||||
// wires density / palette / zoom against this same surface.
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string;
|
||||
client_matter?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
const PROJECT_ID_RE = /^\/projects\/([0-9a-fA-F-]{36})\/chart\/?$/;
|
||||
|
||||
function projectIdFromPath(): string | null {
|
||||
const match = PROJECT_ID_RE.exec(window.location.pathname);
|
||||
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)}`);
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as Project;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function formatMeta(p: Project): string {
|
||||
const parts: string[] = [];
|
||||
if (p.reference) parts.push(p.reference);
|
||||
if (p.client_matter) parts.push(p.client_matter);
|
||||
return parts.join(" • ");
|
||||
}
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
const loadingEl = document.getElementById("projects-chart-loading");
|
||||
const notfoundEl = document.getElementById("projects-chart-notfound");
|
||||
const bodyEl = document.getElementById("projects-chart-body");
|
||||
const titleEl = document.getElementById("projects-chart-title");
|
||||
const metaEl = document.getElementById("projects-chart-meta");
|
||||
const backLink = document.getElementById("projects-chart-back-link") as HTMLAnchorElement | null;
|
||||
const host = document.getElementById("projects-chart-host");
|
||||
const undatedHint = document.getElementById("projects-chart-undated");
|
||||
|
||||
const id = projectIdFromPath();
|
||||
if (!id || !host || !bodyEl || !loadingEl || !notfoundEl) {
|
||||
if (loadingEl) loadingEl.style.display = "none";
|
||||
if (notfoundEl) notfoundEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
const project = await loadProject(id);
|
||||
if (!project) {
|
||||
loadingEl.style.display = "none";
|
||||
notfoundEl.style.display = "block";
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
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,
|
||||
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.
|
||||
const checkUndated = () => {
|
||||
if (!undatedHint || !handle) return;
|
||||
const layout = handle.getLayout();
|
||||
if (!layout) return;
|
||||
if (layout.undatedCount > 0) {
|
||||
undatedHint.style.display = "";
|
||||
undatedHint.textContent = `${layout.undatedCount} Ereignis(se) ohne Datum (links angeheftet).`;
|
||||
} else {
|
||||
undatedHint.style.display = "none";
|
||||
}
|
||||
};
|
||||
// Poll once after the initial fetch settles. mount() kicks the fetch
|
||||
// synchronously; layout becomes available after the network round-trip.
|
||||
setTimeout(checkUndated, 400);
|
||||
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();
|
||||
});
|
||||
@@ -255,16 +255,30 @@ let timelineSelectedLanes: string[] | null = null;
|
||||
// and back keeps the user's choice.
|
||||
let timelineClientShowLanes = false;
|
||||
|
||||
// t-paliad-170 — Verlauf FilterBar state.
|
||||
// t-paliad-170 / t-paliad-176 — Verlauf FilterBar state.
|
||||
//
|
||||
// The bar mounts once, owns the URL params (?time=, ?pe_kind=, …), and
|
||||
// drives loadEvents through its customRunner. Filtering is client-side
|
||||
// against the legacy /api/projects/{id}/events response so subtree mode
|
||||
// + cursor pagination stay intact (substrate-side scope expansion lands
|
||||
// with t-paliad-169 SmartTimeline). Empty filter → identity passthrough.
|
||||
// The bar mounts once, owns the URL params (?time=, ?pe_kind=, ?tl_status=,
|
||||
// ?tl_track=, …), and drives a client-side filter pass over `timelineRows`
|
||||
// before render. The SmartTimeline endpoint has no built-in predicate for
|
||||
// timeline_status / timeline_track / project_event_kind axes — they sit on
|
||||
// BarState only — so we filter rendered rows in `applyTimelineRowFilters`
|
||||
// rather than re-fetching on every chip click. The customRunner drains the
|
||||
// bar's state into `verlaufFilters` and triggers a re-render via onResult.
|
||||
let verlaufBar: BarHandle | null = null;
|
||||
interface VerlaufFilters {
|
||||
// project_event_kind chip — values from KnownProjectEventKinds (see
|
||||
// internal/services/filter_spec.go). Only filters rows whose underlying
|
||||
// project_events.event_type is non-empty (deadline / appointment /
|
||||
// projected rows pass through unaffected — they have no event_type).
|
||||
eventKinds?: Set<string>;
|
||||
// timeline_status chip — matches TimelineEvent.status verbatim
|
||||
// (done | open | overdue | predicted | predicted_overdue | court_set | off_script).
|
||||
timelineStatuses?: Set<string>;
|
||||
// timeline_track chip — chip values are "parent" / "counterclaim" /
|
||||
// "off_script" but row.track may carry suffixed forms like
|
||||
// "counterclaim:<id>" or "parent_context:<id>". Filtering normalises
|
||||
// by matching the chip's prefix against the row's track tag.
|
||||
timelineTracks?: Set<string>;
|
||||
// Bounds are inclusive lower / exclusive upper, matching
|
||||
// computeViewSpecBounds in internal/services/view_service.go so the
|
||||
// semantics align when this surface eventually moves to the substrate.
|
||||
@@ -273,6 +287,65 @@ interface VerlaufFilters {
|
||||
}
|
||||
let verlaufFilters: VerlaufFilters = {};
|
||||
|
||||
// applyTimelineRowFilters narrows the SmartTimeline rows to whatever
|
||||
// the FilterBar's BarState declares. Empty filter → identity passthrough.
|
||||
// Called from renderTimeline immediately before handing rows to
|
||||
// renderSmartTimeline (single-column or lane-strip alike).
|
||||
function applyTimelineRowFilters(rows: SmartTimelineEvent[]): SmartTimelineEvent[] {
|
||||
const f = verlaufFilters;
|
||||
if (
|
||||
!f.eventKinds &&
|
||||
!f.timelineStatuses &&
|
||||
!f.timelineTracks &&
|
||||
!f.fromDate &&
|
||||
!f.toDate
|
||||
) {
|
||||
return rows;
|
||||
}
|
||||
return rows.filter((r) => {
|
||||
// project_event_kind narrows project_events specifically: deadline /
|
||||
// appointment / projected rows pass through unaffected (they carry no
|
||||
// project_event_type). A milestone whose project_event_type isn't in
|
||||
// the picked subset drops out.
|
||||
if (f.eventKinds && r.project_event_type) {
|
||||
if (!f.eventKinds.has(r.project_event_type)) return false;
|
||||
}
|
||||
if (f.timelineStatuses && !f.timelineStatuses.has(r.status)) return false;
|
||||
if (f.timelineTracks && !timelineTrackChipMatches(r.track, f.timelineTracks)) return false;
|
||||
if (f.fromDate || f.toDate) {
|
||||
// Undated rows (court-set decisions, counterclaim-pending) escape
|
||||
// the time horizon — same convention as the renderer's "Datum offen"
|
||||
// bucket. Otherwise compare the row's date against the bounds.
|
||||
if (r.date) {
|
||||
const d = new Date(r.date);
|
||||
if (f.fromDate && d < f.fromDate) return false;
|
||||
if (f.toDate && d >= f.toDate) return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// timelineTrackChipMatches normalises the chip vocabulary against the
|
||||
// row's track tag — chip "counterclaim" matches both "counterclaim" and
|
||||
// "counterclaim:<id>"; chip "parent" matches "parent" only (NOT
|
||||
// "parent_context:<id>", which is a CCR-child-viewing-parent overlay).
|
||||
function timelineTrackChipMatches(rowTrack: string, chips: Set<string>): boolean {
|
||||
const tag = rowTrack || "parent";
|
||||
if (chips.has(tag)) return true;
|
||||
for (const chip of chips) {
|
||||
if (chip === "counterclaim" && tag.startsWith("counterclaim:")) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// applyVerlaufFilters narrows the legacy /api/projects/{id}/events
|
||||
// response to the bar's filter state. The render path no longer reads
|
||||
// this `events` array (the SmartTimeline took over), but loadEvents +
|
||||
// loadMoreEvents still call it so the cursor pagination state stays
|
||||
// consistent for any future re-introduction. Keeps the project_event_kind
|
||||
// + time-horizon filter intact; the SmartTimeline-only axes don't apply
|
||||
// to the legacy ProjectEvent shape.
|
||||
function applyVerlaufFilters(rows: ProjectEvent[]): ProjectEvent[] {
|
||||
const f = verlaufFilters;
|
||||
if (!f.eventKinds && !f.fromDate && !f.toDate) return rows;
|
||||
@@ -505,7 +578,13 @@ function renderTimeline() {
|
||||
return;
|
||||
}
|
||||
|
||||
renderSmartTimeline(host, timelineRows, {
|
||||
// t-paliad-176 — apply FilterBar predicates client-side. The
|
||||
// SmartTimeline endpoint returns the unfiltered superset; the bar's
|
||||
// BarState (timeline_status / timeline_track / project_event_kind /
|
||||
// time horizon) narrows what we render. Empty filter → identity.
|
||||
const filteredRows = applyTimelineRowFilters(timelineRows);
|
||||
|
||||
renderSmartTimeline(host, filteredRows, {
|
||||
projectId,
|
||||
lang: getLang() === "en" ? "en" : "de",
|
||||
lookahead: timelineLookahead,
|
||||
@@ -1007,6 +1086,14 @@ function renderHeader() {
|
||||
(document.getElementById("project-title-display") as HTMLElement).textContent = project.title;
|
||||
(document.getElementById("project-ref-display") as HTMLElement).textContent = project.reference || "";
|
||||
|
||||
// t-paliad-177 — link from Verlauf header to standalone chart page.
|
||||
// Wired here (not in the TSX shell) because we need the resolved
|
||||
// project id, which only exists after the detail fetch settles.
|
||||
const chartLink = document.getElementById("smart-timeline-open-chart") as HTMLAnchorElement | null;
|
||||
if (chartLink) {
|
||||
chartLink.href = `/projects/${encodeURIComponent(project.id)}/chart`;
|
||||
}
|
||||
|
||||
const descDisplay = document.getElementById("project-description-display") as HTMLElement;
|
||||
const description = project.description ?? "";
|
||||
descDisplay.textContent = description;
|
||||
@@ -1968,19 +2055,18 @@ async function main() {
|
||||
}
|
||||
|
||||
// mountVerlaufFilterBar mounts the universal FilterBar inside the
|
||||
// Verlauf tab (t-paliad-170). The bar owns URL params (?time=, ?pe_kind=)
|
||||
// and the displayed filter chrome; on every state change it invokes the
|
||||
// customRunner below, which calls loadEvents (the legacy
|
||||
// /api/projects/{id}/events endpoint) and applies client-side filtering.
|
||||
// Verlauf tab (t-paliad-170 → t-paliad-176). The bar owns URL params
|
||||
// (?time=, ?pe_kind=, ?tl_status=, ?tl_track=) and the displayed filter
|
||||
// chrome; on every state change it invokes the customRunner below, which
|
||||
// drains the bar state into `verlaufFilters` and lets the bar's onResult
|
||||
// callback trigger renderTimeline — narrowing happens client-side over
|
||||
// `timelineRows` in `applyTimelineRowFilters`.
|
||||
//
|
||||
// Why customRunner instead of the substrate POST: the legacy endpoint
|
||||
// expands the project's descendant subtree server-side and returns
|
||||
// cursor-paginated rows, both of which the substrate's project_event
|
||||
// runner doesn't yet support (substrate only does ScopeExplicit on a
|
||||
// flat ID list, no "include descendants", no cursor). Migrating to the
|
||||
// substrate is the SmartTimeline redesign (t-paliad-169) — this slice
|
||||
// avoids the regression by keeping the data path and wiring the bar as
|
||||
// a UI primitive on top.
|
||||
// Why customRunner instead of the substrate POST: the SmartTimeline
|
||||
// endpoint isn't a substrate-managed system view, and timeline_status /
|
||||
// timeline_track / project_event_kind don't all map cleanly onto the
|
||||
// substrate's per-source predicates. The customRunner stays as the bar's
|
||||
// integration point with the SmartTimeline read pipeline.
|
||||
function mountVerlaufFilterBar(id: string): void {
|
||||
const host = document.getElementById("project-events-filter-bar");
|
||||
if (!host) return;
|
||||
@@ -2000,17 +2086,29 @@ function mountVerlaufFilterBar(id: string): void {
|
||||
verlaufBar = mountFilterBar(host, {
|
||||
baseFilter,
|
||||
baseRender,
|
||||
axes: ["time", "project_event_kind"],
|
||||
// t-paliad-176 — exposing timeline_status + timeline_track on the
|
||||
// Verlauf tab. They were declared in the bar's axis catalogue from
|
||||
// Slice 2 onward but never mounted on this surface; chips were
|
||||
// therefore invisible and the wire-up was a no-op.
|
||||
axes: ["time", "timeline_status", "timeline_track", "project_event_kind"],
|
||||
surfaceKey: "project-history",
|
||||
showSaveAsView: false,
|
||||
timePresets: ["past_7d", "past_30d", "past_90d", "any"],
|
||||
customRunner: async (effective) => {
|
||||
customRunner: async (effective, state) => {
|
||||
// project_event_kind rides through effective.filter.predicates
|
||||
// (substrate-shaped); timeline_status / timeline_track live on raw
|
||||
// BarState. The bar passes both to keep first-run hydration honest
|
||||
// (the bar handle hasn't been assigned to verlaufBar yet on first
|
||||
// run, so we can't reach getState() — the state argument fixes that).
|
||||
const kinds = effective.filter.predicates?.project_event?.event_types;
|
||||
const tlStatus = state.timeline_status;
|
||||
const tlTrack = state.timeline_track;
|
||||
verlaufFilters = {
|
||||
eventKinds: kinds && kinds.length ? new Set(kinds) : undefined,
|
||||
timelineStatuses: tlStatus && tlStatus.length ? new Set(tlStatus) : undefined,
|
||||
timelineTracks: tlTrack && tlTrack.length ? new Set(tlTrack) : undefined,
|
||||
...horizonBounds(effective.filter.time?.horizon ?? "any"),
|
||||
};
|
||||
await loadEvents(id);
|
||||
return { rows: [], inaccessible_project_ids: [] };
|
||||
},
|
||||
onResult: () => renderTimeline(),
|
||||
|
||||
@@ -73,9 +73,9 @@ export function initSidebar() {
|
||||
initInboxBadge();
|
||||
initAdminGroup();
|
||||
initPaliadinLinks();
|
||||
initProjectContextChartLink();
|
||||
initUserViewsGroup();
|
||||
initThemeToggle();
|
||||
fixVerfahrensablaufActive();
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
if (!sidebar) return;
|
||||
initSidebarResize(sidebar);
|
||||
@@ -444,29 +444,10 @@ function initUserViewsGroup(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// fixVerfahrensablaufActive disambiguates the two /tools/fristenrechner
|
||||
// sidebar entries (t-paliad-168). The SSR navItem helper compares
|
||||
// hrefs against pathname only, which can't tell ?path=a apart from
|
||||
// the no-query Fristenrechner — both would render as Fristenrechner=
|
||||
// active. At the client we know the search params; flip the active
|
||||
// class so the sidebar lights up the entry the user actually opened.
|
||||
function fixVerfahrensablaufActive(): void {
|
||||
if (window.location.pathname !== "/tools/fristenrechner") return;
|
||||
const path = new URLSearchParams(window.location.search).get("path");
|
||||
const fristenrechner = document.querySelector<HTMLAnchorElement>(
|
||||
'a.sidebar-item[href="/tools/fristenrechner"]',
|
||||
);
|
||||
const verfahrensablauf = document.querySelector<HTMLAnchorElement>(
|
||||
'a.sidebar-item[href="/tools/fristenrechner?path=a"]',
|
||||
);
|
||||
if (path === "a") {
|
||||
fristenrechner?.classList.remove("active");
|
||||
verfahrensablauf?.classList.add("active");
|
||||
} else {
|
||||
verfahrensablauf?.classList.remove("active");
|
||||
fristenrechner?.classList.add("active");
|
||||
}
|
||||
}
|
||||
// fixVerfahrensablaufActive removed (t-paliad-179 Slice 1). The two
|
||||
// sidebar entries now map 1:1 to distinct URLs (/tools/fristenrechner
|
||||
// vs /tools/verfahrensablauf), so the SSR navItem helper picks the
|
||||
// correct active class by pathname alone.
|
||||
|
||||
function renderUserViewItem(view: UserViewLite, currentPath: string): HTMLElement {
|
||||
const a = document.createElement("a");
|
||||
@@ -569,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
|
||||
|
||||
190
frontend/src/client/verfahrensablauf.ts
Normal file
190
frontend/src/client/verfahrensablauf.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
// /tools/verfahrensablauf client (t-paliad-179 Slice 1)
|
||||
//
|
||||
// Abstract-browse surface: pick a proceeding, pick a trigger date,
|
||||
// see the typical timeline. No Akte, no save-to-project, no anchor
|
||||
// override editing, no Pathway B cascade. Variant chips + lane view
|
||||
// (Slice 3) and compare (Slice 4) layer on top of this in later
|
||||
// slices. Court picker + view toggle + calc fetch + renderers all
|
||||
// come from ./views/verfahrensablauf-core, which fristenrechner.ts
|
||||
// shares.
|
||||
|
||||
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import {
|
||||
type DeadlineResponse,
|
||||
calculateDeadlines,
|
||||
formatDate,
|
||||
populateCourtPicker,
|
||||
renderColumnsBody,
|
||||
renderTimelineBody,
|
||||
} from "./views/verfahrensablauf-core";
|
||||
|
||||
let selectedType = "";
|
||||
let lastResponse: DeadlineResponse | null = null;
|
||||
|
||||
type ProcedureView = "timeline" | "columns";
|
||||
let procedureView: ProcedureView = "columns";
|
||||
|
||||
// Auto-calc plumbing — sequence + debounce mirror /tools/fristenrechner
|
||||
// so rapid input changes never let a stale response overwrite a fresh
|
||||
// one.
|
||||
let calcSeq = 0;
|
||||
let calcTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
function scheduleCalc(delayMs = 200) {
|
||||
if (calcTimer !== null) clearTimeout(calcTimer);
|
||||
calcTimer = setTimeout(() => {
|
||||
calcTimer = null;
|
||||
void doCalc();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
function showStep(n: number) {
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const el = document.getElementById(`step-${i}`);
|
||||
if (el) el.style.display = i <= n ? "block" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
async function doCalc() {
|
||||
const seq = ++calcSeq;
|
||||
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
|
||||
const triggerDate = dateInput?.value || "";
|
||||
if (!triggerDate || !selectedType) return;
|
||||
|
||||
const courtPickerRow = document.getElementById("court-picker-row");
|
||||
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
const courtId = courtPickerRow && courtPickerRow.style.display !== "none" && courtPicker?.value
|
||||
? courtPicker.value
|
||||
: "";
|
||||
|
||||
const data = await calculateDeadlines({
|
||||
proceedingType: selectedType,
|
||||
triggerDate,
|
||||
courtId,
|
||||
});
|
||||
if (seq !== calcSeq) return;
|
||||
if (!data) return;
|
||||
lastResponse = data;
|
||||
renderResults(data);
|
||||
showStep(3);
|
||||
}
|
||||
|
||||
function renderResults(data: DeadlineResponse) {
|
||||
const container = document.getElementById("timeline-container");
|
||||
if (!container) return;
|
||||
const printBtn = document.getElementById("fristen-print-btn");
|
||||
const toggle = document.getElementById("fristen-view-toggle");
|
||||
|
||||
const procName = tDyn(`deadlines.${data.proceedingType.toLowerCase()}`);
|
||||
const headerHtml = `<div class="timeline-header">
|
||||
<strong>${procName}</strong>
|
||||
<span class="timeline-trigger-date">${t("deadlines.trigger.label")}: ${formatDate(data.triggerDate)}</span>
|
||||
</div>`;
|
||||
|
||||
const bodyHtml = procedureView === "columns"
|
||||
? renderColumnsBody(data)
|
||||
: renderTimelineBody(data);
|
||||
|
||||
container.innerHTML = headerHtml + bodyHtml;
|
||||
if (printBtn) printBtn.style.display = "block";
|
||||
if (toggle) toggle.style.display = "";
|
||||
}
|
||||
|
||||
function setProceedingPickerCollapsed(collapsed: boolean, displayName?: string) {
|
||||
const groups = document.querySelectorAll<HTMLElement>(".proceeding-group");
|
||||
const summary = document.getElementById("proceeding-summary") as HTMLElement | null;
|
||||
const summaryName = document.getElementById("proceeding-summary-name");
|
||||
groups.forEach((g) => { g.style.display = collapsed ? "none" : ""; });
|
||||
if (summary) summary.style.display = collapsed ? "" : "none";
|
||||
if (summaryName && displayName) summaryName.textContent = displayName;
|
||||
}
|
||||
|
||||
function selectProceeding(btn: HTMLButtonElement) {
|
||||
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
||||
btn.classList.add("active");
|
||||
selectedType = btn.dataset.code || "";
|
||||
|
||||
const name = btn.querySelector("strong")?.textContent || "";
|
||||
const triggerEventEl = document.getElementById("trigger-event");
|
||||
if (triggerEventEl) triggerEventEl.textContent = name;
|
||||
|
||||
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
|
||||
|
||||
setProceedingPickerCollapsed(true, name);
|
||||
|
||||
showStep(2);
|
||||
scheduleCalc(0);
|
||||
}
|
||||
|
||||
function initViewToggle() {
|
||||
const toggle = document.getElementById("fristen-view-toggle");
|
||||
if (!toggle) return;
|
||||
|
||||
const initial = new URLSearchParams(window.location.search).get("view");
|
||||
if (initial === "timeline") procedureView = "timeline";
|
||||
|
||||
toggle.querySelectorAll<HTMLInputElement>("input[name=fristen-view]").forEach((input) => {
|
||||
input.checked = input.value === procedureView;
|
||||
input.addEventListener("change", () => {
|
||||
if (!input.checked) return;
|
||||
procedureView = input.value === "columns" ? "columns" : "timeline";
|
||||
const url = new URL(window.location.href);
|
||||
if (procedureView === "columns") {
|
||||
url.searchParams.delete("view");
|
||||
} else {
|
||||
url.searchParams.set("view", procedureView);
|
||||
}
|
||||
history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
});
|
||||
});
|
||||
|
||||
toggle.style.display = "none";
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
document.querySelectorAll<HTMLButtonElement>(".proceeding-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", () => selectProceeding(btn));
|
||||
});
|
||||
|
||||
document.getElementById("proceeding-summary-reselect")?.addEventListener("click", () => {
|
||||
setProceedingPickerCollapsed(false);
|
||||
});
|
||||
|
||||
document.getElementById("calculate-btn")?.addEventListener("click", () => scheduleCalc(0));
|
||||
|
||||
const dateInput = document.getElementById("trigger-date") as HTMLInputElement | null;
|
||||
if (dateInput) {
|
||||
dateInput.addEventListener("change", () => scheduleCalc());
|
||||
dateInput.addEventListener("input", () => scheduleCalc());
|
||||
dateInput.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") scheduleCalc(0);
|
||||
});
|
||||
}
|
||||
|
||||
const courtPicker = document.getElementById("court-picker") as HTMLSelectElement | null;
|
||||
if (courtPicker) courtPicker.addEventListener("change", () => scheduleCalc(0));
|
||||
|
||||
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
|
||||
|
||||
initViewToggle();
|
||||
|
||||
onLangChange(() => {
|
||||
if (lastResponse) renderResults(lastResponse);
|
||||
const activeBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn.active");
|
||||
if (activeBtn) {
|
||||
const name = activeBtn.querySelector("strong")?.textContent || "";
|
||||
const triggerEventEl = document.getElementById("trigger-event");
|
||||
if (triggerEventEl) triggerEventEl.textContent = name;
|
||||
}
|
||||
});
|
||||
|
||||
// Pre-select the first proceeding tile so users see a timeline
|
||||
// immediately on landing — matches /tools/fristenrechner behaviour.
|
||||
const firstBtn = document.querySelector<HTMLButtonElement>(".proceeding-btn");
|
||||
if (firstBtn) selectProceeding(firstBtn);
|
||||
});
|
||||
@@ -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}`;
|
||||
}
|
||||
254
frontend/src/client/views/shape-timeline-chart.test.ts
Normal file
254
frontend/src/client/views/shape-timeline-chart.test.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { layout, type ChartViewport } from "./shape-timeline-chart";
|
||||
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
|
||||
|
||||
// t-paliad-177 Slice 1 — table-driven tests for the pure `layout()`
|
||||
// function. `layout` translates a TimelineEvent[] + LaneInfo[] + viewport
|
||||
// into deterministic SVG-ready geometry. Tests pin the math so subtle
|
||||
// drift (off-by-one days, axis tick density, lane stacking) surfaces fast.
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §15.
|
||||
|
||||
const vp = (overrides: Partial<ChartViewport> = {}): ChartViewport => ({
|
||||
width: 1000,
|
||||
height: 400,
|
||||
laneLabelWidth: 200,
|
||||
dateAxisHeight: 40,
|
||||
todayISO: "2026-06-15",
|
||||
rangeFrom: "2026-01-01",
|
||||
rangeTo: "2026-12-31",
|
||||
density: "standard",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const ev = (overrides: Partial<TimelineEvent> = {}): TimelineEvent => ({
|
||||
kind: "deadline",
|
||||
status: "open",
|
||||
track: "parent",
|
||||
date: "2026-06-15",
|
||||
title: "Test event",
|
||||
...overrides,
|
||||
});
|
||||
|
||||
describe("layout — base geometry", () => {
|
||||
test("chart canvas sits to the right of lane labels and below date axis", () => {
|
||||
const out = layout([], [], vp());
|
||||
expect(out.chartLeft).toBe(200);
|
||||
expect(out.chartTop).toBe(40);
|
||||
expect(out.chartWidth).toBe(800);
|
||||
expect(out.chartHeight).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("pxPerDay = chartWidth / total_days", () => {
|
||||
// 2026 is 365 days; range Jan 1..Dec 31 is 364 day-deltas + 1 = 365 days.
|
||||
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
|
||||
expect(out.pxPerDay).toBeCloseTo(800 / 364, 5);
|
||||
});
|
||||
|
||||
test("invalid range (to before from) falls back to a 1-day span", () => {
|
||||
const out = layout([], [], vp({ rangeFrom: "2026-06-01", rangeTo: "2026-05-01" }));
|
||||
// Sanity: pxPerDay finite, no division-by-zero.
|
||||
expect(Number.isFinite(out.pxPerDay)).toBe(true);
|
||||
expect(out.pxPerDay).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout — today rule", () => {
|
||||
test("today inside range produces a non-null todayX in the chart canvas", () => {
|
||||
const out = layout([], [], vp({ todayISO: "2026-06-15" }));
|
||||
expect(out.todayX).not.toBeNull();
|
||||
expect(out.todayX!).toBeGreaterThan(out.chartLeft);
|
||||
expect(out.todayX!).toBeLessThan(out.chartLeft + out.chartWidth);
|
||||
});
|
||||
|
||||
test("today before range.from → todayX is null", () => {
|
||||
const out = layout([], [], vp({ todayISO: "2025-12-15" }));
|
||||
expect(out.todayX).toBeNull();
|
||||
});
|
||||
|
||||
test("today after range.to → todayX is null", () => {
|
||||
const out = layout([], [], vp({ todayISO: "2027-01-15" }));
|
||||
expect(out.todayX).toBeNull();
|
||||
});
|
||||
|
||||
test("today equals range.from → todayX sits at chartLeft", () => {
|
||||
const out = layout([], [], vp({ todayISO: "2026-01-01" }));
|
||||
expect(out.todayX).toBeCloseTo(out.chartLeft, 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout — lane stacking", () => {
|
||||
test("empty lanes synthesises a single 'self' lane", () => {
|
||||
const out = layout([], [], vp());
|
||||
expect(out.laneRows).toHaveLength(1);
|
||||
expect(out.laneRows[0].id).toBe("self");
|
||||
});
|
||||
|
||||
test("multiple lanes stack vertically in input order", () => {
|
||||
const lanes: LaneInfo[] = [
|
||||
{ id: "self", label: "Hauptverfahren" },
|
||||
{ id: "counterclaim:abc", label: "Widerklage" },
|
||||
{ id: "parent_context:xyz", label: "Parent" },
|
||||
];
|
||||
const out = layout([], lanes, vp());
|
||||
expect(out.laneRows).toHaveLength(3);
|
||||
expect(out.laneRows[0].y).toBe(out.chartTop);
|
||||
expect(out.laneRows[1].y).toBeGreaterThan(out.laneRows[0].y);
|
||||
expect(out.laneRows[2].y).toBeGreaterThan(out.laneRows[1].y);
|
||||
// All same height.
|
||||
expect(out.laneRows[0].height).toBe(out.laneRows[1].height);
|
||||
expect(out.laneRows[1].height).toBe(out.laneRows[2].height);
|
||||
});
|
||||
|
||||
test("density compact gives smaller lane height than spacious", () => {
|
||||
const compact = layout([], [], vp({ density: "compact" }));
|
||||
const spacious = layout([], [], vp({ density: "spacious" }));
|
||||
expect(compact.laneRows[0].height).toBeLessThan(spacious.laneRows[0].height);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout — marks", () => {
|
||||
test("single deadline maps to one mark in the self lane", () => {
|
||||
const events: TimelineEvent[] = [ev({ date: "2026-06-15" })];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks).toHaveLength(1);
|
||||
expect(out.marks[0].eventIndex).toBe(0);
|
||||
expect(out.marks[0].laneId).toBe("self");
|
||||
expect(out.marks[0].undated).toBe(false);
|
||||
});
|
||||
|
||||
test("event's x position matches its date offset from range.from", () => {
|
||||
// June 15 is day 165 of 2026 (0-indexed from Jan 1).
|
||||
const events: TimelineEvent[] = [ev({ date: "2026-06-15" })];
|
||||
const out = layout(events, [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
|
||||
const expectedX = out.chartLeft + 165 * out.pxPerDay;
|
||||
expect(out.marks[0].x).toBeCloseTo(expectedX, 1);
|
||||
});
|
||||
|
||||
test("event bucketed by lane_id matches the corresponding lane row", () => {
|
||||
const lanes: LaneInfo[] = [
|
||||
{ id: "self", label: "Self" },
|
||||
{ id: "ccr", label: "CCR" },
|
||||
];
|
||||
const events: TimelineEvent[] = [
|
||||
ev({ date: "2026-06-15", lane_id: "ccr" }),
|
||||
];
|
||||
const out = layout(events, lanes, vp());
|
||||
const ccrRow = out.laneRows.find((r) => r.id === "ccr")!;
|
||||
expect(out.marks[0].laneId).toBe("ccr");
|
||||
expect(out.marks[0].y).toBeCloseTo(ccrRow.y + ccrRow.height / 2, 1);
|
||||
});
|
||||
|
||||
test("unknown lane_id falls back to the first lane (defensive)", () => {
|
||||
const lanes: LaneInfo[] = [{ id: "self", label: "Self" }];
|
||||
const events: TimelineEvent[] = [
|
||||
ev({ date: "2026-06-15", lane_id: "deleted-lane-id" }),
|
||||
];
|
||||
const out = layout(events, lanes, vp());
|
||||
expect(out.marks[0].laneId).toBe("self");
|
||||
});
|
||||
|
||||
test("events outside range are clipped (not emitted)", () => {
|
||||
const events: TimelineEvent[] = [
|
||||
ev({ date: "2025-01-01", title: "before" }),
|
||||
ev({ date: "2026-06-15", title: "inside" }),
|
||||
ev({ date: "2027-12-31", title: "after" }),
|
||||
];
|
||||
const out = layout(events, [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
|
||||
expect(out.marks).toHaveLength(1);
|
||||
expect(out.marks[0].eventIndex).toBe(1);
|
||||
});
|
||||
|
||||
test("undated events go to the undated zone with undated=true", () => {
|
||||
const events: TimelineEvent[] = [ev({ date: null, title: "court-set" })];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks).toHaveLength(1);
|
||||
expect(out.marks[0].undated).toBe(true);
|
||||
// Undated marks sit in the lane label gutter (x < chartLeft).
|
||||
expect(out.marks[0].x).toBeLessThan(out.chartLeft);
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout — mark shapes by kind+status", () => {
|
||||
test("deadline.done → dot, deadline.open → dot, deadline.overdue → dot", () => {
|
||||
const events: TimelineEvent[] = [
|
||||
ev({ kind: "deadline", status: "done" }),
|
||||
ev({ kind: "deadline", status: "open" }),
|
||||
ev({ kind: "deadline", status: "overdue" }),
|
||||
];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks.map((m) => m.shape)).toEqual(["dot", "dot", "dot"]);
|
||||
});
|
||||
|
||||
test("milestone → diamond", () => {
|
||||
const events: TimelineEvent[] = [ev({ kind: "milestone", status: "done" })];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks[0].shape).toBe("diamond");
|
||||
});
|
||||
|
||||
test("appointment → dot (Slice 1 keeps it simple; bar variant deferred)", () => {
|
||||
const events: TimelineEvent[] = [ev({ kind: "appointment", status: "open" })];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks[0].shape).toBe("dot");
|
||||
});
|
||||
|
||||
test("projected.predicted → hatched-dot", () => {
|
||||
const events: TimelineEvent[] = [ev({ kind: "projected", status: "predicted" })];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks[0].shape).toBe("hatched-dot");
|
||||
});
|
||||
|
||||
test("projected.court_set → dashed-dot", () => {
|
||||
const events: TimelineEvent[] = [ev({ kind: "projected", status: "court_set" })];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.marks[0].shape).toBe("dashed-dot");
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout — axis ticks", () => {
|
||||
test("short range (<90d) emits month ticks", () => {
|
||||
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-02-28" }));
|
||||
const kinds = new Set(out.axisTicks.map((t) => t.kind));
|
||||
expect(kinds.has("month")).toBe(true);
|
||||
});
|
||||
|
||||
test("medium range (90-730d) emits quarter ticks", () => {
|
||||
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2026-12-31" }));
|
||||
const kinds = new Set(out.axisTicks.map((t) => t.kind));
|
||||
expect(kinds.has("quarter")).toBe(true);
|
||||
});
|
||||
|
||||
test("long range (>730d) emits year ticks", () => {
|
||||
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2029-12-31" }));
|
||||
const kinds = new Set(out.axisTicks.map((t) => t.kind));
|
||||
expect(kinds.has("year")).toBe(true);
|
||||
});
|
||||
|
||||
test("year-boundary ticks are flagged", () => {
|
||||
const out = layout([], [], vp({ rangeFrom: "2026-01-01", rangeTo: "2027-12-31" }));
|
||||
const yearBoundaries = out.axisTicks.filter((t) => t.isYearBoundary);
|
||||
expect(yearBoundaries.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test("all ticks fall inside the chart canvas horizontally", () => {
|
||||
const out = layout([], [], vp());
|
||||
for (const tick of out.axisTicks) {
|
||||
expect(tick.x).toBeGreaterThanOrEqual(out.chartLeft - 0.5);
|
||||
expect(tick.x).toBeLessThanOrEqual(out.chartLeft + out.chartWidth + 0.5);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("layout — undated counting", () => {
|
||||
test("undated marks tallied separately from inside-range count", () => {
|
||||
const events: TimelineEvent[] = [
|
||||
ev({ date: "2026-06-15" }),
|
||||
ev({ date: null }),
|
||||
ev({ date: null }),
|
||||
ev({ date: "2025-01-01" }), // out of range
|
||||
];
|
||||
const out = layout(events, [], vp());
|
||||
expect(out.undatedCount).toBe(2);
|
||||
expect(out.marks).toHaveLength(3); // 1 dated + 2 undated, the out-of-range one is clipped
|
||||
});
|
||||
});
|
||||
974
frontend/src/client/views/shape-timeline-chart.ts
Normal file
974
frontend/src/client/views/shape-timeline-chart.ts
Normal file
@@ -0,0 +1,974 @@
|
||||
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
|
||||
|
||||
// shape-timeline-chart (t-paliad-177 Slice 1) — horizontal SVG Gantt
|
||||
// renderer for the standalone Project Timeline / Chart page.
|
||||
//
|
||||
// Split into two concerns:
|
||||
//
|
||||
// layout(events, lanes, viewport): ChartLayout
|
||||
// pure function — translates the wire shape into deterministic
|
||||
// SVG-ready geometry (axis ticks, lane row y/height, mark x/y/shape,
|
||||
// today-rule x). No DOM access. Table-driven tests pin this in
|
||||
// shape-timeline-chart.test.ts.
|
||||
//
|
||||
// paint(layout, root): void (Slice 1, next commit)
|
||||
// DOM-mutates an SVGSVGElement. Reads layout, never recomputes
|
||||
// positions. Idempotent — calling twice with the same layout
|
||||
// produces the same DOM.
|
||||
//
|
||||
// mount(host, opts): ChartHandle (Slice 1, next commit)
|
||||
// End-to-end: fetches /api/projects/{id}/timeline, computes layout,
|
||||
// paints, returns a handle with .refresh() / .dispose().
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §2.3 + §3.2 + §12.
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type Density = "compact" | "standard" | "spacious";
|
||||
|
||||
export interface ChartViewport {
|
||||
width: number;
|
||||
height: number;
|
||||
/** Reserved on the left for lane labels (and the undated zone). */
|
||||
laneLabelWidth: number;
|
||||
/** Reserved on top for the date axis. */
|
||||
dateAxisHeight: number;
|
||||
/** Today's date as ISO YYYY-MM-DD. Used to position the today rule. */
|
||||
todayISO: string;
|
||||
/** Inclusive ISO YYYY-MM-DD start of the chart's date range. */
|
||||
rangeFrom: string;
|
||||
/** Inclusive ISO YYYY-MM-DD end of the chart's date range. */
|
||||
rangeTo: string;
|
||||
density: Density;
|
||||
}
|
||||
|
||||
export interface AxisTick {
|
||||
x: number;
|
||||
label: string;
|
||||
kind: "year" | "quarter" | "month";
|
||||
isYearBoundary: boolean;
|
||||
}
|
||||
|
||||
export interface LaneRow {
|
||||
id: string;
|
||||
label: string;
|
||||
y: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export type MarkShape =
|
||||
| "dot"
|
||||
| "diamond"
|
||||
| "hatched-dot"
|
||||
| "dashed-dot";
|
||||
|
||||
export interface Mark {
|
||||
/** Index into the original events array — paint() reuses this for tooltips + deep-links. */
|
||||
eventIndex: number;
|
||||
x: number;
|
||||
y: number;
|
||||
/** Radius for dot / hatched-dot / dashed-dot, half-diagonal for diamond. */
|
||||
radius: number;
|
||||
shape: MarkShape;
|
||||
kind: TimelineEvent["kind"];
|
||||
status: TimelineEvent["status"];
|
||||
laneId: string;
|
||||
undated: boolean;
|
||||
}
|
||||
|
||||
export interface ChartLayout {
|
||||
viewport: ChartViewport;
|
||||
pxPerDay: number;
|
||||
chartLeft: number;
|
||||
chartTop: number;
|
||||
chartWidth: number;
|
||||
chartHeight: number;
|
||||
axisTicks: AxisTick[];
|
||||
laneRows: LaneRow[];
|
||||
marks: Mark[];
|
||||
/** Pixel x of the today rule, or null when today is outside the range. */
|
||||
todayX: number | null;
|
||||
undatedCount: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Density tokens — single source of truth, used by layout() and CSS swap.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const LANE_HEIGHT: Record<Density, number> = {
|
||||
compact: 24,
|
||||
standard: 40,
|
||||
spacious: 64,
|
||||
};
|
||||
|
||||
const MARK_RADIUS: Record<Density, number> = {
|
||||
compact: 5,
|
||||
standard: 7,
|
||||
spacious: 10,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Date helpers — UTC throughout to avoid DST drift in day-math.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const DAY_MS = 86_400_000;
|
||||
|
||||
function parseISODay(iso: string): number | null {
|
||||
// Accept "YYYY-MM-DD" and "YYYY-MM-DDTHH:MM:SSZ" (substrate marshals
|
||||
// deadline.due_date as the UTC-midnight form — see format.ts).
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(iso);
|
||||
if (!m) return null;
|
||||
const y = Number(m[1]);
|
||||
const mo = Number(m[2]);
|
||||
const d = Number(m[3]);
|
||||
if (
|
||||
!Number.isFinite(y) || !Number.isFinite(mo) || !Number.isFinite(d) ||
|
||||
mo < 1 || mo > 12 || d < 1 || d > 31
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return Date.UTC(y, mo - 1, d);
|
||||
}
|
||||
|
||||
function dayDelta(fromMs: number, toMs: number): number {
|
||||
return Math.round((toMs - fromMs) / DAY_MS);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mark shape resolution — single mapping table, mirrors §6.2 of the design.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function markShape(kind: TimelineEvent["kind"], status: TimelineEvent["status"]): MarkShape {
|
||||
if (kind === "milestone") return "diamond";
|
||||
if (kind === "projected") {
|
||||
if (status === "court_set") return "dashed-dot";
|
||||
return "hatched-dot"; // predicted, predicted_overdue, off_script
|
||||
}
|
||||
// deadline + appointment + everything else → plain dot. Status drives
|
||||
// colour saturation (see CSS palette tokens), not shape.
|
||||
return "dot";
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Axis tick generation — granularity by total span.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function generateTicks(
|
||||
fromMs: number,
|
||||
toMs: number,
|
||||
chartLeft: number,
|
||||
pxPerDay: number,
|
||||
): AxisTick[] {
|
||||
const totalDays = dayDelta(fromMs, toMs);
|
||||
const ticks: AxisTick[] = [];
|
||||
|
||||
// Walk from the first day-of-month >= fromMs forward.
|
||||
const start = new Date(fromMs);
|
||||
const yStart = start.getUTCFullYear();
|
||||
const mStart = start.getUTCMonth();
|
||||
|
||||
// Density rules:
|
||||
// <90d → month ticks (every month-start)
|
||||
// 90-730 → quarter ticks (Jan, Apr, Jul, Oct)
|
||||
// >730 → year ticks (Jan only)
|
||||
let kind: AxisTick["kind"];
|
||||
let monthStep: number;
|
||||
if (totalDays < 90) {
|
||||
kind = "month";
|
||||
monthStep = 1;
|
||||
} else if (totalDays <= 730) {
|
||||
kind = "quarter";
|
||||
monthStep = 3;
|
||||
} else {
|
||||
kind = "year";
|
||||
monthStep = 12;
|
||||
}
|
||||
|
||||
// For quarter/year ticks, snap the starting month to the next aligned
|
||||
// boundary so the labels are calendar-aligned (Jan/Apr/Jul/Oct, not
|
||||
// Feb/May/Aug/Nov).
|
||||
let mCursor = mStart;
|
||||
let yCursor = yStart;
|
||||
if (kind === "quarter") {
|
||||
const offset = mCursor % 3;
|
||||
if (offset !== 0) mCursor += 3 - offset;
|
||||
} else if (kind === "year") {
|
||||
if (mCursor !== 0) {
|
||||
mCursor = 0;
|
||||
yCursor += 1;
|
||||
}
|
||||
}
|
||||
// If the first day of fromMs is not month-1, advance by one month so we
|
||||
// don't double-print the partial month at the very start.
|
||||
if (kind === "month" && start.getUTCDate() !== 1) {
|
||||
mCursor += 1;
|
||||
}
|
||||
while (mCursor >= 12) {
|
||||
mCursor -= 12;
|
||||
yCursor += 1;
|
||||
}
|
||||
|
||||
// Emit ticks until past toMs.
|
||||
while (true) {
|
||||
const tickMs = Date.UTC(yCursor, mCursor, 1);
|
||||
if (tickMs > toMs) break;
|
||||
const days = dayDelta(fromMs, tickMs);
|
||||
const x = chartLeft + days * pxPerDay;
|
||||
const label = formatTickLabel(yCursor, mCursor, kind);
|
||||
ticks.push({
|
||||
x,
|
||||
label,
|
||||
kind,
|
||||
isYearBoundary: mCursor === 0,
|
||||
});
|
||||
mCursor += monthStep;
|
||||
while (mCursor >= 12) {
|
||||
mCursor -= 12;
|
||||
yCursor += 1;
|
||||
}
|
||||
}
|
||||
return ticks;
|
||||
}
|
||||
|
||||
const MONTH_DE = [
|
||||
"Jan", "Feb", "Mär", "Apr", "Mai", "Jun",
|
||||
"Jul", "Aug", "Sep", "Okt", "Nov", "Dez",
|
||||
];
|
||||
|
||||
function formatTickLabel(year: number, month: number, kind: AxisTick["kind"]): string {
|
||||
if (kind === "year") return String(year);
|
||||
if (kind === "quarter") {
|
||||
const q = Math.floor(month / 3) + 1;
|
||||
return `Q${q} ${year}`;
|
||||
}
|
||||
return MONTH_DE[month];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: layout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function layout(
|
||||
events: ReadonlyArray<TimelineEvent>,
|
||||
lanes: ReadonlyArray<LaneInfo>,
|
||||
viewport: ChartViewport,
|
||||
): ChartLayout {
|
||||
// -- Canvas geometry --------------------------------------------------
|
||||
const chartLeft = viewport.laneLabelWidth;
|
||||
const chartTop = viewport.dateAxisHeight;
|
||||
const chartWidth = Math.max(0, viewport.width - chartLeft);
|
||||
// chartHeight is derived from the number of lane rows so the SVG grows
|
||||
// / shrinks vertically with the data, not the supplied viewport.height
|
||||
// (which the caller uses as a hint — actual height comes back in
|
||||
// viewport.height after the paint pass).
|
||||
const laneCount = Math.max(1, lanes.length);
|
||||
const laneHeight = LANE_HEIGHT[viewport.density];
|
||||
const chartHeight = laneCount * laneHeight;
|
||||
|
||||
// -- Date math --------------------------------------------------------
|
||||
const fromMs = parseISODay(viewport.rangeFrom);
|
||||
const toMsRaw = parseISODay(viewport.rangeTo);
|
||||
if (fromMs === null || toMsRaw === null) {
|
||||
// Degenerate input — return an empty layout rather than NaN-paint.
|
||||
return {
|
||||
viewport,
|
||||
pxPerDay: 0,
|
||||
chartLeft,
|
||||
chartTop,
|
||||
chartWidth,
|
||||
chartHeight,
|
||||
axisTicks: [],
|
||||
laneRows: synthLaneRows(lanes, chartTop, laneHeight),
|
||||
marks: [],
|
||||
todayX: null,
|
||||
undatedCount: 0,
|
||||
};
|
||||
}
|
||||
// Guard against to < from. Clamp the inverted case to a 1-day span so
|
||||
// pxPerDay stays positive and finite.
|
||||
const toMs = toMsRaw <= fromMs ? fromMs + DAY_MS : toMsRaw;
|
||||
const totalDays = Math.max(1, dayDelta(fromMs, toMs));
|
||||
const pxPerDay = chartWidth / totalDays;
|
||||
|
||||
// -- Today rule -------------------------------------------------------
|
||||
const todayMs = parseISODay(viewport.todayISO);
|
||||
let todayX: number | null = null;
|
||||
if (todayMs !== null && todayMs >= fromMs && todayMs <= toMs) {
|
||||
todayX = chartLeft + dayDelta(fromMs, todayMs) * pxPerDay;
|
||||
}
|
||||
|
||||
// -- Lane rows --------------------------------------------------------
|
||||
const laneRows = synthLaneRows(lanes, chartTop, laneHeight);
|
||||
const laneIndex = new Map<string, LaneRow>();
|
||||
for (const row of laneRows) laneIndex.set(row.id, row);
|
||||
const fallbackLane = laneRows[0];
|
||||
|
||||
// -- Marks ------------------------------------------------------------
|
||||
const marks: Mark[] = [];
|
||||
let undatedCount = 0;
|
||||
const radius = MARK_RADIUS[viewport.density];
|
||||
|
||||
for (let i = 0; i < events.length; i++) {
|
||||
const event = events[i];
|
||||
const laneRow = (event.lane_id && laneIndex.get(event.lane_id)) || fallbackLane;
|
||||
|
||||
if (!event.date) {
|
||||
// Undated rows live in a gutter to the left of the chart canvas.
|
||||
// We pile them up vertically inside the lane label area so they
|
||||
// remain hover-/click-targets, but they don't compete with the
|
||||
// date-axis-positioned marks for screen space.
|
||||
undatedCount++;
|
||||
const undatedX = chartLeft - viewport.laneLabelWidth * 0.25;
|
||||
marks.push({
|
||||
eventIndex: i,
|
||||
x: undatedX,
|
||||
y: laneRow.y + laneRow.height / 2,
|
||||
radius,
|
||||
shape: markShape(event.kind, event.status),
|
||||
kind: event.kind,
|
||||
status: event.status,
|
||||
laneId: laneRow.id,
|
||||
undated: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const ms = parseISODay(event.date);
|
||||
if (ms === null) continue; // unparseable date, drop defensively
|
||||
if (ms < fromMs || ms > toMs) continue; // outside range — clipped
|
||||
|
||||
const x = chartLeft + dayDelta(fromMs, ms) * pxPerDay;
|
||||
const y = laneRow.y + laneRow.height / 2;
|
||||
marks.push({
|
||||
eventIndex: i,
|
||||
x,
|
||||
y,
|
||||
radius,
|
||||
shape: markShape(event.kind, event.status),
|
||||
kind: event.kind,
|
||||
status: event.status,
|
||||
laneId: laneRow.id,
|
||||
undated: false,
|
||||
});
|
||||
}
|
||||
|
||||
// -- Axis ticks -------------------------------------------------------
|
||||
const axisTicks = generateTicks(fromMs, toMs, chartLeft, pxPerDay);
|
||||
|
||||
return {
|
||||
viewport,
|
||||
pxPerDay,
|
||||
chartLeft,
|
||||
chartTop,
|
||||
chartWidth,
|
||||
chartHeight,
|
||||
axisTicks,
|
||||
laneRows,
|
||||
marks,
|
||||
todayX,
|
||||
undatedCount,
|
||||
};
|
||||
}
|
||||
|
||||
function synthLaneRows(
|
||||
lanes: ReadonlyArray<LaneInfo>,
|
||||
chartTop: number,
|
||||
laneHeight: number,
|
||||
): LaneRow[] {
|
||||
if (lanes.length === 0) {
|
||||
return [{ id: "self", label: "", y: chartTop, height: laneHeight }];
|
||||
}
|
||||
return lanes.map((lane, idx) => ({
|
||||
id: lane.id,
|
||||
label: lane.label,
|
||||
y: chartTop + idx * laneHeight,
|
||||
height: laneHeight,
|
||||
}));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public: paint
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const SVG_NS = "http://www.w3.org/2000/svg";
|
||||
|
||||
function svg(name: string, attrs: Record<string, string | number> = {}): SVGElement {
|
||||
const el = document.createElementNS(SVG_NS, name);
|
||||
for (const [k, v] of Object.entries(attrs)) {
|
||||
el.setAttribute(k, String(v));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
/**
|
||||
* paint mutates an existing SVGSVGElement to reflect a ChartLayout.
|
||||
* Idempotent: clears prior children before painting, so calling twice
|
||||
* with the same layout produces the same DOM.
|
||||
*
|
||||
* Events are *not* wired here — mount() attaches the delegated listeners
|
||||
* after paint() returns. paint() stays pure-render so it stays cheap to
|
||||
* call from a resize / palette swap.
|
||||
*/
|
||||
export function paint(
|
||||
chart: ChartLayout,
|
||||
root: SVGSVGElement,
|
||||
events: ReadonlyArray<TimelineEvent>,
|
||||
): void {
|
||||
// Clear prior contents.
|
||||
while (root.firstChild) root.removeChild(root.firstChild);
|
||||
|
||||
const totalHeight = chart.chartTop + chart.chartHeight + 24; // 24px bottom pad for axis labels
|
||||
root.setAttribute("viewBox", `0 0 ${chart.viewport.width} ${totalHeight}`);
|
||||
root.setAttribute("preserveAspectRatio", "xMinYMin meet");
|
||||
root.setAttribute("role", "img");
|
||||
root.setAttribute("aria-label", "Project Timeline / Chart");
|
||||
|
||||
// <defs> — hatched pattern for projected marks.
|
||||
const defs = svg("defs");
|
||||
const pattern = svg("pattern", {
|
||||
id: "chart-hatch",
|
||||
patternUnits: "userSpaceOnUse",
|
||||
width: 4,
|
||||
height: 4,
|
||||
});
|
||||
pattern.appendChild(svg("path", {
|
||||
d: "M0,4 L4,0",
|
||||
stroke: "currentColor",
|
||||
"stroke-width": 1,
|
||||
fill: "none",
|
||||
}));
|
||||
defs.appendChild(pattern);
|
||||
root.appendChild(defs);
|
||||
|
||||
// Layer order: grid → lane separators → today rule → marks → labels.
|
||||
const gGrid = svg("g", { class: "chart-grid" });
|
||||
root.appendChild(gGrid);
|
||||
|
||||
// Date axis ticks — vertical guidelines + labels at top.
|
||||
for (const tick of chart.axisTicks) {
|
||||
gGrid.appendChild(svg("line", {
|
||||
class: tick.isYearBoundary
|
||||
? "chart-tick chart-tick--year"
|
||||
: "chart-tick",
|
||||
x1: tick.x,
|
||||
y1: chart.chartTop,
|
||||
x2: tick.x,
|
||||
y2: chart.chartTop + chart.chartHeight,
|
||||
}));
|
||||
const label = svg("text", {
|
||||
class: "chart-tick-label",
|
||||
x: tick.x + 4,
|
||||
y: chart.chartTop - 8,
|
||||
});
|
||||
label.textContent = tick.label;
|
||||
gGrid.appendChild(label);
|
||||
}
|
||||
|
||||
// Lane separators — horizontal lines between rows + labels in the gutter.
|
||||
for (let i = 0; i < chart.laneRows.length; i++) {
|
||||
const row = chart.laneRows[i];
|
||||
if (i > 0) {
|
||||
gGrid.appendChild(svg("line", {
|
||||
class: "chart-lane-separator",
|
||||
x1: 0,
|
||||
y1: row.y,
|
||||
x2: chart.viewport.width,
|
||||
y2: row.y,
|
||||
}));
|
||||
}
|
||||
if (row.label) {
|
||||
const labelEl = svg("text", {
|
||||
class: "chart-lane-label",
|
||||
x: 8,
|
||||
y: row.y + row.height / 2 + 4,
|
||||
});
|
||||
labelEl.textContent = row.label;
|
||||
gGrid.appendChild(labelEl);
|
||||
}
|
||||
}
|
||||
|
||||
// Today rule — vertical lime line + "Heute" label.
|
||||
if (chart.todayX !== null) {
|
||||
gGrid.appendChild(svg("line", {
|
||||
class: "chart-today-rule",
|
||||
x1: chart.todayX,
|
||||
y1: chart.chartTop - 4,
|
||||
x2: chart.todayX,
|
||||
y2: chart.chartTop + chart.chartHeight + 4,
|
||||
}));
|
||||
const todayLabel = svg("text", {
|
||||
class: "chart-today-label",
|
||||
x: chart.todayX + 4,
|
||||
y: chart.chartTop + chart.chartHeight + 18,
|
||||
});
|
||||
todayLabel.textContent = "Heute";
|
||||
gGrid.appendChild(todayLabel);
|
||||
}
|
||||
|
||||
// Marks.
|
||||
const gMarks = svg("g", { class: "chart-marks" });
|
||||
root.appendChild(gMarks);
|
||||
|
||||
for (const mark of chart.marks) {
|
||||
const event = events[mark.eventIndex];
|
||||
const markEl = paintMark(mark, event);
|
||||
gMarks.appendChild(markEl);
|
||||
}
|
||||
}
|
||||
|
||||
function paintMark(mark: Mark, event: TimelineEvent): SVGElement {
|
||||
// Wrap every mark in a <g> with data-* attributes so mount() can do
|
||||
// event-delegation off the top-level <svg> without per-mark listeners.
|
||||
const g = svg("g", {
|
||||
class: markClassName(mark),
|
||||
"data-event-index": mark.eventIndex,
|
||||
"data-kind": mark.kind,
|
||||
"data-status": mark.status,
|
||||
"data-lane": mark.laneId,
|
||||
"data-undated": mark.undated ? "1" : "0",
|
||||
"data-deadline-id": event.deadline_id || "",
|
||||
"data-appointment-id": event.appointment_id || "",
|
||||
"data-project-event-id": event.project_event_id || "",
|
||||
role: "img",
|
||||
tabindex: 0,
|
||||
});
|
||||
|
||||
// ARIA label so screen-readers can read each mark (§13).
|
||||
const title = svg("title");
|
||||
title.textContent = markAriaLabel(mark, event);
|
||||
g.appendChild(title);
|
||||
|
||||
// Generous invisible hit-target so dots are easy to click without
|
||||
// hunting (12px hit halo around a 7px standard radius).
|
||||
g.appendChild(svg("circle", {
|
||||
class: "chart-mark-hit",
|
||||
cx: mark.x,
|
||||
cy: mark.y,
|
||||
r: mark.radius + 6,
|
||||
fill: "transparent",
|
||||
}));
|
||||
|
||||
switch (mark.shape) {
|
||||
case "dot": {
|
||||
const c = svg("circle", {
|
||||
class: "chart-mark-dot",
|
||||
cx: mark.x,
|
||||
cy: mark.y,
|
||||
r: mark.radius,
|
||||
});
|
||||
g.appendChild(c);
|
||||
break;
|
||||
}
|
||||
case "diamond": {
|
||||
const r = mark.radius;
|
||||
g.appendChild(svg("polygon", {
|
||||
class: "chart-mark-diamond",
|
||||
points: `${mark.x},${mark.y - r} ${mark.x + r},${mark.y} ${mark.x},${mark.y + r} ${mark.x - r},${mark.y}`,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "hatched-dot": {
|
||||
g.appendChild(svg("circle", {
|
||||
class: "chart-mark-hatched",
|
||||
cx: mark.x,
|
||||
cy: mark.y,
|
||||
r: mark.radius,
|
||||
fill: "url(#chart-hatch)",
|
||||
}));
|
||||
break;
|
||||
}
|
||||
case "dashed-dot": {
|
||||
g.appendChild(svg("circle", {
|
||||
class: "chart-mark-dashed",
|
||||
cx: mark.x,
|
||||
cy: mark.y,
|
||||
r: mark.radius,
|
||||
}));
|
||||
break;
|
||||
}
|
||||
}
|
||||
return g;
|
||||
}
|
||||
|
||||
function markClassName(mark: Mark): string {
|
||||
const parts = ["chart-mark", `chart-mark--${mark.kind}`, `chart-mark--status-${mark.status}`];
|
||||
if (mark.undated) parts.push("chart-mark--undated");
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function markAriaLabel(mark: Mark, event: TimelineEvent): string {
|
||||
const dateStr = event.date ? event.date.slice(0, 10) : "Datum offen";
|
||||
return `${event.title} — ${event.kind} (${event.status}) — ${dateStr}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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;
|
||||
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 {
|
||||
/** Re-fetches the timeline and re-paints. */
|
||||
refresh: () => Promise<void>;
|
||||
/** Removes event listeners + tears down the SVG. */
|
||||
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 {
|
||||
events: TimelineEvent[];
|
||||
lanes: LaneInfo[];
|
||||
}
|
||||
|
||||
/**
|
||||
* mount builds a chart inside the given host element. The host's
|
||||
* dimensions drive the SVG width; height grows from the lane row count.
|
||||
* Returns a handle for refresh / dispose.
|
||||
*/
|
||||
export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle {
|
||||
host.classList.add("smart-timeline-chart-host");
|
||||
|
||||
// Empty / error placeholders.
|
||||
const messageEl = document.createElement("div");
|
||||
messageEl.className = "smart-timeline-chart-message";
|
||||
messageEl.textContent = "";
|
||||
host.appendChild(messageEl);
|
||||
|
||||
// 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", opts.palette ?? "default");
|
||||
svgEl.setAttribute("data-density", opts.density ?? "standard");
|
||||
host.appendChild(svgEl);
|
||||
|
||||
let lastEvents: TimelineEvent[] = [];
|
||||
let lastLayout: ChartLayout | null = null;
|
||||
|
||||
const todayISO = opts.todayISO ?? today();
|
||||
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 { from, to } = resolveRange();
|
||||
const viewport: ChartViewport = {
|
||||
width,
|
||||
height: 400,
|
||||
laneLabelWidth: 200,
|
||||
dateAxisHeight: 40,
|
||||
todayISO,
|
||||
rangeFrom: from,
|
||||
rangeTo: to,
|
||||
density: currentDensity,
|
||||
};
|
||||
// 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, renderEvents);
|
||||
svgEl.setAttribute("width", String(width));
|
||||
svgEl.setAttribute("height", String(chart.chartTop + chart.chartHeight + 32));
|
||||
}
|
||||
|
||||
let currentLanes: LaneInfo[] = [];
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
messageEl.textContent = "Lädt …";
|
||||
messageEl.classList.remove("smart-timeline-chart-message--error");
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${encodeURIComponent(opts.projectId)}/timeline`,
|
||||
);
|
||||
if (!resp.ok) {
|
||||
messageEl.textContent = "Timeline konnte nicht geladen werden.";
|
||||
messageEl.classList.add("smart-timeline-chart-message--error");
|
||||
return;
|
||||
}
|
||||
const body = await resp.json();
|
||||
// Defensive: tolerate the legacy []TimelineEvent shape (pre-Slice-4)
|
||||
// even though the Slice-4 envelope is the contract today.
|
||||
if (Array.isArray(body)) {
|
||||
lastEvents = body as TimelineEvent[];
|
||||
currentLanes = [];
|
||||
} else {
|
||||
const env = body as TimelineEnvelope;
|
||||
lastEvents = env.events ?? [];
|
||||
currentLanes = env.lanes ?? [];
|
||||
}
|
||||
if (lastEvents.length === 0) {
|
||||
messageEl.textContent = "Keine Ereignisse im gewählten Zeitraum.";
|
||||
} 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");
|
||||
}
|
||||
}
|
||||
|
||||
// Click delegation — read data-* attrs to deep-link.
|
||||
function handleClick(e: Event) {
|
||||
const target = e.target as Element | null;
|
||||
if (!target) return;
|
||||
const g = target.closest("g.chart-mark") as Element | null;
|
||||
if (!g) return;
|
||||
const indexAttr = g.getAttribute("data-event-index");
|
||||
if (!indexAttr) return;
|
||||
const idx = Number(indexAttr);
|
||||
const event = lastEvents[idx];
|
||||
if (!event) return;
|
||||
if (opts.onMarkClick) {
|
||||
opts.onMarkClick(event);
|
||||
return;
|
||||
}
|
||||
if (event.deadline_id) {
|
||||
window.location.href = `/deadlines/${encodeURIComponent(event.deadline_id)}`;
|
||||
} else if (event.appointment_id) {
|
||||
window.location.href = `/appointments/${encodeURIComponent(event.appointment_id)}`;
|
||||
}
|
||||
// Milestones + projected rows have no detail page today — no-op.
|
||||
}
|
||||
|
||||
// Resize handler — debounced.
|
||||
let resizeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
function handleResize() {
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
resizeTimer = setTimeout(() => {
|
||||
repaint();
|
||||
}, 120);
|
||||
}
|
||||
|
||||
svgEl.addEventListener("click", handleClick);
|
||||
window.addEventListener("resize", handleResize);
|
||||
|
||||
// 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);
|
||||
if (resizeTimer) clearTimeout(resizeTimer);
|
||||
if (svgEl.parentNode) svgEl.parentNode.removeChild(svgEl);
|
||||
if (messageEl.parentNode) messageEl.parentNode.removeChild(messageEl);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 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();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${dd}`;
|
||||
}
|
||||
|
||||
function shiftYears(iso: string, delta: number): string {
|
||||
const ms = parseISODay(iso);
|
||||
if (ms === null) return iso;
|
||||
const d = new Date(ms);
|
||||
return `${d.getUTCFullYear() + delta}-${String(d.getUTCMonth() + 1).padStart(2, "0")}-${String(d.getUTCDate()).padStart(2, "0")}`;
|
||||
}
|
||||
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);
|
||||
}
|
||||
@@ -72,6 +72,12 @@ export interface TimelineEvent {
|
||||
// Empty / missing is treated as "self" (the legacy single-lane case).
|
||||
lane_id?: string;
|
||||
bubble_up?: boolean;
|
||||
|
||||
// t-paliad-176 — underlying paliad.project_events.event_type for
|
||||
// milestone rows. Empty for deadline / appointment / projected rows.
|
||||
// Powers the FilterBar's project_event_kind chip on the Verlauf tab
|
||||
// (matched against KnownProjectEventKinds in filter_spec.go).
|
||||
project_event_type?: string;
|
||||
}
|
||||
|
||||
export interface LaneInfo {
|
||||
|
||||
@@ -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.
|
||||
|
||||
447
frontend/src/client/views/verfahrensablauf-core.ts
Normal file
447
frontend/src/client/views/verfahrensablauf-core.ts
Normal file
@@ -0,0 +1,447 @@
|
||||
// Shared core for Fristenrechner-style proceeding-timeline rendering.
|
||||
//
|
||||
// Both /tools/fristenrechner (deadline determination) and
|
||||
// /tools/verfahrensablauf (abstract browse — t-paliad-179 Slice 1) call
|
||||
// POST /api/tools/fristenrechner and paint the result with the same
|
||||
// renderers. The module is pure-functional: no shared mutable state, all
|
||||
// language / overrides / editability flow in through args so the two
|
||||
// pages can wire their own per-page concerns (Akte save, anchor edits,
|
||||
// Pathway B etc. on fristenrechner; variant chips, compare etc. coming
|
||||
// to verfahrensablauf in later slices) without leaking into each other.
|
||||
|
||||
import { t, tDyn, getLang } from "../i18n";
|
||||
|
||||
export interface AdjustmentHoliday {
|
||||
Date: string;
|
||||
Name: string;
|
||||
IsVacation: boolean;
|
||||
IsClosure: boolean;
|
||||
}
|
||||
|
||||
export interface AdjustmentReason {
|
||||
kind: "weekend" | "public_holiday" | "vacation";
|
||||
holidays?: AdjustmentHoliday[];
|
||||
vacation_name?: string;
|
||||
vacation_start?: string;
|
||||
vacation_end?: string;
|
||||
original_weekday?: string;
|
||||
}
|
||||
|
||||
export interface CalculatedDeadline {
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
party: string;
|
||||
isMandatory: boolean;
|
||||
ruleRef: string;
|
||||
legalSource?: string;
|
||||
notes?: string;
|
||||
notesEN?: string;
|
||||
dueDate: string;
|
||||
originalDate: string;
|
||||
wasAdjusted: boolean;
|
||||
adjustmentReason?: AdjustmentReason;
|
||||
isRootEvent: boolean;
|
||||
isCourtSet: boolean;
|
||||
isCourtSetIndirect?: boolean;
|
||||
isOptional?: boolean;
|
||||
isOverridden?: boolean;
|
||||
}
|
||||
|
||||
export interface DeadlineResponse {
|
||||
proceedingType: string;
|
||||
proceedingName: string;
|
||||
triggerDate: string;
|
||||
deadlines: CalculatedDeadline[];
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
id: string;
|
||||
code: string;
|
||||
nameDE: string;
|
||||
nameEN: string;
|
||||
country: string;
|
||||
regime?: string;
|
||||
courtType: string;
|
||||
}
|
||||
|
||||
export interface CalcParams {
|
||||
proceedingType: string;
|
||||
triggerDate: string;
|
||||
priorityDate?: string;
|
||||
flags?: string[];
|
||||
anchorOverrides?: Record<string, string>;
|
||||
courtId?: string;
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
claimant: "party-claimant",
|
||||
defendant: "party-defendant",
|
||||
court: "party-court",
|
||||
both: "party-both",
|
||||
};
|
||||
|
||||
// ─── small helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
export function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
export function escHtml(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
export function formatDate(dateStr: string): string {
|
||||
if (!dateStr) return "—";
|
||||
const d = new Date(dateStr + "T00:00:00");
|
||||
if (getLang() === "en") {
|
||||
const weekday = d.toLocaleDateString("en-US", { weekday: "short" });
|
||||
const yyyy = d.getFullYear();
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
return `${weekday}, ${yyyy}-${mm}-${dd}`;
|
||||
}
|
||||
return d.toLocaleDateString("de-DE", {
|
||||
weekday: "short",
|
||||
day: "2-digit",
|
||||
month: "2-digit",
|
||||
year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateSpan(startISO: string, endISO: string): string {
|
||||
const start = new Date(startISO + "T00:00:00");
|
||||
const end = new Date(endISO + "T00:00:00");
|
||||
if (getLang() === "en") {
|
||||
const fmt = (d: Date) => d.toLocaleDateString("en-US", { day: "numeric", month: "short" });
|
||||
return `${fmt(start)} – ${fmt(end)}`;
|
||||
}
|
||||
const fmt = (d: Date) => `${d.getDate()}.${d.getMonth() + 1}.`;
|
||||
return `${fmt(start)}–${fmt(end)}`;
|
||||
}
|
||||
|
||||
function localizeWeekday(en: string): string {
|
||||
if (en === "Saturday") return t("deadlines.adjusted.weekend.saturday");
|
||||
if (en === "Sunday") return t("deadlines.adjusted.weekend.sunday");
|
||||
return en;
|
||||
}
|
||||
|
||||
// Vacation names come straight from paliad.holidays (e.g. "UPC judicial
|
||||
// vacation"). Not translated — they're proper names of court-set closures.
|
||||
function localizeVacationName(name: string): string {
|
||||
return name;
|
||||
}
|
||||
|
||||
function renderAdjustmentReason(r: AdjustmentReason): string {
|
||||
if (r.kind === "vacation" && r.vacation_name && r.vacation_start && r.vacation_end) {
|
||||
const span = formatDateSpan(r.vacation_start, r.vacation_end);
|
||||
return tDyn("deadlines.adjusted.vacation")
|
||||
.replace("{name}", localizeVacationName(r.vacation_name))
|
||||
.replace("{span}", span);
|
||||
}
|
||||
if (r.kind === "public_holiday" && r.holidays && r.holidays.length > 0) {
|
||||
return tDyn("deadlines.adjusted.holiday").replace("{name}", r.holidays[0].Name);
|
||||
}
|
||||
if (r.kind === "weekend" && r.original_weekday) {
|
||||
return localizeWeekday(r.original_weekday);
|
||||
}
|
||||
return t("deadlines.adjusted.weekend");
|
||||
}
|
||||
|
||||
function formatAdjustedNote(dl: CalculatedDeadline): string {
|
||||
const arrow = `${formatDate(dl.originalDate)} → ${formatDate(dl.dueDate)}`;
|
||||
const reason = dl.adjustmentReason
|
||||
? renderAdjustmentReason(dl.adjustmentReason)
|
||||
: t("deadlines.adjusted.reason");
|
||||
if (getLang() === "en") {
|
||||
return `${t("deadlines.adjusted")} (${reason}): ${arrow}`;
|
||||
}
|
||||
return `${t("deadlines.adjusted")} wegen ${reason}: ${arrow}`;
|
||||
}
|
||||
|
||||
export function partyBadge(party: string): string {
|
||||
const cls = PARTY_CLASS[party] || "party-both";
|
||||
return `<span class="party-badge ${cls}">${tDyn("deadlines.party." + party)}</span>`;
|
||||
}
|
||||
|
||||
// ─── card + body renderers ────────────────────────────────────────────────
|
||||
|
||||
export interface CardOpts {
|
||||
showParty: boolean;
|
||||
// editable=true wires the click-to-edit affordance: data-rule-code,
|
||||
// role=button, tabindex, hover hint. Fristenrechner enables it; the
|
||||
// verfahrensablauf abstract-browse surface keeps editable=false because
|
||||
// there's no anchor-override state on that page in Slice 1.
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string {
|
||||
const wantsEditable = !!opts.editable;
|
||||
const editable = wantsEditable && !dl.isRootEvent && dl.code !== "";
|
||||
const overriddenClass = dl.isOverridden ? " timeline-date--overridden" : "";
|
||||
const editAttrs = editable
|
||||
? ` data-rule-code="${escAttr(dl.code)}" data-current-date="${escAttr(dl.dueDate)}" role="button" tabindex="0" title="${escAttr(t("deadlines.date.edit.hint"))}"`
|
||||
: "";
|
||||
const courtLabelKey = dl.isCourtSetIndirect
|
||||
? "deadlines.court.indirect"
|
||||
: "deadlines.court.set";
|
||||
const dateStr = dl.isCourtSet
|
||||
? `<span class="timeline-court-set frist-date-edit"${editAttrs}>${t(courtLabelKey)}</span>`
|
||||
: `<span class="timeline-date${overriddenClass} frist-date-edit"${editAttrs}>${formatDate(dl.dueDate)}</span>`;
|
||||
|
||||
const mandatoryBadge = dl.isMandatory
|
||||
? ""
|
||||
: '<span class="optional-badge">optional</span>';
|
||||
|
||||
const dlName = getLang() === "en" ? dl.nameEN : dl.name;
|
||||
|
||||
const adjustedNote = dl.wasAdjusted
|
||||
? `<div class="timeline-adjusted">⚠ ${formatAdjustedNote(dl)}</div>`
|
||||
: "";
|
||||
|
||||
const ruleRef = dl.ruleRef
|
||||
? `<span class="timeline-rule">${dl.ruleRef}</span>`
|
||||
: "";
|
||||
|
||||
const noteText = getLang() === "en" ? (dl.notesEN || dl.notes) : dl.notes;
|
||||
const notes = noteText
|
||||
? `<div class="timeline-notes">${noteText}</div>`
|
||||
: "";
|
||||
|
||||
const meta = (opts.showParty || ruleRef)
|
||||
? `<div class="timeline-meta">
|
||||
${opts.showParty ? partyBadge(dl.party) : ""}
|
||||
${ruleRef}
|
||||
</div>`
|
||||
: "";
|
||||
|
||||
return `<div class="timeline-item-header">
|
||||
<span class="timeline-name">
|
||||
${dlName}
|
||||
${mandatoryBadge}
|
||||
</span>
|
||||
${dateStr}
|
||||
</div>
|
||||
${meta}
|
||||
${adjustedNote}
|
||||
${notes}`;
|
||||
}
|
||||
|
||||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||||
let html = '<div class="timeline">';
|
||||
for (const dl of data.deadlines) {
|
||||
html += `
|
||||
<div class="timeline-item ${dl.isRootEvent ? "timeline-root" : ""}">
|
||||
<div class="timeline-dot-col">
|
||||
<div class="timeline-dot ${dl.isRootEvent ? "dot-root" : ""}"></div>
|
||||
<div class="timeline-line"></div>
|
||||
</div>
|
||||
<div class="timeline-content">
|
||||
${deadlineCardHtml(dl, opts)}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
}
|
||||
|
||||
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
|
||||
// (defendant). Each grid row shares a dueDate so same-day events line up
|
||||
// across columns; party=both renders in BOTH the Proactive and Reactive
|
||||
// cells of the row. Undated rows (Urteil etc.) trail the dated tail, each
|
||||
// keyed by sequence-order so e.g. Urteil precedes Berufungseinlegung.
|
||||
export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "showParty"> = {}): string {
|
||||
type Cell = CalculatedDeadline[];
|
||||
type Row = { proactive: Cell; court: Cell; reactive: Cell };
|
||||
|
||||
const UNSCHEDULED_PREFIX = "__unscheduled__";
|
||||
const rowsMap = new Map<string, Row>();
|
||||
const ensureRow = (key: string): Row => {
|
||||
let r = rowsMap.get(key);
|
||||
if (!r) {
|
||||
r = { proactive: [], court: [], reactive: [] };
|
||||
rowsMap.set(key, r);
|
||||
}
|
||||
return r;
|
||||
};
|
||||
|
||||
data.deadlines.forEach((dl, idx) => {
|
||||
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
|
||||
const row = ensureRow(key);
|
||||
switch (dl.party) {
|
||||
case "claimant":
|
||||
row.proactive.push(dl);
|
||||
break;
|
||||
case "defendant":
|
||||
row.reactive.push(dl);
|
||||
break;
|
||||
case "court":
|
||||
row.court.push(dl);
|
||||
break;
|
||||
case "both":
|
||||
row.proactive.push(dl);
|
||||
row.reactive.push(dl);
|
||||
break;
|
||||
default:
|
||||
row.court.push(dl);
|
||||
}
|
||||
});
|
||||
|
||||
const datedKeys: string[] = [];
|
||||
const unscheduledKeys: string[] = [];
|
||||
for (const k of rowsMap.keys()) {
|
||||
if (k.startsWith(UNSCHEDULED_PREFIX)) unscheduledKeys.push(k);
|
||||
else datedKeys.push(k);
|
||||
}
|
||||
datedKeys.sort();
|
||||
unscheduledKeys.sort();
|
||||
const keys = [...datedKeys, ...unscheduledKeys];
|
||||
|
||||
const cardOpts: CardOpts = { showParty: false, editable: opts.editable };
|
||||
|
||||
const renderCell = (items: CalculatedDeadline[]): string => {
|
||||
if (items.length === 0) {
|
||||
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
|
||||
}
|
||||
const cards = items
|
||||
.map((dl) => {
|
||||
const mirrorTag = dl.party === "both"
|
||||
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
|
||||
: "";
|
||||
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
|
||||
${deadlineCardHtml(dl, cardOpts)}
|
||||
${mirrorTag}
|
||||
</div>`;
|
||||
})
|
||||
.join("");
|
||||
return `<div class="fr-col-cell">${cards}</div>`;
|
||||
};
|
||||
|
||||
const headerCell = (label: string, cls: string) =>
|
||||
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
|
||||
|
||||
let html = '<div class="fr-columns-view">';
|
||||
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
|
||||
html += headerCell(t("deadlines.col.court"), "fr-col-court");
|
||||
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
|
||||
|
||||
for (const key of keys) {
|
||||
const row = rowsMap.get(key)!;
|
||||
html += renderCell(row.proactive);
|
||||
html += renderCell(row.court);
|
||||
html += renderCell(row.reactive);
|
||||
}
|
||||
html += "</div>";
|
||||
return html;
|
||||
}
|
||||
|
||||
// ─── calculate fetch wrapper ──────────────────────────────────────────────
|
||||
|
||||
export async function calculateDeadlines(params: CalcParams): Promise<DeadlineResponse | null> {
|
||||
if (!params.proceedingType || !params.triggerDate) return null;
|
||||
try {
|
||||
const resp = await fetch("/api/tools/fristenrechner", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
proceedingType: params.proceedingType,
|
||||
triggerDate: params.triggerDate,
|
||||
priorityDate: params.priorityDate || undefined,
|
||||
flags: params.flags && params.flags.length > 0 ? params.flags : undefined,
|
||||
anchorOverrides: params.anchorOverrides && Object.keys(params.anchorOverrides).length > 0
|
||||
? params.anchorOverrides
|
||||
: undefined,
|
||||
courtId: params.courtId || undefined,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
console.error("API error:", err);
|
||||
return null;
|
||||
}
|
||||
return (await resp.json()) as DeadlineResponse;
|
||||
} catch (e) {
|
||||
console.error("Fetch error:", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── court picker ─────────────────────────────────────────────────────────
|
||||
|
||||
const courtCache = new Map<string, CourtRow[]>();
|
||||
|
||||
export function courtTypesFor(proceedingType: string): string[] {
|
||||
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
|
||||
return ["UPC-CoA"];
|
||||
}
|
||||
if (proceedingType === "UPC_REV") {
|
||||
return ["UPC-CD", "UPC-LD"];
|
||||
}
|
||||
if (proceedingType.startsWith("UPC_")) {
|
||||
return ["UPC-LD"];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function defaultCourtFor(proceedingType: string): string {
|
||||
if (proceedingType === "UPC_APP" || proceedingType === "UPC_APP_ORDERS" || proceedingType === "UPC_COST_APPEAL") {
|
||||
return "upc-coa-luxembourg";
|
||||
}
|
||||
if (proceedingType === "UPC_REV") {
|
||||
return "upc-cd-paris";
|
||||
}
|
||||
return "upc-ld-muenchen";
|
||||
}
|
||||
|
||||
export async function fetchCourts(courtType: string): Promise<CourtRow[]> {
|
||||
if (courtCache.has(courtType)) return courtCache.get(courtType)!;
|
||||
try {
|
||||
const resp = await fetch(`/api/tools/courts?courtType=${encodeURIComponent(courtType)}`);
|
||||
if (!resp.ok) return [];
|
||||
const rows = (await resp.json()) as CourtRow[];
|
||||
courtCache.set(courtType, rows);
|
||||
return rows;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// populateCourtPicker fills the <select> for the proceeding's compatible
|
||||
// court types. The row + select IDs are passed in so each page can own
|
||||
// its own DOM scope. Visible only when the proceeding has ≥2 compatible
|
||||
// courts; otherwise hidden (server resolves the jurisdiction default).
|
||||
export async function populateCourtPicker(
|
||||
rowId: string,
|
||||
selectId: string,
|
||||
proceedingType: string,
|
||||
): Promise<void> {
|
||||
const row = document.getElementById(rowId);
|
||||
const select = document.getElementById(selectId) as HTMLSelectElement | null;
|
||||
if (!row || !select) return;
|
||||
|
||||
const types = courtTypesFor(proceedingType);
|
||||
if (types.length === 0) {
|
||||
row.style.display = "none";
|
||||
select.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const lists = await Promise.all(types.map((c) => fetchCourts(c)));
|
||||
const courts = lists.flat();
|
||||
if (courts.length <= 1) {
|
||||
row.style.display = "none";
|
||||
select.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
|
||||
const lang = getLang();
|
||||
const defaultID = defaultCourtFor(proceedingType);
|
||||
select.innerHTML = courts.map((c) => {
|
||||
const name = lang === "en" ? c.nameEN : c.nameDE;
|
||||
return `<option value="${escAttr(c.id)}"${c.id === defaultID ? " selected" : ""}>${escHtml(name)}</option>`;
|
||||
}).join("");
|
||||
row.style.display = "";
|
||||
}
|
||||
@@ -7,9 +7,10 @@ const ICON_CLOCK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" s
|
||||
const ICON_DOWNLOAD = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>';
|
||||
const ICON_LINK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>';
|
||||
const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>';
|
||||
// Open-book icon for the /tools/fristenrechner?path=a "Verfahrensablauf"
|
||||
// nav entry (t-paliad-168). Distinct from ICON_BOOK (Glossar, closed)
|
||||
// so the two affordances read as different at a glance.
|
||||
// Open-book icon for the /tools/verfahrensablauf "Verfahrensablauf"
|
||||
// nav entry (t-paliad-168 → t-paliad-179 Slice 1 split). Distinct from
|
||||
// ICON_BOOK (Glossar, closed) so the two affordances read as different
|
||||
// at a glance.
|
||||
const ICON_BOOK_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H2z"/><path d="M22 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>';
|
||||
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
|
||||
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
|
||||
@@ -139,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
|
||||
@@ -161,7 +174,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
Gerichte / Glossar), then content (Links / Downloads). */}
|
||||
{group("nav.group.werkzeuge", "Werkzeuge",
|
||||
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
|
||||
navItem("/tools/fristenrechner?path=a", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
|
||||
navItem("/tools/verfahrensablauf", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
|
||||
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
|
||||
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
|
||||
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +
|
||||
|
||||
@@ -207,20 +207,9 @@ export function renderFristenrechner(): string {
|
||||
Incoming — ein Ereignis hat eine Frist ausgelöst.
|
||||
</span>
|
||||
</button>
|
||||
{/* t-paliad-168 — third card: discoverable browse-/learn-mode
|
||||
entry. Drops directly into Pathway A (Verfahrensablauf
|
||||
wizard) with no save flow — mirrors the existing ad-hoc
|
||||
explore behaviour: timeline renders, save CTA stays
|
||||
disabled because there's no save intent. */}
|
||||
<button type="button" className="fristen-step2-card" data-action="browse" id="fristen-step2-browse">
|
||||
<span className="fristen-step2-card-icon" aria-hidden="true">📖</span>
|
||||
<span className="fristen-step2-card-title" data-i18n="deadlines.step2.browse.title">
|
||||
Verfahrensablauf einsehen
|
||||
</span>
|
||||
<span className="fristen-step2-card-desc" data-i18n="deadlines.step2.browse.desc">
|
||||
Browse / Learn — sehen, was wann passiert. Keine Frist eintragen.
|
||||
</span>
|
||||
</button>
|
||||
{/* t-paliad-179 Slice 1: the third "Verfahrensablauf
|
||||
einsehen" card retired — abstract-browse intent now
|
||||
owns its own route at /tools/verfahrensablauf. */}
|
||||
</div>
|
||||
<div className="fristen-step2-shortcut">
|
||||
<div className="fristen-pathway-fork-shortcut-label" data-i18n="deadlines.pathway.shortcut.label">
|
||||
|
||||
@@ -1442,6 +1442,7 @@ export type I18nKey =
|
||||
| "nav.akten"
|
||||
| "nav.caldav"
|
||||
| "nav.checklisten"
|
||||
| "nav.context.project_chart"
|
||||
| "nav.dashboard"
|
||||
| "nav.downloads"
|
||||
| "nav.einstellungen"
|
||||
@@ -1623,6 +1624,42 @@ export type I18nKey =
|
||||
| "projects.cards.show_all_levels"
|
||||
| "projects.cards.show_all_levels.hint"
|
||||
| "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"
|
||||
| "projects.chip.mine"
|
||||
@@ -1748,6 +1785,7 @@ export type I18nKey =
|
||||
| "projects.detail.smarttimeline.milestone.date"
|
||||
| "projects.detail.smarttimeline.milestone.description"
|
||||
| "projects.detail.smarttimeline.milestone.title"
|
||||
| "projects.detail.smarttimeline.open_chart"
|
||||
| "projects.detail.smarttimeline.section.future"
|
||||
| "projects.detail.smarttimeline.section.past"
|
||||
| "projects.detail.smarttimeline.section.undated"
|
||||
@@ -2013,6 +2051,9 @@ export type I18nKey =
|
||||
| "theme.toggle.cycle.light"
|
||||
| "theme.toggle.dark"
|
||||
| "theme.toggle.light"
|
||||
| "tools.verfahrensablauf.heading"
|
||||
| "tools.verfahrensablauf.subtitle"
|
||||
| "tools.verfahrensablauf.title"
|
||||
| "unit_role.attorney"
|
||||
| "unit_role.lead"
|
||||
| "unit_role.pa"
|
||||
@@ -2172,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";
|
||||
|
||||
172
frontend/src/projects-chart.tsx
Normal file
172
frontend/src/projects-chart.tsx
Normal file
@@ -0,0 +1,172 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-177 Slice 1 — Project Timeline / Chart standalone page.
|
||||
//
|
||||
// Pure shell: header / controls scaffold (inert chips for the
|
||||
// vertical-toggle, density and palette pickers, which Slice 3 wires
|
||||
// live) + a chart host that client/projects-chart.ts mounts the SVG
|
||||
// renderer into. Project metadata is loaded client-side so the same
|
||||
// dist/projects-chart.html serves every {id}.
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
|
||||
export function renderProjectsChart(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="projects.chart.title">Projekt-Chart — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/projects" />
|
||||
<BottomNav currentPath="/projects" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page smart-timeline-chart-page">
|
||||
<div className="container">
|
||||
<a
|
||||
id="projects-chart-back-link"
|
||||
href="/projects"
|
||||
className="back-link"
|
||||
data-i18n="projects.chart.back"
|
||||
>
|
||||
← Zurück zum Verlauf
|
||||
</a>
|
||||
|
||||
<div id="projects-chart-loading" className="entity-loading">
|
||||
<p data-i18n="projects.chart.loading">Lädt…</p>
|
||||
</div>
|
||||
|
||||
<div id="projects-chart-notfound" className="entity-empty" style="display:none">
|
||||
<p data-i18n="projects.chart.notfound">Projekt nicht gefunden oder keine Berechtigung.</p>
|
||||
</div>
|
||||
|
||||
<div id="projects-chart-body" style="display:none">
|
||||
<header className="smart-timeline-chart-header">
|
||||
<h1 id="projects-chart-title" />
|
||||
<span id="projects-chart-meta" className="smart-timeline-chart-meta" />
|
||||
</header>
|
||||
|
||||
<div className="smart-timeline-chart-controls" id="projects-chart-controls">
|
||||
{/* Slice 1: chips render inert. Slice 3 wires them to
|
||||
density / palette / zoom state. The presence keeps
|
||||
the surface visually stable when controls light up. */}
|
||||
<span className="chip-inert" data-i18n="projects.chart.control.layout.horizontal" title="Slice 3">
|
||||
Layout: Horizontal
|
||||
</span>
|
||||
<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="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="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="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" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/projects-chart.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -104,6 +104,19 @@ export function renderProjectsDetail(): string {
|
||||
<button type="button" className="btn-primary btn-cta-lime btn-small" id="smart-timeline-add-btn" data-i18n="projects.detail.smarttimeline.add.cta">
|
||||
+ Eintrag
|
||||
</button>
|
||||
{/* t-paliad-177 — link to the standalone /chart page.
|
||||
Opens in a new tab per design §8.1; the Verlauf
|
||||
embed itself stays vertical-DOM-only. */}
|
||||
<a
|
||||
id="smart-timeline-open-chart"
|
||||
className="btn-secondary btn-small"
|
||||
href="#"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
data-i18n="projects.detail.smarttimeline.open_chart"
|
||||
>
|
||||
Als Chart anzeigen ↗
|
||||
</a>
|
||||
</div>
|
||||
<div id="project-events-filter-bar" />
|
||||
<div id="project-smart-timeline" className="smart-timeline" />
|
||||
|
||||
@@ -14172,3 +14172,491 @@ dialog.quick-add-sheet::backdrop {
|
||||
display: block;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
Smart Timeline Chart (t-paliad-177 Slice 1)
|
||||
Horizontal SVG Gantt renderer mounted on /projects/{id}/chart.
|
||||
Token surface lets future palette / density slices override
|
||||
colour and lane height purely via CSS-var swap — see
|
||||
docs/design-project-chart-2026-05-09.md §5 + §6.
|
||||
============================================================ */
|
||||
.smart-timeline-chart-page {
|
||||
padding: 1rem 0 3rem;
|
||||
}
|
||||
.smart-timeline-chart-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem 1.25rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.smart-timeline-chart-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
.smart-timeline-chart-meta {
|
||||
color: var(--color-text-muted, #777);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.smart-timeline-chart-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
.smart-timeline-chart-controls .chip-inert {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-subtle, #f7f7f7);
|
||||
color: var(--color-text-muted, #777);
|
||||
font-size: 0.85rem;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.smart-timeline-chart-host {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 8px;
|
||||
background: var(--chart-bg, var(--color-bg, #fff));
|
||||
padding: 0;
|
||||
min-height: 200px;
|
||||
}
|
||||
.smart-timeline-chart-message {
|
||||
padding: 2rem 1.5rem;
|
||||
text-align: center;
|
||||
color: var(--color-text-muted, #777);
|
||||
}
|
||||
.smart-timeline-chart-message--error {
|
||||
color: #c0392b;
|
||||
}
|
||||
.smart-timeline-chart {
|
||||
/* Default palette tokens — kept here so Slice 3 can swap them via
|
||||
[data-palette="..."] selectors without touching the renderer.
|
||||
Reference --color-* family so dark mode flips for free. */
|
||||
--chart-mark-deadline: var(--color-accent, #c6f41c);
|
||||
--chart-mark-appointment: #f5a623;
|
||||
--chart-mark-milestone: var(--hlc-midnight, #1a2233);
|
||||
--chart-mark-projected: var(--color-text-subtle, #999);
|
||||
--chart-mark-overdue: #d62828;
|
||||
--chart-mark-done: var(--color-accent, #c6f41c);
|
||||
--chart-today-rule: var(--color-accent, #c6f41c);
|
||||
--chart-grid-line: var(--color-border, #e0e0e0);
|
||||
--chart-lane-label: var(--color-text-muted, #777);
|
||||
--chart-tick-label: var(--color-text-muted, #777);
|
||||
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: var(--chart-mark-projected);
|
||||
font-family: inherit;
|
||||
}
|
||||
.smart-timeline-chart .chart-tick {
|
||||
stroke: var(--chart-grid-line);
|
||||
stroke-width: 1;
|
||||
stroke-dasharray: 2 3;
|
||||
}
|
||||
.smart-timeline-chart .chart-tick--year {
|
||||
stroke: var(--chart-grid-line);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: none;
|
||||
}
|
||||
.smart-timeline-chart .chart-tick-label {
|
||||
font-size: 0.75rem;
|
||||
fill: var(--chart-tick-label);
|
||||
}
|
||||
.smart-timeline-chart .chart-lane-separator {
|
||||
stroke: var(--chart-grid-line);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.smart-timeline-chart .chart-lane-label {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
fill: var(--chart-lane-label);
|
||||
}
|
||||
.smart-timeline-chart .chart-today-rule {
|
||||
stroke: var(--chart-today-rule);
|
||||
stroke-width: 2;
|
||||
}
|
||||
.smart-timeline-chart .chart-today-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
fill: var(--chart-today-rule);
|
||||
}
|
||||
.smart-timeline-chart .chart-mark {
|
||||
cursor: pointer;
|
||||
}
|
||||
.smart-timeline-chart .chart-mark:focus-visible {
|
||||
outline: 2px solid var(--color-accent, #c6f41c);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.smart-timeline-chart .chart-mark-dot {
|
||||
fill: var(--chart-mark-deadline);
|
||||
stroke: var(--chart-mark-deadline);
|
||||
stroke-width: 1.5;
|
||||
}
|
||||
.smart-timeline-chart .chart-mark--deadline.chart-mark--status-open .chart-mark-dot {
|
||||
fill: var(--chart-bg, #fff);
|
||||
stroke: var(--chart-mark-deadline);
|
||||
}
|
||||
.smart-timeline-chart .chart-mark--deadline.chart-mark--status-overdue .chart-mark-dot {
|
||||
fill: var(--chart-mark-overdue);
|
||||
stroke: var(--chart-mark-overdue);
|
||||
}
|
||||
.smart-timeline-chart .chart-mark--deadline.chart-mark--status-done .chart-mark-dot {
|
||||
fill: var(--chart-mark-done);
|
||||
stroke: var(--chart-mark-done);
|
||||
}
|
||||
.smart-timeline-chart .chart-mark--appointment .chart-mark-dot {
|
||||
fill: var(--chart-mark-appointment);
|
||||
stroke: var(--chart-mark-appointment);
|
||||
}
|
||||
.smart-timeline-chart .chart-mark-diamond {
|
||||
fill: var(--chart-mark-milestone);
|
||||
stroke: var(--chart-mark-milestone);
|
||||
stroke-width: 1;
|
||||
}
|
||||
.smart-timeline-chart .chart-mark-hatched {
|
||||
color: var(--chart-mark-projected); /* drives the pattern stroke via currentColor */
|
||||
stroke: var(--chart-mark-projected);
|
||||
stroke-width: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.smart-timeline-chart .chart-mark--projected.chart-mark--status-predicted_overdue .chart-mark-hatched {
|
||||
color: var(--chart-mark-overdue);
|
||||
stroke: var(--chart-mark-overdue);
|
||||
}
|
||||
.smart-timeline-chart .chart-mark-dashed {
|
||||
fill: none;
|
||||
stroke: var(--chart-mark-projected);
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 3 2;
|
||||
}
|
||||
.smart-timeline-chart .chart-mark--undated .chart-mark-dot,
|
||||
.smart-timeline-chart .chart-mark--undated .chart-mark-diamond,
|
||||
.smart-timeline-chart .chart-mark--undated .chart-mark-hatched,
|
||||
.smart-timeline-chart .chart-mark--undated .chart-mark-dashed {
|
||||
opacity: 0.55;
|
||||
}
|
||||
.smart-timeline-chart-undated-hint {
|
||||
margin-top: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #777);
|
||||
}
|
||||
|
||||
/* Mobile — design §9: force a vertical-only fallback notice below 640px
|
||||
instead of trying to render horizontal Gantt at phone width. Slice 3
|
||||
wires the actual layout flip; Slice 1 just nudges the user. */
|
||||
@media (max-width: 640px) {
|
||||
.smart-timeline-chart-host {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
207
frontend/src/verfahrensablauf.tsx
Normal file
207
frontend/src/verfahrensablauf.tsx
Normal file
@@ -0,0 +1,207 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Slice 1 (t-paliad-179) — the dedicated abstract-browse surface for
|
||||
// procedural shape. Same backend (POST /api/tools/fristenrechner) +
|
||||
// same renderer module (./client/views/verfahrensablauf-core) as
|
||||
// /tools/fristenrechner; this page strips the Step 1 Akte picker /
|
||||
// Step 2 cards / Pathway A wizard / Pathway B cascade / save modal,
|
||||
// leaving just: proceeding-type tile picker + trigger date + court
|
||||
// picker + result panel. Variant chips, lane view and compare arrive in
|
||||
// Slices 2-4.
|
||||
|
||||
interface ProceedingDef {
|
||||
code: string;
|
||||
i18nKey: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function proceedingBtn(p: ProceedingDef): string {
|
||||
return (
|
||||
<button type="button" className="proceeding-btn" data-code={p.code}>
|
||||
<strong data-i18n={p.i18nKey}>{p.name}</strong>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const UPC_TYPES: ProceedingDef[] = [
|
||||
{ code: "UPC_INF", i18nKey: "deadlines.upc_inf", name: "Verletzungsverfahren" },
|
||||
{ code: "UPC_REV", i18nKey: "deadlines.upc_rev", name: "Nichtigkeitsklage" },
|
||||
{ code: "UPC_PI", i18nKey: "deadlines.upc_pi", name: "Einstw. Maßnahmen" },
|
||||
{ code: "UPC_APP", i18nKey: "deadlines.upc_app", name: "Berufung" },
|
||||
{ code: "UPC_DAMAGES", i18nKey: "deadlines.upc_damages", name: "Schadensbemessung" },
|
||||
{ code: "UPC_DISCOVERY", i18nKey: "deadlines.upc_discovery", name: "Bucheinsicht" },
|
||||
{ code: "UPC_COST_APPEAL", i18nKey: "deadlines.upc_cost_appeal", name: "Berufung Kosten" },
|
||||
{ code: "UPC_APP_ORDERS", i18nKey: "deadlines.upc_app_orders", name: "Berufung Anordnungen" },
|
||||
];
|
||||
|
||||
const DE_TYPES: ProceedingDef[] = [
|
||||
{ code: "DE_INF", i18nKey: "deadlines.de_inf", name: "Verletzungsklage (LG)" },
|
||||
{ code: "DE_INF_OLG", i18nKey: "deadlines.de_inf_olg", name: "Berufung OLG" },
|
||||
{ code: "DE_INF_BGH", i18nKey: "deadlines.de_inf_bgh", name: "Revision/NZB BGH" },
|
||||
{ code: "DE_NULL", i18nKey: "deadlines.de_null", name: "Nichtigkeitsverfahren" },
|
||||
{ code: "DE_NULL_BGH", i18nKey: "deadlines.de_null_bgh", name: "Berufung BGH (Nichtigk.)" },
|
||||
];
|
||||
|
||||
const EPA_TYPES: ProceedingDef[] = [
|
||||
{ code: "EPA_OPP", i18nKey: "deadlines.epa_opp", name: "Einspruchsverfahren" },
|
||||
{ code: "EPA_APP", i18nKey: "deadlines.epa_app", name: "Beschwerdeverfahren" },
|
||||
{ code: "EP_GRANT", i18nKey: "deadlines.ep_grant", name: "EP-Erteilungsverfahren" },
|
||||
];
|
||||
|
||||
const DPMA_TYPES: ProceedingDef[] = [
|
||||
{ code: "DPMA_OPP", i18nKey: "deadlines.dpma_opp", name: "Einspruch DPMA" },
|
||||
{ code: "DPMA_BPATG_BESCHWERDE", i18nKey: "deadlines.dpma_bpatg_beschwerde", name: "Beschwerde BPatG (DPMA)" },
|
||||
{ code: "DPMA_BGH_RB", i18nKey: "deadlines.dpma_bgh_rb", name: "Rechtsbeschwerde BGH" },
|
||||
];
|
||||
|
||||
export function renderVerfahrensablauf(): string {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="tools.verfahrensablauf.title">Verfahrensablauf — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/tools/verfahrensablauf" />
|
||||
<BottomNav currentPath="/tools/verfahrensablauf" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="tools.verfahrensablauf.heading">Verfahrensablauf</h1>
|
||||
<p className="tool-subtitle" data-i18n="tools.verfahrensablauf.subtitle">
|
||||
Typischen Verfahrensablauf einsehen — Verfahrensart wählen, Datum optional setzen.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Verfahrensart picker (single-tile mode — same DOM ids as
|
||||
/tools/fristenrechner so the shared renderer module and
|
||||
court-picker primitives bind without parameterisation). */}
|
||||
<div className="fristen-wizard" id="verfahrensablauf-wizard" data-mode="procedure">
|
||||
<div className="wizard-step" id="step-1">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">1</span>
|
||||
<span data-i18n="deadlines.step1">Verfahrensart wählen</span>
|
||||
</h3>
|
||||
|
||||
<div className="proceeding-group" data-forum="upc">
|
||||
<h4 data-i18n="deadlines.upc">UPC</h4>
|
||||
<div className="proceeding-btns">
|
||||
{UPC_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="de">
|
||||
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
|
||||
<div className="proceeding-btns">
|
||||
{DE_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="epa">
|
||||
<h4 data-i18n="deadlines.epa">EPA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{EPA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="dpma">
|
||||
<h4 data-i18n="deadlines.dpma">DPMA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{DPMA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
|
||||
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
|
||||
<strong className="proceeding-summary-name" id="proceeding-summary-name">—</strong>
|
||||
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
|
||||
data-i18n="deadlines.proceeding.reselect">
|
||||
Anderes Verfahren wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-2" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">2</span>
|
||||
<span data-i18n="deadlines.step2">Ausgangsdatum eingeben</span>
|
||||
</h3>
|
||||
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="trigger-event" className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</label>
|
||||
<span id="trigger-event" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
|
||||
<input type="date" id="trigger-date" className="date-input" value={today} />
|
||||
</div>
|
||||
<div className="date-field-row" id="court-picker-row" style="display:none">
|
||||
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
|
||||
<select id="court-picker" className="date-input"></select>
|
||||
</div>
|
||||
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
|
||||
Fristen berechnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-3" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">3</span>
|
||||
<span data-i18n="deadlines.step3">Ergebnis</span>
|
||||
</h3>
|
||||
|
||||
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
|
||||
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="columns" checked />
|
||||
<span data-i18n="deadlines.view.columns">Spalten</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
</div>
|
||||
|
||||
<div className="fristen-result-actions">
|
||||
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
|
||||
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 6 2 18 2 18 9"></polyline>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
|
||||
<rect x="6" y="14" width="12" height="8"></rect>
|
||||
</svg>
|
||||
<span data-i18n="deadlines.print">Drucken</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/verfahrensablauf.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
74
internal/handlers/chart_pages.go
Normal file
74
internal/handlers/chart_pages.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// t-paliad-177 — Project Timeline / Chart standalone page.
|
||||
//
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
@@ -9,10 +9,29 @@ import (
|
||||
)
|
||||
|
||||
// Fristenrechner page handler: serves the static HTML. No DB dependency.
|
||||
//
|
||||
// Back-compat: the pre-split sidebar entry for "Verfahrensablauf" pointed at
|
||||
// /tools/fristenrechner?path=a. After the t-paliad-179 split, that landing is
|
||||
// owned by /tools/verfahrensablauf. A naked ?path=a (no Akte context — i.e.
|
||||
// no ?project=) is the bookmarked-legacy-entry case → 302 to the new route.
|
||||
// ?project=<uuid>&path=a is the Akte-mode internal wizard pathway and stays
|
||||
// on /tools/fristenrechner so the wizard state survives a refresh.
|
||||
func handleFristenrechnerPage(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
if q.Get("path") == "a" && q.Get("project") == "" {
|
||||
http.Redirect(w, r, "/tools/verfahrensablauf", http.StatusFound)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, "dist/fristenrechner.html")
|
||||
}
|
||||
|
||||
// Verfahrensablauf page handler (t-paliad-179 Slice 1): the dedicated
|
||||
// abstract-browse surface for procedural shape. No DB dependency — the page
|
||||
// shell is static HTML; the calculator API still drives the timeline render.
|
||||
func handleVerfahrensablaufPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/verfahrensablauf.html")
|
||||
}
|
||||
|
||||
// POST /api/tools/fristenrechner — calculate the UI timeline for a proceeding.
|
||||
//
|
||||
// Phase C: routes through FristenrechnerService which pulls rules from
|
||||
|
||||
@@ -160,6 +160,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /tools/kostenrechner", handleKostenrechnerPage)
|
||||
protected.HandleFunc("POST /api/tools/kostenrechner", handleKostenrechnerAPI)
|
||||
protected.HandleFunc("GET /tools/fristenrechner", handleFristenrechnerPage)
|
||||
protected.HandleFunc("GET /tools/verfahrensablauf", handleVerfahrensablaufPage)
|
||||
protected.HandleFunc("POST /api/tools/fristenrechner", handleFristenrechnerAPI)
|
||||
protected.HandleFunc("POST /api/tools/fristenrechner/calculate-rule", handleFristenrechnerCalculateRule)
|
||||
protected.HandleFunc("GET /api/tools/proceeding-types", handleProceedingTypes)
|
||||
@@ -222,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)
|
||||
@@ -359,6 +362,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /projects/{id}/notes", gateOnboarded(handleProjectsDetailPage))
|
||||
protected.HandleFunc("GET /projects/{id}/checklists", gateOnboarded(handleProjectsDetailPage))
|
||||
protected.HandleFunc("GET /projects/{id}/team", gateOnboarded(handleProjectsDetailPage))
|
||||
// t-paliad-177 — standalone Project Timeline / Chart page (Slice 1).
|
||||
// Horizontal SVG renderer mounted client-side; reuses the existing
|
||||
// /api/projects/{id}/timeline JSON endpoint for data.
|
||||
protected.HandleFunc("GET /projects/{id}/chart", gateOnboarded(handleProjectsChartPage))
|
||||
protected.HandleFunc("GET /projects/{id}/deadlines/new", gateOnboarded(handleDeadlinesNewPage))
|
||||
protected.HandleFunc("GET /projects/{id}/appointments/new", gateOnboarded(handleAppointmentsNewPage))
|
||||
|
||||
|
||||
@@ -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"}
|
||||
|
||||
83
internal/handlers/verfahrensablauf_test.go
Normal file
83
internal/handlers/verfahrensablauf_test.go
Normal file
@@ -0,0 +1,83 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// /tools/fristenrechner?path=a was the pre-split sidebar entry for the
|
||||
// "Verfahrensablauf" surface. After t-paliad-179 Slice 1 that intent
|
||||
// owns its own /tools/verfahrensablauf route — so a naked ?path=a hit
|
||||
// must 302 to the new URL to preserve bookmarked legacy links.
|
||||
//
|
||||
// The Akte-mode internal wizard pathway (?project=<uuid>&path=a) is
|
||||
// NOT a top-level entry — it's wizard state set by client-side
|
||||
// history.replaceState. That URL must keep serving the fristenrechner
|
||||
// shell so a mid-wizard refresh doesn't bounce away.
|
||||
func TestHandleFristenrechnerPage_LegacyPathARedirect(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
path string
|
||||
wantStatus int
|
||||
wantLoc string
|
||||
}{
|
||||
{
|
||||
name: "naked path=a → redirect",
|
||||
path: "/tools/fristenrechner?path=a",
|
||||
wantStatus: http.StatusFound,
|
||||
wantLoc: "/tools/verfahrensablauf",
|
||||
},
|
||||
{
|
||||
name: "path=a with project= → no redirect (Akte-mode wizard)",
|
||||
path: "/tools/fristenrechner?project=abc-123&path=a",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "no path param → no redirect",
|
||||
path: "/tools/fristenrechner",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
{
|
||||
name: "path=b → no redirect (Pathway B stays)",
|
||||
path: "/tools/fristenrechner?path=b",
|
||||
wantStatus: http.StatusOK,
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, tc.path, nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleFristenrechnerPage(w, req)
|
||||
if w.Code != tc.wantStatus {
|
||||
// http.ServeFile may write 404 if dist/fristenrechner.html
|
||||
// is missing under `go test` (CI runs without a frontend
|
||||
// build). We only care that we did NOT redirect in those
|
||||
// cases — collapse 200 and 404 into "not a redirect".
|
||||
if tc.wantStatus == http.StatusOK && w.Code != http.StatusFound {
|
||||
return
|
||||
}
|
||||
t.Fatalf("status = %d, want %d", w.Code, tc.wantStatus)
|
||||
}
|
||||
if tc.wantLoc != "" {
|
||||
if got := w.Header().Get("Location"); got != tc.wantLoc {
|
||||
t.Fatalf("Location = %q, want %q", got, tc.wantLoc)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The new /tools/verfahrensablauf route registers as a 1-liner page
|
||||
// handler that ServeFiles dist/verfahrensablauf.html. We assert the
|
||||
// handler does NOT redirect — if the dist artefact is missing under
|
||||
// `go test`, ServeFile may return 404, but it must never return a 3xx.
|
||||
func TestHandleVerfahrensablaufPage_NoRedirect(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/tools/verfahrensablauf", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handleVerfahrensablaufPage(w, req)
|
||||
if w.Code >= 300 && w.Code < 400 {
|
||||
t.Fatalf("verfahrensablauf must not redirect; got %d → %s",
|
||||
w.Code, w.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -238,6 +238,95 @@ func TestProjectionService_LevelAggregation_Live(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Patent-level: direct_only collapses to single 'self' lane (m/paliad#33)", func(t *testing.T) {
|
||||
rows, meta, err := projection.For(ctx, userID, patentID, ProjectionOpts{DirectOnly: true})
|
||||
if err != nil {
|
||||
t.Fatalf("For patent direct_only: %v", err)
|
||||
}
|
||||
// Lanes should NOT include child cases — just one "self" entry
|
||||
// pointing at the patent itself.
|
||||
if len(meta.Lanes) != 1 || meta.Lanes[0].ID != "self" {
|
||||
t.Errorf("DirectOnly Lanes = %v, want a single 'self' lane", meta.Lanes)
|
||||
}
|
||||
if len(meta.Lanes) > 0 && meta.Lanes[0].ProjectID != patentID.String() {
|
||||
t.Errorf("self lane ProjectID = %q, want patent id", meta.Lanes[0].ProjectID)
|
||||
}
|
||||
// Case-A's deadline / milestones must NOT surface — they belong to
|
||||
// the case subtree and direct_only excludes them.
|
||||
for _, r := range rows {
|
||||
if r.DeadlineID != nil && *r.DeadlineID == deadlineA {
|
||||
t.Errorf("Case-A deadline should NOT surface at Patent level with direct_only=true (got %v)", r)
|
||||
}
|
||||
if r.ProjectEventID != nil && *r.ProjectEventID == bubbledMilestoneA {
|
||||
t.Errorf("Case-A bubbled milestone should NOT surface at Patent level with direct_only=true")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Case-level: direct_only drops CCR sub-project lane", func(t *testing.T) {
|
||||
// Seed a CCR child of Case-A so the default (subtree) path
|
||||
// includes a "counterclaim:<id>" lane and direct_only excludes it.
|
||||
ccrID := uuid.New()
|
||||
ccrMilestoneID := uuid.New()
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, parent_id, counterclaim_of, path, title, status, created_by)
|
||||
VALUES ($1, 'case', $2, $2, $2::text || '.' || $1::text, 'Case A — CCR', 'active', $3)`,
|
||||
ccrID, caseAID, userID); err != nil {
|
||||
t.Fatalf("seed CCR: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
|
||||
VALUES ($1, $2, 'lead', 'lead', false, $2)`,
|
||||
ccrID, userID); err != nil {
|
||||
t.Fatalf("seed CCR team: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_events
|
||||
(id, project_id, event_type, title, event_date, created_by, metadata,
|
||||
created_at, updated_at, timeline_kind)
|
||||
VALUES ($1, $2, 'custom_milestone', 'CCR-side note', $3, $4,
|
||||
'{}'::jsonb, $5, $5, 'custom_milestone')`,
|
||||
ccrMilestoneID, ccrID, now.AddDate(0, 0, -1), userID, now); err != nil {
|
||||
t.Fatalf("seed CCR milestone: %v", err)
|
||||
}
|
||||
defer func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE id = $1`, ccrMilestoneID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, ccrID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, ccrID)
|
||||
}()
|
||||
|
||||
// Default (subtree) path: Case-A timeline carries both "self" +
|
||||
// "counterclaim:<ccrID>" lanes.
|
||||
_, defaultMeta, err := projection.For(ctx, userID, caseAID, ProjectionOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("For caseA default: %v", err)
|
||||
}
|
||||
var sawCCRLane bool
|
||||
for _, l := range defaultMeta.Lanes {
|
||||
if l.ID == "counterclaim:"+ccrID.String() {
|
||||
sawCCRLane = true
|
||||
}
|
||||
}
|
||||
if !sawCCRLane {
|
||||
t.Fatalf("default Case-A meta.Lanes should include the CCR child: %v", defaultMeta.Lanes)
|
||||
}
|
||||
|
||||
// Direct-only path: only the "self" lane survives, CCR milestones
|
||||
// are excluded.
|
||||
rows, directMeta, err := projection.For(ctx, userID, caseAID, ProjectionOpts{DirectOnly: true})
|
||||
if err != nil {
|
||||
t.Fatalf("For caseA direct_only: %v", err)
|
||||
}
|
||||
if len(directMeta.Lanes) != 1 || directMeta.Lanes[0].ID != "self" {
|
||||
t.Errorf("direct_only Lanes = %v, want only 'self'", directMeta.Lanes)
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.ProjectEventID != nil && *r.ProjectEventID == ccrMilestoneID {
|
||||
t.Errorf("CCR milestone should NOT surface at Case-A with direct_only=true")
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Patent-level: bubble_up false → row dropped", func(t *testing.T) {
|
||||
// Re-write the regular milestone with bubble_up=true and confirm
|
||||
// it surfaces. Then revert.
|
||||
|
||||
@@ -116,6 +116,13 @@ type TimelineEvent struct {
|
||||
// one column per lane and groups rows by LaneID.
|
||||
LaneID string `json:"lane_id,omitempty"`
|
||||
|
||||
// ProjectEventType carries the underlying paliad.project_events.event_type
|
||||
// for milestone rows (t-paliad-176). Empty for deadline / appointment /
|
||||
// projected rows. The FilterBar's project_event_kind chip narrows the
|
||||
// rendered list against this field; KnownProjectEventKinds in
|
||||
// internal/services/filter_spec.go is the canonical vocabulary.
|
||||
ProjectEventType string `json:"project_event_type,omitempty"`
|
||||
|
||||
// BubbleUp signals that a project_event milestone is marked to
|
||||
// bubble up to higher-level SmartTimelines (t-paliad-175 §5.3 + §7.2).
|
||||
// Read from metadata.bubble_up on the underlying paliad.project_events
|
||||
@@ -298,6 +305,17 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
|
||||
|
||||
policy := levelPolicy(proj.Type)
|
||||
|
||||
// DirectOnly collapses every level to a single-lane "self" view —
|
||||
// no CCR sub-project lanes (Case level), no parent_context lane (CCR
|
||||
// child viewpoint), no child-case / child-patent / child-litigation
|
||||
// lanes (Patent / Litigation / Client levels). The level-policy
|
||||
// kind/status filter still applies at higher levels so that, e.g., a
|
||||
// Patent-level direct view doesn't suddenly leak off_script custom
|
||||
// milestones that the aggregated view filters out (t-paliad-176).
|
||||
if opts.DirectOnly {
|
||||
return s.forDirectSelfOnly(ctx, userID, proj, policy, opts, meta)
|
||||
}
|
||||
|
||||
// Patent / Litigation / Client levels — lane-aggregated rendering.
|
||||
if policy.LaneAxis != "self_plus_ccr" {
|
||||
return s.forAggregatedLevel(ctx, userID, proj, policy, opts, meta)
|
||||
@@ -309,6 +327,51 @@ func (s *ProjectionService) For(ctx context.Context, userID, projectID uuid.UUID
|
||||
return s.forCaseLevel(ctx, userID, proj, opts, meta)
|
||||
}
|
||||
|
||||
// forDirectSelfOnly handles every level when DirectOnly is requested
|
||||
// (m/paliad#33). Renders this project's own actuals + (at Case level)
|
||||
// projection only — no CCR / parent_context / child-case lanes. The
|
||||
// policy's kind/status filter still applies at higher levels so the
|
||||
// "Nur direkt" Patent view honours the same milestone-only contract as
|
||||
// the aggregated default. Produces a single "self" lane.
|
||||
func (s *ProjectionService) forDirectSelfOnly(
|
||||
ctx context.Context,
|
||||
userID uuid.UUID,
|
||||
proj *models.Project,
|
||||
policy LevelPolicy,
|
||||
opts ProjectionOpts,
|
||||
meta ProjectionMeta,
|
||||
) ([]TimelineEvent, ProjectionMeta, error) {
|
||||
includeProjection := policy.LaneAxis == "self_plus_ccr"
|
||||
rows, mainMeta, err := s.loadProjectTrack(ctx, userID, proj, opts, "parent", nil, includeProjection)
|
||||
if err != nil {
|
||||
return nil, meta, err
|
||||
}
|
||||
meta.HasProjection = mainMeta.HasProjection
|
||||
meta.ProjectedTotal = mainMeta.ProjectedTotal
|
||||
meta.ProjectedShown = mainMeta.ProjectedShown
|
||||
meta.PredictedOverdue = mainMeta.PredictedOverdue
|
||||
|
||||
allowKind := stringSet(policy.Kinds)
|
||||
allowStatus := stringSet(policy.Statuses)
|
||||
out := make([]TimelineEvent, 0, len(rows))
|
||||
for i := range rows {
|
||||
row := rows[i]
|
||||
row.LaneID = "self"
|
||||
if !rowSurvivesPolicy(row, allowKind, allowStatus) {
|
||||
continue
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
meta.Lanes = append(meta.Lanes, LaneInfo{
|
||||
ID: "self",
|
||||
Label: proj.Title,
|
||||
ProjectID: proj.ID.String(),
|
||||
})
|
||||
|
||||
sortTimeline(out)
|
||||
return out, meta, nil
|
||||
}
|
||||
|
||||
// forCaseLevel runs the original Slice-1-through-3 flow: parent track +
|
||||
// CCR sub-projects (when this project is the parent) or parent_context
|
||||
// (when this project is a CCR child). Lanes mirror tracks one-for-one
|
||||
@@ -1100,6 +1163,9 @@ func (s *ProjectionService) listProjectEvents(ctx context.Context, userID, proje
|
||||
ProjectEventID: &r.ID,
|
||||
BubbleUp: extractBubbleUp(r.Metadata, r.EventType, r.TimelineKind),
|
||||
}
|
||||
if r.EventType != nil {
|
||||
ev.ProjectEventType = *r.EventType
|
||||
}
|
||||
if r.Description != nil {
|
||||
ev.Description = *r.Description
|
||||
}
|
||||
|
||||
@@ -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