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