Compare commits
8 Commits
mai/noethe
...
mai/pauli/
| Author | SHA1 | Date | |
|---|---|---|---|
| 7c4bc39115 | |||
| adf377c2ca | |||
| 3ba5727deb | |||
| d8f7745f86 | |||
| 98a51faa66 | |||
| b24063bee1 | |||
| d1314a46f9 | |||
| 968b0bc2da |
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.**
|
||||
@@ -1167,6 +1167,23 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.chart.control.density.standard": "Dichte: Standard",
|
||||
"projects.chart.control.palette.default": "Palette: Standard",
|
||||
"projects.chart.control.export.soon": "Export \u2193 (Slice 2)",
|
||||
"projects.chart.control.palette.label": "Palette:",
|
||||
"projects.chart.palette.default": "Standard",
|
||||
"projects.chart.palette.kind_coded": "Nach Ereignistyp",
|
||||
"projects.chart.palette.track_coded": "Nach Spur",
|
||||
"projects.chart.palette.high_contrast": "Hoher Kontrast",
|
||||
"projects.chart.palette.print": "Druck (S/W)",
|
||||
"projects.chart.control.density.label": "Dichte:",
|
||||
"projects.chart.density.compact": "Kompakt",
|
||||
"projects.chart.density.standard": "Standard",
|
||||
"projects.chart.density.spacious": "Großzügig",
|
||||
"projects.chart.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",
|
||||
@@ -3475,6 +3492,23 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.chart.control.density.standard": "Density: standard",
|
||||
"projects.chart.control.palette.default": "Palette: default",
|
||||
"projects.chart.control.export.soon": "Export \u2193 (Slice 2)",
|
||||
"projects.chart.control.palette.label": "Palette:",
|
||||
"projects.chart.palette.default": "Default",
|
||||
"projects.chart.palette.kind_coded": "By event kind",
|
||||
"projects.chart.palette.track_coded": "By track",
|
||||
"projects.chart.palette.high_contrast": "High contrast",
|
||||
"projects.chart.palette.print": "Print (B/W)",
|
||||
"projects.chart.control.density.label": "Density:",
|
||||
"projects.chart.density.compact": "Compact",
|
||||
"projects.chart.density.standard": "Standard",
|
||||
"projects.chart.density.spacious": "Spacious",
|
||||
"projects.chart.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",
|
||||
|
||||
@@ -1,6 +1,21 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { mount, type ChartHandle } from "./views/shape-timeline-chart";
|
||||
import {
|
||||
ALL_DENSITIES,
|
||||
ALL_PALETTES,
|
||||
mount,
|
||||
type ChartHandle,
|
||||
type Density,
|
||||
type Palette,
|
||||
} from "./views/shape-timeline-chart";
|
||||
import {
|
||||
exportCSV,
|
||||
exportJSON,
|
||||
exportPNG,
|
||||
exportPrint,
|
||||
exportSVG,
|
||||
type ExportContext,
|
||||
} from "./views/chart-export";
|
||||
|
||||
// t-paliad-177 Slice 1 — boot client for the standalone Project Timeline
|
||||
// / Chart page. Reads the project id from the URL path, loads the
|
||||
@@ -25,6 +40,48 @@ function projectIdFromPath(): string | null {
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
const PALETTE_SET: ReadonlySet<string> = new Set(ALL_PALETTES);
|
||||
|
||||
/** Reads ?palette=... from the URL; returns the default when missing /
|
||||
* unknown so a hostile or stale URL can't break the chart. */
|
||||
function paletteFromURL(): Palette {
|
||||
const raw = new URLSearchParams(window.location.search).get("palette");
|
||||
if (raw && PALETTE_SET.has(raw)) return raw as Palette;
|
||||
return "default";
|
||||
}
|
||||
|
||||
/** Mirrors paletteFromURL but for writing — pushes a new history entry
|
||||
* so the URL stays bookmarkable / shareable per design §8.2. */
|
||||
function writePaletteToURL(palette: Palette): void {
|
||||
writeParamToURL("palette", palette, "default");
|
||||
}
|
||||
|
||||
const DENSITY_SET: ReadonlySet<string> = new Set(ALL_DENSITIES);
|
||||
|
||||
function densityFromURL(): Density {
|
||||
const raw = new URLSearchParams(window.location.search).get("density");
|
||||
if (raw && DENSITY_SET.has(raw)) return raw as Density;
|
||||
return "standard";
|
||||
}
|
||||
|
||||
function writeDensityToURL(density: Density): void {
|
||||
writeParamToURL("density", density, "standard");
|
||||
}
|
||||
|
||||
/** 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)}`);
|
||||
@@ -78,15 +135,84 @@ async function boot(): Promise<void> {
|
||||
loadingEl.style.display = "none";
|
||||
bodyEl.style.display = "";
|
||||
|
||||
const initialPalette = paletteFromURL();
|
||||
const initialDensity = densityFromURL();
|
||||
let handle: ChartHandle | null = null;
|
||||
try {
|
||||
handle = mount(host, { projectId: id });
|
||||
handle = mount(host, {
|
||||
projectId: id,
|
||||
palette: initialPalette,
|
||||
density: initialDensity,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("chart mount failed", err);
|
||||
host.textContent = t("projects.chart.error.mount");
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
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 = () => {
|
||||
@@ -106,6 +232,19 @@ async function boot(): Promise<void> {
|
||||
setTimeout(checkUndated, 1500);
|
||||
}
|
||||
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
void boot();
|
||||
});
|
||||
|
||||
274
frontend/src/client/views/chart-export.ts
Normal file
274
frontend/src/client/views/chart-export.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import type { LaneInfo, TimelineEvent } from "./shape-timeline";
|
||||
|
||||
// chart-export (t-paliad-177 Slice 2) — client-side export helpers for
|
||||
// the Project Timeline / Chart page.
|
||||
//
|
||||
// Five formats land in Slice 2 (per design §7.1, m's pick on faraday-Q4
|
||||
// to rule out server-side PDF via chromedp):
|
||||
//
|
||||
// SVG — XMLSerializer of the live SVG element
|
||||
// PNG — SVG → <img> → <canvas> at 2× HiDPI, toBlob("image/png")
|
||||
// PDF — window.print() with @media print stylesheet (browser handles
|
||||
// the PDF engine; no chromedp dep on Dokploy)
|
||||
// CSV — flat tabular dump of TimelineEvent[] (UTF-8 BOM for Excel-DE)
|
||||
// JSON — wire envelope verbatim + export-metadata header
|
||||
//
|
||||
// iCal lands in a follow-up commit (C5) and goes via a server-side
|
||||
// endpoint that reuses internal/services/caldav_ical.go (faraday-Q6).
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §7.
|
||||
|
||||
export interface ExportContext {
|
||||
projectId: string;
|
||||
projectTitle: string;
|
||||
svgEl: SVGSVGElement;
|
||||
events: ReadonlyArray<TimelineEvent>;
|
||||
lanes: ReadonlyArray<LaneInfo>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public surface
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function exportSVG(ctx: ExportContext): Promise<void> {
|
||||
const svgString = serialiseSVG(ctx.svgEl);
|
||||
const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
|
||||
triggerDownload(blob, filename(ctx, "svg"));
|
||||
}
|
||||
|
||||
export async function exportPNG(ctx: ExportContext): Promise<void> {
|
||||
const svgString = serialiseSVG(ctx.svgEl);
|
||||
const blob = await rasterise(svgString, ctx.svgEl);
|
||||
if (!blob) {
|
||||
throw new Error("PNG raster failed");
|
||||
}
|
||||
triggerDownload(blob, filename(ctx, "png"));
|
||||
}
|
||||
|
||||
export function exportCSV(ctx: ExportContext): void {
|
||||
const rows: string[][] = [csvHeader()];
|
||||
for (const event of ctx.events) {
|
||||
rows.push(csvRow(event, ctx));
|
||||
}
|
||||
// UTF-8 BOM keeps Excel-DE from mis-detecting ANSI; ISO-8601 dates
|
||||
// round-trip correctly into German Excel as text.
|
||||
const text = "" + rows.map(csvLine).join("\r\n") + "\r\n";
|
||||
const blob = new Blob([text], { type: "text/csv;charset=utf-8" });
|
||||
triggerDownload(blob, filename(ctx, "csv"));
|
||||
}
|
||||
|
||||
export function exportJSON(ctx: ExportContext): void {
|
||||
const envelope = {
|
||||
project_id: ctx.projectId,
|
||||
project_title: ctx.projectTitle,
|
||||
exported_at: new Date().toISOString(),
|
||||
events: ctx.events,
|
||||
lanes: ctx.lanes,
|
||||
};
|
||||
const text = JSON.stringify(envelope, null, 2) + "\n";
|
||||
const blob = new Blob([text], { type: "application/json;charset=utf-8" });
|
||||
triggerDownload(blob, filename(ctx, "json"));
|
||||
}
|
||||
|
||||
export function exportPrint(): void {
|
||||
// The @media print stylesheet in global.css does the layout work;
|
||||
// we just invoke the browser's print dialog. User picks "Save as PDF"
|
||||
// (Chrome/Edge), "Drucken in Datei" (Firefox), etc.
|
||||
window.print();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SVG / PNG plumbing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function serialiseSVG(svgEl: SVGSVGElement): string {
|
||||
// Clone so we can inline computed styles without polluting the live DOM.
|
||||
// For a true cross-environment-portable SVG, we'd compute every used
|
||||
// CSS-var into a literal value. v1 keeps it light: the receiver inherits
|
||||
// colours via document context when opened standalone, and the rendered
|
||||
// bars still work because palette tokens fall through to the .smart-
|
||||
// timeline-chart root selector via inline class. Add a fallback width /
|
||||
// height attribute so headless viewers don't render 0×0.
|
||||
const clone = svgEl.cloneNode(true) as SVGSVGElement;
|
||||
if (!clone.getAttribute("width") && svgEl.getAttribute("width")) {
|
||||
clone.setAttribute("width", svgEl.getAttribute("width") || "1000");
|
||||
}
|
||||
if (!clone.getAttribute("height") && svgEl.getAttribute("height")) {
|
||||
clone.setAttribute("height", svgEl.getAttribute("height") || "400");
|
||||
}
|
||||
clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
|
||||
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
|
||||
|
||||
// Inline the chart's computed palette tokens so the standalone SVG
|
||||
// paints the same way when opened in an image viewer (which has no
|
||||
// document.css). Read every --chart-* property off the live element.
|
||||
const computed = window.getComputedStyle(svgEl);
|
||||
const styleLines: string[] = [];
|
||||
for (const prop of [
|
||||
"--chart-mark-deadline",
|
||||
"--chart-mark-appointment",
|
||||
"--chart-mark-milestone",
|
||||
"--chart-mark-projected",
|
||||
"--chart-mark-overdue",
|
||||
"--chart-mark-done",
|
||||
"--chart-today-rule",
|
||||
"--chart-grid-line",
|
||||
"--chart-lane-label",
|
||||
"--chart-tick-label",
|
||||
"--chart-bg",
|
||||
]) {
|
||||
const val = computed.getPropertyValue(prop).trim();
|
||||
if (val) styleLines.push(`${prop}: ${val};`);
|
||||
}
|
||||
if (styleLines.length > 0) {
|
||||
const existing = clone.getAttribute("style") || "";
|
||||
clone.setAttribute("style", existing + styleLines.join(" "));
|
||||
}
|
||||
|
||||
return new XMLSerializer().serializeToString(clone);
|
||||
}
|
||||
|
||||
async function rasterise(svgString: string, svgEl: SVGSVGElement): Promise<Blob | null> {
|
||||
const widthAttr = svgEl.getAttribute("width") || "1000";
|
||||
const heightAttr = svgEl.getAttribute("height") || "400";
|
||||
const width = Number(widthAttr) || 1000;
|
||||
const height = Number(heightAttr) || 400;
|
||||
// 2× device pixel ratio for HiDPI exports (design §7.1 "PNG, 2× HiDPI").
|
||||
const scale = 2;
|
||||
|
||||
const blob = new Blob([svgString], { type: "image/svg+xml;charset=utf-8" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
try {
|
||||
const img = await loadImage(url);
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = Math.round(width * scale);
|
||||
canvas.height = Math.round(height * scale);
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
return await new Promise<Blob | null>((resolve) => {
|
||||
canvas.toBlob((b) => resolve(b), "image/png");
|
||||
});
|
||||
} finally {
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
}
|
||||
|
||||
function loadImage(src: string): Promise<HTMLImageElement> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => resolve(img);
|
||||
img.onerror = () => reject(new Error("Image load failed"));
|
||||
img.src = src;
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CSV plumbing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const CSV_COLUMNS = [
|
||||
"project_id",
|
||||
"project_title",
|
||||
"kind",
|
||||
"status",
|
||||
"track",
|
||||
"lane_id",
|
||||
"date",
|
||||
"title",
|
||||
"description",
|
||||
"rule_code",
|
||||
"depends_on_rule_code",
|
||||
"depends_on_date",
|
||||
"depends_on_rule_name",
|
||||
"sub_project_id",
|
||||
"sub_project_title",
|
||||
"bubble_up",
|
||||
"deadline_id",
|
||||
"appointment_id",
|
||||
"project_event_id",
|
||||
"project_event_type",
|
||||
] as const;
|
||||
|
||||
function csvHeader(): string[] {
|
||||
return [...CSV_COLUMNS];
|
||||
}
|
||||
|
||||
function csvRow(event: TimelineEvent, ctx: ExportContext): string[] {
|
||||
return [
|
||||
ctx.projectId,
|
||||
ctx.projectTitle,
|
||||
event.kind,
|
||||
event.status,
|
||||
event.track,
|
||||
event.lane_id ?? "",
|
||||
isoOnly(event.date),
|
||||
event.title,
|
||||
event.description ?? "",
|
||||
event.rule_code ?? "",
|
||||
event.depends_on_rule_code ?? "",
|
||||
isoOnly(event.depends_on_date),
|
||||
event.depends_on_rule_name ?? "",
|
||||
event.sub_project_id ?? "",
|
||||
event.sub_project_title ?? "",
|
||||
event.bubble_up ? "true" : "false",
|
||||
event.deadline_id ?? "",
|
||||
event.appointment_id ?? "",
|
||||
event.project_event_id ?? "",
|
||||
event.project_event_type ?? "",
|
||||
];
|
||||
}
|
||||
|
||||
function csvLine(fields: string[]): string {
|
||||
return fields.map(csvEscape).join(",");
|
||||
}
|
||||
|
||||
/** RFC 4180 quoting: double quotes inside the field are doubled; wrap
|
||||
* the whole field in quotes if it contains comma / quote / newline. */
|
||||
function csvEscape(value: string): string {
|
||||
if (/[,"\r\n]/.test(value)) {
|
||||
return '"' + value.replace(/"/g, '""') + '"';
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function isoOnly(date: string | null | undefined): string {
|
||||
if (!date) return "";
|
||||
return date.slice(0, 10);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Download trigger
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function triggerDownload(blob: Blob, name: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = name;
|
||||
// Some browsers (Safari < 14) ignore the download attribute unless
|
||||
// the link is in the document tree. Inserting + removing is cheap.
|
||||
a.style.display = "none";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
// Give the browser a tick to start the download before we revoke.
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
|
||||
function filename(ctx: ExportContext, ext: string): string {
|
||||
// Keep filenames diff-friendly + filesystem-safe. Replace anything that
|
||||
// isn't ASCII alnum/dot/hyphen with "_". Truncate the title to 60 chars.
|
||||
const safeTitle = (ctx.projectTitle || "timeline")
|
||||
.normalize("NFKD")
|
||||
.replace(/[^\x20-\x7e]/g, "")
|
||||
.replace(/[^A-Za-z0-9.-]+/g, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_|_$/g, "")
|
||||
.slice(0, 60) || "timeline";
|
||||
const dateStr = new Date().toISOString().slice(0, 10);
|
||||
return `paliad-${safeTitle}-${dateStr}.${ext}`;
|
||||
}
|
||||
@@ -607,10 +607,35 @@ function markAriaLabel(mark: Mark, event: TimelineEvent): string {
|
||||
// Public: mount
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Palette presets from design §5.1. Each is a CSS-var override hung off
|
||||
* `.smart-timeline-chart[data-palette="<name>"]`; the renderer never
|
||||
* reads palette state directly. */
|
||||
export type Palette =
|
||||
| "default"
|
||||
| "kind-coded"
|
||||
| "track-coded"
|
||||
| "high-contrast"
|
||||
| "print";
|
||||
|
||||
export const ALL_PALETTES: ReadonlyArray<Palette> = [
|
||||
"default",
|
||||
"kind-coded",
|
||||
"track-coded",
|
||||
"high-contrast",
|
||||
"print",
|
||||
];
|
||||
|
||||
export const ALL_DENSITIES: ReadonlyArray<Density> = [
|
||||
"compact",
|
||||
"standard",
|
||||
"spacious",
|
||||
];
|
||||
|
||||
export interface ChartMountOpts {
|
||||
projectId: string;
|
||||
todayISO?: string;
|
||||
density?: Density;
|
||||
palette?: Palette;
|
||||
/** Optional ISO YYYY-MM-DD overrides for the date range. When omitted,
|
||||
* mount picks `today-1y .. today+1y` per design Q8. */
|
||||
rangeFrom?: string;
|
||||
@@ -627,6 +652,14 @@ export interface ChartHandle {
|
||||
dispose: () => void;
|
||||
/** Returns the last computed layout (useful for tests / debugging). */
|
||||
getLayout: () => ChartLayout | null;
|
||||
/** Swap palette via data-palette attribute. Pure CSS-var swap — no repaint. */
|
||||
setPalette: (palette: Palette) => void;
|
||||
/** Swap density. Re-runs layout() since lane height / mark radius change. */
|
||||
setDensity: (density: Density) => void;
|
||||
/** The raw SVG node — chart-export.ts reads this for SVG / PNG / print. */
|
||||
getSVGElement: () => SVGSVGElement;
|
||||
/** Last-loaded data — chart-export.ts reads this for CSV / JSON / iCal. */
|
||||
getData: () => { events: TimelineEvent[]; lanes: LaneInfo[] };
|
||||
}
|
||||
|
||||
interface TimelineEnvelope {
|
||||
@@ -651,7 +684,7 @@ export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle {
|
||||
// The SVG root we paint into.
|
||||
const svgEl = document.createElementNS(SVG_NS, "svg") as SVGSVGElement;
|
||||
svgEl.classList.add("smart-timeline-chart");
|
||||
svgEl.setAttribute("data-palette", "default");
|
||||
svgEl.setAttribute("data-palette", opts.palette ?? "default");
|
||||
svgEl.setAttribute("data-density", opts.density ?? "standard");
|
||||
host.appendChild(svgEl);
|
||||
|
||||
@@ -662,12 +695,13 @@ export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle {
|
||||
const rangeFrom = opts.rangeFrom ?? shiftYears(todayISO, -1);
|
||||
const rangeTo = opts.rangeTo ?? shiftYears(todayISO, 1);
|
||||
|
||||
let currentDensity: Density = opts.density ?? "standard";
|
||||
|
||||
function repaint(): void {
|
||||
const rect = host.getBoundingClientRect();
|
||||
// Minimum width keeps the canvas usable when the host is hidden /
|
||||
// about to be sized; resize listener will repaint on real layout.
|
||||
const width = Math.max(640, rect.width || 1000);
|
||||
const density: Density = opts.density ?? "standard";
|
||||
const viewport: ChartViewport = {
|
||||
width,
|
||||
height: 400,
|
||||
@@ -676,7 +710,7 @@ export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle {
|
||||
todayISO,
|
||||
rangeFrom,
|
||||
rangeTo,
|
||||
density,
|
||||
density: currentDensity,
|
||||
};
|
||||
const chart = layout(lastEvents, [...currentLanes], viewport);
|
||||
lastLayout = chart;
|
||||
@@ -763,6 +797,16 @@ export function mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle {
|
||||
return {
|
||||
refresh,
|
||||
getLayout: () => lastLayout,
|
||||
setPalette: (palette: Palette) => {
|
||||
svgEl.setAttribute("data-palette", palette);
|
||||
},
|
||||
setDensity: (density: Density) => {
|
||||
currentDensity = density;
|
||||
svgEl.setAttribute("data-density", density);
|
||||
repaint();
|
||||
},
|
||||
getSVGElement: () => svgEl,
|
||||
getData: () => ({ events: lastEvents, lanes: currentLanes }),
|
||||
dispose: () => {
|
||||
svgEl.removeEventListener("click", handleClick);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
|
||||
@@ -1625,13 +1625,30 @@ export type I18nKey =
|
||||
| "projects.cards.team"
|
||||
| "projects.chart.back"
|
||||
| "projects.chart.control.columns.auto"
|
||||
| "projects.chart.control.density.label"
|
||||
| "projects.chart.control.density.standard"
|
||||
| "projects.chart.control.export.soon"
|
||||
| "projects.chart.control.layout.horizontal"
|
||||
| "projects.chart.control.palette.default"
|
||||
| "projects.chart.control.palette.label"
|
||||
| "projects.chart.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.title"
|
||||
| "projects.chip.all"
|
||||
| "projects.chip.has_open_deadlines"
|
||||
|
||||
@@ -67,15 +67,66 @@ export function renderProjectsChart(): string {
|
||||
<span className="chip-inert" data-i18n="projects.chart.control.columns.auto" title="Slice 3">
|
||||
Spalten: Auto
|
||||
</span>
|
||||
<span className="chip-inert" data-i18n="projects.chart.control.density.standard" title="Slice 3">
|
||||
Dichte: Standard
|
||||
<span className="smart-timeline-chart-picker">
|
||||
<label htmlFor="projects-chart-density" data-i18n="projects.chart.control.density.label">
|
||||
Dichte:
|
||||
</label>
|
||||
<select id="projects-chart-density">
|
||||
<option value="compact" data-i18n="projects.chart.density.compact">Kompakt</option>
|
||||
<option value="standard" data-i18n="projects.chart.density.standard">Standard</option>
|
||||
<option value="spacious" data-i18n="projects.chart.density.spacious">Großzügig</option>
|
||||
</select>
|
||||
</span>
|
||||
<span className="chip-inert" data-i18n="projects.chart.control.palette.default" title="Slice 3">
|
||||
Palette: Standard
|
||||
</span>
|
||||
<span className="chip-inert" data-i18n="projects.chart.control.export.soon" title="Slice 2">
|
||||
Export ↓ (Slice 2)
|
||||
<span className="smart-timeline-chart-picker">
|
||||
<label htmlFor="projects-chart-palette" data-i18n="projects.chart.control.palette.label">
|
||||
Palette:
|
||||
</label>
|
||||
<select id="projects-chart-palette">
|
||||
<option value="default" data-i18n="projects.chart.palette.default">Standard</option>
|
||||
<option value="kind-coded" data-i18n="projects.chart.palette.kind_coded">Nach Ereignistyp</option>
|
||||
<option value="track-coded" data-i18n="projects.chart.palette.track_coded">Nach Spur</option>
|
||||
<option value="high-contrast" data-i18n="projects.chart.palette.high_contrast">Hoher Kontrast</option>
|
||||
<option value="print" data-i18n="projects.chart.palette.print">Druck (S/W)</option>
|
||||
</select>
|
||||
</span>
|
||||
<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-host" className="smart-timeline-chart-host" />
|
||||
|
||||
@@ -14355,3 +14355,204 @@ dialog.quick-add-sheet::backdrop {
|
||||
overflow-x: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* ---- 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;
|
||||
}
|
||||
|
||||
/* ---- 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,74 @@
|
||||
package handlers
|
||||
|
||||
import "net/http"
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
// t-paliad-177 Slice 1 — Project Timeline / Chart standalone page.
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// t-paliad-177 — Project Timeline / Chart standalone page.
|
||||
//
|
||||
// Serves the statically-generated dist/projects-chart.html shell for
|
||||
// GET /projects/{id}/chart. The visibility check happens client-side
|
||||
// against the existing /api/projects/{id}/timeline endpoint, which
|
||||
// already gates on project visibility through ProjectionService.For.
|
||||
// Slice 1 served dist/projects-chart.html unconditionally and relied on
|
||||
// the client's first API fetch to enforce visibility. That leaked a 200
|
||||
// for any well-formed UUID a guesser tried (m/paliad#35 Slice 1 edge
|
||||
// case #2). Slice 2 closes the leak — we resolve the project via
|
||||
// ProjectService.GetByID *before* serving the shell so an inaccessible
|
||||
// id returns 404 + the standard notfound chrome.
|
||||
//
|
||||
// Design ref: docs/design-project-chart-2026-05-09.md §8.2 + §12.
|
||||
|
||||
func handleProjectsChartPage(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
serveChartNotFound(w)
|
||||
return
|
||||
}
|
||||
if _, err := dbSvc.projects.GetByID(r.Context(), uid, id); err != nil {
|
||||
// ErrNotVisible + any "not found" surface from the service collapses
|
||||
// to the same outward 404 — never tell a guesser whether the id
|
||||
// exists, only whether they can see it.
|
||||
if errors.Is(err, services.ErrNotVisible) {
|
||||
serveChartNotFound(w)
|
||||
return
|
||||
}
|
||||
// Genuine errors (DB hiccup, etc.) — log via writeServiceError but
|
||||
// also fall back to 404 page chrome for the user instead of a raw
|
||||
// 500 string. The JSON path of writeServiceError handles /api/*
|
||||
// only, so we keep its logging side-effect but render the HTML.
|
||||
writeServiceError(httpDevNullJSON{}, err)
|
||||
serveChartNotFound(w)
|
||||
return
|
||||
}
|
||||
http.ServeFile(w, r, "dist/projects-chart.html")
|
||||
}
|
||||
|
||||
func serveChartNotFound(w http.ResponseWriter) {
|
||||
body, err := os.ReadFile("dist/notfound.html")
|
||||
if err != nil {
|
||||
http.Error(w, "404 page not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
_, _ = w.Write(body)
|
||||
}
|
||||
|
||||
// httpDevNullJSON is a writer that discards everything writeServiceError
|
||||
// would have emitted — we only want the log line, not a duplicate body
|
||||
// before serveChartNotFound writes the real one.
|
||||
type httpDevNullJSON struct{}
|
||||
|
||||
func (httpDevNullJSON) Header() http.Header { return http.Header{} }
|
||||
func (httpDevNullJSON) Write(b []byte) (int, error) { return len(b), nil }
|
||||
func (httpDevNullJSON) WriteHeader(int) {}
|
||||
|
||||
30
internal/handlers/chart_pages_test.go
Normal file
30
internal/handlers/chart_pages_test.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// t-paliad-177 Slice 2 — visibility leak fix.
|
||||
//
|
||||
// The end-to-end "GET /chart returns 404 for invisible projects" check
|
||||
// would need a mocked ProjectService + auth.Client; the handler package
|
||||
// has no harness for that today (all existing _test.go files unit-test
|
||||
// pure helpers). Until that harness exists, we pin the contract from
|
||||
// the helper layer: serveChartNotFound writes a 404 + an HTML
|
||||
// Content-Type. The dist/notfound.html lookup falls back to a plain
|
||||
// 404 string in test environments without a built frontend, which is
|
||||
// the documented degraded path.
|
||||
|
||||
func TestServeChartNotFound_Returns404HTML(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
serveChartNotFound(w)
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("status = %d, want %d", w.Code, http.StatusNotFound)
|
||||
}
|
||||
body := w.Body.String()
|
||||
if body == "" {
|
||||
t.Error("body is empty — should be either the notfound chrome or the plain-text fallback")
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// /timeline/anchor is the click-to-anchor write (Slice 2).
|
||||
// /timeline/skip is the "ist nicht eingetreten" decision (§6.4).
|
||||
protected.HandleFunc("GET /api/projects/{id}/timeline", handleGetProjectTimeline)
|
||||
// t-paliad-177 Slice 2 — iCal feed (deadlines + appointments only).
|
||||
protected.HandleFunc("GET /api/projects/{id}/timeline.ics", handleGetProjectTimelineICS)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/milestone", handleCreateProjectTimelineMilestone)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/anchor", handleProjectTimelineAnchor)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/skip", handleProjectTimelineSkip)
|
||||
|
||||
@@ -96,6 +96,91 @@ func handleGetProjectTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
})
|
||||
}
|
||||
|
||||
// GET /api/projects/{id}/timeline.ics
|
||||
//
|
||||
// t-paliad-177 Slice 2 — iCal feed export. Returns a VCALENDAR with one
|
||||
// VEVENT per deadline + appointment row (faraday-Q6: NO projected — a
|
||||
// calendar feed must never carry predicted dates the user never
|
||||
// confirmed). Reuses the formatter from caldav_ical.go so future
|
||||
// CalDAV sync work and chart exports share one source of truth.
|
||||
//
|
||||
// Visibility piggybacks on ProjectionService.For (same gate as
|
||||
// /timeline). Project title is fetched via ProjectService.GetByID and
|
||||
// passed as the X-WR-CALNAME for Outlook / Apple Calendar display.
|
||||
func handleGetProjectTimelineICS(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.projection == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "projection service unavailable",
|
||||
})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
rows, _, err := dbSvc.projection.For(r.Context(), uid, id, services.ProjectionOpts{})
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
proj, err := dbSvc.projects.GetByID(r.Context(), uid, id)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
body := services.FormatTimelineICS(rows, proj.Title)
|
||||
w.Header().Set("Content-Type", "text/calendar; charset=utf-8")
|
||||
// Sanitise the project title for the filename — RFC-7230 disallows
|
||||
// many bytes in header values, and Outlook truncates non-ASCII
|
||||
// disposition filenames inconsistently. ASCII slug + date is portable.
|
||||
w.Header().Set(
|
||||
"Content-Disposition",
|
||||
`attachment; filename="paliad-`+filenameSlug(proj.Title)+`-`+
|
||||
time.Now().UTC().Format("2006-01-02")+`.ics"`,
|
||||
)
|
||||
_, _ = w.Write([]byte(body))
|
||||
}
|
||||
|
||||
func filenameSlug(s string) string {
|
||||
if s == "" {
|
||||
return "timeline"
|
||||
}
|
||||
out := make([]byte, 0, len(s))
|
||||
for i := 0; i < len(s); i++ {
|
||||
c := s[i]
|
||||
switch {
|
||||
case c >= 'A' && c <= 'Z', c >= 'a' && c <= 'z', c >= '0' && c <= '9', c == '-', c == '.':
|
||||
out = append(out, c)
|
||||
default:
|
||||
if len(out) > 0 && out[len(out)-1] != '_' {
|
||||
out = append(out, '_')
|
||||
}
|
||||
}
|
||||
}
|
||||
for len(out) > 0 && (out[0] == '_' || out[len(out)-1] == '_') {
|
||||
if out[0] == '_' {
|
||||
out = out[1:]
|
||||
} else {
|
||||
out = out[:len(out)-1]
|
||||
}
|
||||
}
|
||||
if len(out) > 60 {
|
||||
out = out[:60]
|
||||
}
|
||||
if len(out) == 0 {
|
||||
return "timeline"
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// POST /api/projects/{id}/timeline/anchor
|
||||
//
|
||||
// Body: {"rule_code":"inf.sod","actual_date":"2026-08-31","kind":"deadline"}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user