Compare commits
24 Commits
mai/lagran
...
mai/farada
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84020022a6 | ||
|
|
7930ee0bdb | ||
|
|
7e57507a92 | ||
|
|
7da8802f9b | ||
|
|
91d3811276 | ||
|
|
483649d9d2 | ||
|
|
82888dea78 | ||
|
|
306bb11618 | ||
|
|
196f3f74a6 | ||
|
|
331efc8603 | ||
|
|
85d7dd497c | ||
|
|
335be29b23 | ||
|
|
0835be4a7f | ||
|
|
3e1bbd3c77 | ||
|
|
7057fe5d25 | ||
|
|
4a5d56d9e6 | ||
|
|
afd3aab2b2 | ||
|
|
49c260b888 | ||
|
|
12b35fc9fe | ||
|
|
ebcda13f88 | ||
|
|
487fec2672 | ||
|
|
69544bf3fb | ||
|
|
7fef64159b | ||
|
|
7238b12b05 |
@@ -168,6 +168,7 @@ func main() {
|
||||
Broadcast: services.NewBroadcastService(pool, mailSvc, users, teamSvc, emailTemplateSvc),
|
||||
Pin: services.NewPinService(pool, projectSvc),
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
|
||||
}
|
||||
|
||||
// Paliadin backend selection (t-paliad-146 + t-paliad-151):
|
||||
|
||||
607
docs/design-project-chart-2026-05-09.md
Normal file
607
docs/design-project-chart-2026-05-09.md
Normal file
@@ -0,0 +1,607 @@
|
||||
# Design — Project Timeline / Chart (visualisation layer above SmartTimeline)
|
||||
|
||||
**Author:** faraday (inventor)
|
||||
**Date:** 2026-05-09
|
||||
**Task:** t-paliad-177
|
||||
**Issue:** m/paliad#35
|
||||
**Status:** READY FOR REVIEW — m gates inventor → coder transition.
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live (before designing)
|
||||
|
||||
Before anchoring the design, I checked the live state — CLAUDE.md / memory / issue body can drift, the live system can't.
|
||||
|
||||
- **SmartTimeline data substrate is shipped through Slice 4.** `internal/services/projection_service.go:287 (For)` returns `([]TimelineEvent, ProjectionMeta, error)`. The wire envelope (`ResponseEnvelope`) is `{events: TimelineEvent[], lanes: LaneInfo[]}` — `Lanes` is the load-bearing primitive for parent-node aggregation (one column per direct child case / patent / litigation). `LevelPolicy` already differentiates `self_plus_ccr` (Case) / `child_case` (Patent) / `child_patent` (Litigation) / `child_litigation` (Client). Recent commits 7da8802, 7e57507, 7930ee0 confirm — design merge is on `main` (b4f4b3 baseline as of this branch).
|
||||
- **Frontend renderer for the SmartTimeline is `frontend/src/client/views/shape-timeline.ts` (960 LoC, hand-rolled DOM via `document.createElement`).** It already implements: vertical flow, parallel-track CSS-grid for CCR (`renderParallelTracks`), lane-strip CSS-grid for parent-node aggregation (`renderLaneStrip`), click-to-anchor inline editor, `[Track ▼]` chip, lane-filter chip multiselect, lookahead toggle. The "horizontal Gantt" mode m's brief asks about does **not** exist.
|
||||
- **No chart library is in the repo.** `package.json` has only `@types/bun`. No D3, no Chart.js, no Apache ECharts, no plotly, no chartjs-node-canvas. Frontend is hand-rolled DOM/SVG via the custom TSX renderer described in `.claude/CLAUDE.md`. Adding a runtime dep would need m's explicit approval (per global rules).
|
||||
- **No PDF / image-export pipeline exists either.** `internal/services/caldav_ical.go` generates VCALENDAR strings (BEGIN:VCALENDAR / BEGIN:VEVENT) for CalDAV PUT bodies, but there is no public iCal-feed download endpoint, no headless-browser dep (`chromedp` not in `go.sum`), no Go PDF lib. The only existing `Content-Disposition: attachment` header is in `internal/handlers/files.go` for the Gitea Downloads proxy.
|
||||
- **Custom Views render shapes are list / cards / calendar.** `internal/services/render_spec.go` declares `RenderShape` = `ShapeList | ShapeCards | ShapeCalendar`. **There is no `ShapeTimeline` registered yet** — t-paliad-169 §8.6 reserved the slot but didn't claim it. A new chart shape would extend this enum and grow `frontend/src/views.tsx` host accordingly.
|
||||
- **Mobile breakpoints in use today are 640px / 720px / 768px / 1023px** (`frontend/src/styles/global.css`). Lime green primary token is `--color-accent: var(--hlc-lime)` with light/dark variants and a `--color-accent-fg` foreground token. There is `@media print` already in the stylesheet — printing is on the table.
|
||||
- **Project hierarchy depth in prod = 4 levels, 11 projects total.** A loaded Patent at the upper end has 5 child cases; a hypothetical Client could have 100+ matters. Any chart layout must answer "how does this look on a page with 5 cases × 30 events" and "with 100+ matters" — see §10.
|
||||
|
||||
If the live state above contradicts a memory or issue note, the live state wins.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision + scope
|
||||
|
||||
m's brief (verbatim 2026-05-09 18:32):
|
||||
|
||||
> One could chose to show the timeline in one or in separate columns and with different colors even... bigger feature development but ... a project timeline / chart would be nice in general. So we need to make some considerations on how to design one. Another aspect to this is vertical or horizontal... and an export functionality would also be great.
|
||||
|
||||
The **Project Timeline / Chart** is the *visualisation layer* above the SmartTimeline data substrate. Where SmartTimeline answers "what is the data", the Chart answers "how does the lawyer want to see it today, on what surface, in what shape, exported to whom".
|
||||
|
||||
### What this design covers
|
||||
|
||||
| Axis | Choices |
|
||||
|---|---|
|
||||
| **Layout direction** | Vertical (today) / Horizontal Gantt-strip / Hybrid |
|
||||
| **Column model** | Single-column flow / Multi-column (lanes — already in substrate) |
|
||||
| **Visual customisation** | Color schemes per track / kind / status / party; density modes (compact/standard/spacious); status pill / kind chip / shape variants |
|
||||
| **Export** | SVG (vector) / PNG (raster) / PDF (browser-print or rasterised) / CSV (data) / JSON (data) / iCal (deadlines+appointments feed) |
|
||||
| **Surfaces** | Verlauf-tab embed (existing) / `/projects/{id}/chart` standalone full-page / `RenderShape="timeline"` Custom Views |
|
||||
|
||||
### What stays
|
||||
|
||||
- **`projection_service.go` is the only data source.** No new query path. The chart is a presentation-level concern; data composition is solved.
|
||||
- **`shape-timeline.ts` (vertical DOM renderer) stays** as the embed default for the Verlauf tab. We add modes alongside it; we don't tear it out.
|
||||
- **`paliad.deadlines`, `paliad.appointments`, `paliad.project_events`, `paliad.deadline_rules`** schemas — unchanged. Zero migrations in this design.
|
||||
- **Color tokens (`--color-accent`, `--color-bg-lime-tint`, …)** — anchor every chart palette, light/dark mode + WCAG follow for free.
|
||||
|
||||
### Out of scope (v1 of this feature)
|
||||
|
||||
- **Cross-matter chart on `/projects` list page** — bundled under the Custom-Views path (§8.3) once `RenderShape="timeline"` lands. Not v1.
|
||||
- **Live collaborative cursors / annotation pins** — presentation features for a later phase, not for shipping the chart itself.
|
||||
- **Rich-text editing of chart entries from inside the chart canvas** — clicks deep-link to existing detail pages. Edit-in-place is the SmartTimeline's anchor affordance and stays there.
|
||||
- **Server-side PDF rendering via headless browser** — adding `chromedp` introduces a Chromium runtime dependency on the Dokploy compose host. Recommend client-side `window.print()` for v1; revisit only if user feedback says "PDFs differ across employees' browsers". See §7.3 for the trade-off in full.
|
||||
- **Theming UI for end users to pick palettes** — v1 gives a small fixed palette set; a colour-picker is v2 nice-to-have only if real users ask for it.
|
||||
|
||||
---
|
||||
|
||||
## 2. Renderer choice — SVG for the Gantt mode, DOM for the flow mode
|
||||
|
||||
This is the load-bearing call. Five candidates surveyed:
|
||||
|
||||
| Renderer | Pros | Cons | Fit |
|
||||
|---|---|---|---|
|
||||
| **DOM/CSS grid** (existing) | Accessible by default; themable via CSS vars; free dark-mode + i18n; exportable via `window.print()` | Hard to do continuous date-axis math (Gantt scaling); heavy reflow on resize; html-to-PNG via foreignObject is browser-quirky | Best for vertical flow ✓ |
|
||||
| **SVG hand-rolled** | Vector by construction → free SVG / PNG export via canvas drawImage; precise positioning math; one paint call; printable | Manual ARIA scaffolding; no automatic text-wrapping; need a layout pass | Best for horizontal Gantt ✓ |
|
||||
| **`<canvas>`** | Top performance for 1000+ nodes | Zero accessibility; manual hit-testing for clicks; export needs separate path | Overkill for our scale (≤150 nodes typical) ✗ |
|
||||
| **D3.js** | Battle-tested abstractions for axes / scales | ~250 KB minified, runtime data-driven DOM mutation conflicts with our IIFE-bundle pattern, would need m's package approval | Overkill, runtime cost ✗ |
|
||||
| **SVG + foreignObject for text** | Vector with native HTML text wrapping | Spotty PDF and Safari support; defeats the export-for-free pitch | Avoid ✗ |
|
||||
|
||||
### 2.1 Recommendation
|
||||
|
||||
**Two renderers coexist.** Same data, different DOM:
|
||||
|
||||
- **`shape-timeline.ts`** (existing DOM/CSS grid, vertical) keeps powering the Verlauf-tab embed — it's small, accessible, themed.
|
||||
- **`shape-timeline-chart.ts`** (new SVG) powers the standalone `/projects/{id}/chart` page in horizontal Gantt mode. Hand-rolled, no library, ~500 LoC for v1.
|
||||
|
||||
The horizontal Gantt page is also where the export buttons live (§7) — exporting a vertical DOM list is "open browser print and cmd-P" already, no new code needed; the Gantt is the genuinely new surface and brings PDF/SVG/PNG with it.
|
||||
|
||||
### 2.2 Why hand-rolled SVG over D3
|
||||
|
||||
We have ≤150 nodes per project, two axes (date + lane), three primitives (bar, dot, label) and one expanding need (zoom + pan, eventually). D3 ships ~250 KB to give us scales + axis generators + zoom. Our scale is `(date - earliestDate) / dayWidthPx`, a one-liner; our axis is a year/quarter tick generator, ~30 LoC; pan + zoom is `addEventListener("wheel"|"pointermove")`, ~50 LoC. The lift to write it ourselves is real but small, the runtime cost saving is real, and we keep the single-file IIFE bundle pattern intact.
|
||||
|
||||
If we ever hit "the layout math is too painful to maintain", D3-only-the-axis-helper or an `axes.ts` module is a refactor we can do then. v1 ships without.
|
||||
|
||||
### 2.3 What hand-rolled SVG looks like
|
||||
|
||||
One root SVG element, three layered groups:
|
||||
|
||||
```
|
||||
<svg viewBox="0 0 W H">
|
||||
<defs>
|
||||
<pattern id="weekend"…/> # weekend background stripe
|
||||
<linearGradient id="proj"…/> # projected-row gradient
|
||||
</defs>
|
||||
<g class="chart-grid"> # lane separators + date-axis ticks + today rule
|
||||
<g class="chart-bars"> # one rect/g per event
|
||||
<g class="chart-labels"> # text labels (kind chip, title)
|
||||
<g class="chart-overlay"> # tooltip + selection scrim
|
||||
</svg>
|
||||
```
|
||||
|
||||
Coordinates are computed by a `layout(events, lanes, viewport)` pure function — testable, deterministic, the same on screen and on export.
|
||||
|
||||
---
|
||||
|
||||
## 3. Layout — vertical (existing) + horizontal (new)
|
||||
|
||||
### 3.1 Vertical (DOM, existing — no changes)
|
||||
|
||||
Embedded on `/projects/{id}` Verlauf tab. Today's `shape-timeline.ts` flow with date column / event card right column, "Heute →" rule, parallel tracks for CCR, lane-strip for parent-node aggregation. Nothing changes in this design — I'm explicit about that so the implementer doesn't accidentally rewrite working code.
|
||||
|
||||
### 3.2 Horizontal Gantt-strip (SVG, new)
|
||||
|
||||
The `/projects/{id}/chart` page. Time on the X axis, lanes on the Y axis. Each lane is a horizontal row; events plot as either a dot (point-in-time: deadline due-date, milestone, appointment) or a bar (range: future-projected sequence between two anchors, or appointment with end_at). Today's rule = vertical line.
|
||||
|
||||
```
|
||||
←──────── 2026 ────────→ 2027 ─────→
|
||||
┌────────────────────────────────────────┐
|
||||
Self │ ✓ ●─────●────────────● ░──░──░──░ │
|
||||
Hauptverf. │ Klage Antw. HV R29a R29c │
|
||||
│ ↑Heute │
|
||||
├────────────────────────────────────────┤
|
||||
Widerklage │ ⊕──────░───░──░ │
|
||||
(CCR) │ Filed R29d R32 │
|
||||
│ │
|
||||
└────────────────────────────────────────┘
|
||||
Date axis: Q1 Q2 Q3 Q4 Q1 Q2 Q3
|
||||
│ │
|
||||
└ year border └ Today rule (lime)
|
||||
```
|
||||
|
||||
### 3.3 Layout invariants (both modes)
|
||||
|
||||
These rules must hold across both renderers — they're the contract that lets us swap modes without surprising the user:
|
||||
|
||||
1. **Past = left/below; Future = right/above; Today = lime separator.** Vertical: future at top per existing convention. Horizontal: future on right per Gantt convention. The convention flip is fine because the "today" lime separator orients the user instantly.
|
||||
2. **One row = one event** in vertical; **one bar/dot = one event** in horizontal. We never group two events into one mark. Lane (column in horizontal, parallel-track-column in vertical) is the only grouping primitive.
|
||||
3. **`Kind` drives shape / glyph; `Status` drives color saturation; `Track` drives column placement.** This composes orthogonally — see §5.
|
||||
|
||||
### 3.4 Hybrid not in v1
|
||||
|
||||
A "compact horizontal-strip-on-top + vertical-detail-below" hybrid (think Gmail conversation view but for matters) is a tempting third mode. **Not in v1** — adds a third renderer with no clear user request behind it. Revisit if a partner asks "I want both at once".
|
||||
|
||||
### 3.5 Single-column vs multi-column on horizontal
|
||||
|
||||
Multi-column = lanes, identical to the substrate's `LaneInfo` already. The horizontal Gantt **always multi-lanes** when there's more than one lane; collapsing all events into one row just to give a "single-column" version produces visual chaos with overlapping bars on the same date. The `[Track ▼]` filter (existing) lets the user collapse to a single track if they want a single-row view. So:
|
||||
|
||||
- **Substrate has 1 lane** (Case-level, no CCR): single horizontal row.
|
||||
- **Substrate has 2+ lanes** (Case + CCR sub-project, OR Patent / Litigation / Client level): horizontal multi-lane Gantt with one row per lane.
|
||||
|
||||
This mirrors the lane-mode the vertical renderer already uses (`renderLaneStrip`) — same data shape, different rendering.
|
||||
|
||||
---
|
||||
|
||||
## 4. Column model — extend `LaneInfo`, no new substrate concept
|
||||
|
||||
The substrate already discriminates lanes via `levelPolicy(projectType)` returning `LaneAxis`. The chart inherits that vocabulary for free.
|
||||
|
||||
### 4.1 What the chart adds
|
||||
|
||||
Two read-only filters at chart mount time, both client-side (no backend changes):
|
||||
|
||||
```ts
|
||||
interface ChartViewState {
|
||||
layout: "vertical" | "horizontal"; // default "horizontal" on /chart, "vertical" on Verlauf
|
||||
columns: "auto" | "single" | "lanes"; // "auto" reads lanes.length from substrate
|
||||
density: "compact" | "standard" | "spacious";
|
||||
palette: "default" | "high-contrast" | "print" | "kind-coded" | "track-coded";
|
||||
zoom: number; // px-per-day; default 4
|
||||
range?: { from: string; to: string }; // ISO; defaults to substrate's earliest..latest+30d
|
||||
}
|
||||
```
|
||||
|
||||
`columns="auto"` is the default — the substrate decides. `columns="single"` collapses everything into one row (useful when comparing dates across CCR + parent on horizontal). `columns="lanes"` forces lane mode even when only one lane exists (useful for screenshot consistency).
|
||||
|
||||
### 4.2 What the chart does not add to the substrate
|
||||
|
||||
**No new lane axis.** If the brief later wants "lanes per party" (claimant vs defendant) or "lanes per court country", that becomes a new `LaneAxis` value in `levelPolicy` — substrate work, not chart work. The chart is a render of whatever lanes the substrate produced.
|
||||
|
||||
This boundary is important: the chart can be improved / re-skinned / re-renderered without touching the data layer, and substrate improvements (new lane axes, new event kinds) automatically reach both renderers.
|
||||
|
||||
---
|
||||
|
||||
## 5. Color schemes
|
||||
|
||||
The brief asks for *"different colors even"*. Three palette dimensions are useful — and they're orthogonal, so a user picks one at a time.
|
||||
|
||||
### 5.1 Palette presets (built-in, fixed)
|
||||
|
||||
| Preset | What's color-coded by | Use case |
|
||||
|---|---|---|
|
||||
| **`default`** | Lane (`--color-accent` for parent, neutral grey for CCR/parent_context) | Embed in Verlauf, partner glance |
|
||||
| **`kind-coded`** | Event kind (deadline = blue, appointment = amber, milestone = lime, projected = soft-grey) | "Show me what's a hearing vs a deadline at a glance" |
|
||||
| **`track-coded`** | Track tag (parent / counterclaim / parent_context — three distinct hues) | CCR-heavy projects where the track is the most important axis |
|
||||
| **`high-contrast`** | Status only (done = green ✓; open = amber; overdue = red; predicted = light-grey) | Print-friendly, accessibility-first, screenshot for client |
|
||||
| **`print`** | Black / white / one-stripe-pattern (no color at all) | Faxable, b&w-printable, redactable |
|
||||
|
||||
All five palettes are CSS custom-property *swaps* on the chart root — the renderer reads `var(--chart-bar-deadline)`, the palette CSS file defines what each is. No JS branching in the renderer.
|
||||
|
||||
### 5.2 Token surface (CSS vars)
|
||||
|
||||
```css
|
||||
.smart-timeline-chart {
|
||||
--chart-bar-deadline: var(--color-accent);
|
||||
--chart-bar-appointment: #f5a623;
|
||||
--chart-bar-milestone: var(--hlc-midnight);
|
||||
--chart-bar-projected: var(--color-text-subtle);
|
||||
--chart-bar-overdue: #d62828;
|
||||
|
||||
--chart-track-parent: var(--color-accent);
|
||||
--chart-track-counterclaim: #6e8a8c; /* desaturated teal */
|
||||
--chart-track-parent-context: var(--color-text-subtle);
|
||||
|
||||
--chart-today-rule: var(--color-accent);
|
||||
--chart-grid-line: var(--color-border);
|
||||
--chart-bg: var(--color-bg);
|
||||
--chart-bg-weekend: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.smart-timeline-chart[data-palette="kind-coded"] {
|
||||
/* override --chart-bar-* — track tokens stay neutral so kind dominates */
|
||||
--chart-track-parent: var(--color-text-subtle);
|
||||
--chart-track-counterclaim: var(--color-text-subtle);
|
||||
}
|
||||
|
||||
.smart-timeline-chart[data-palette="print"] {
|
||||
--chart-bar-deadline: #000;
|
||||
--chart-bar-appointment: #555;
|
||||
--chart-bar-milestone: #000;
|
||||
--chart-bar-projected: #aaa;
|
||||
/* …and so on; the palette is a pure CSS swap */
|
||||
}
|
||||
```
|
||||
|
||||
### 5.3 Why no per-user color picker in v1
|
||||
|
||||
A per-user palette picker is a feature with a long tail (storage in user prefs, defaults vs overrides, migration when palette tokens change names, theme conflicts with light/dark). The fixed-preset surface answers 90 % of "I want different colors" with 10 % of the cost. If real users say "I want my-firm-blue", we add a v2 admin-level palette override (`paliad.firm_palette` row keyed by `FIRM_NAME`).
|
||||
|
||||
### 5.4 Light / dark / print
|
||||
|
||||
Existing dark-mode flip works automatically — the chart palette tokens *reference* `--color-*` family which is already dark-mode-aware. No extra surface. `@media print` overrides force the `print` palette regardless of the user-selected one — a print-out is always b&w-friendly.
|
||||
|
||||
---
|
||||
|
||||
## 6. Density + visual variants
|
||||
|
||||
### 6.1 Density modes
|
||||
|
||||
```ts
|
||||
type Density = "compact" | "standard" | "spacious";
|
||||
```
|
||||
|
||||
- `compact`: lane height 24px, bar height 12px, label inline-only (no description). Use for "1000-row birds-eye" lane mode.
|
||||
- `standard` (default): lane height 40px, bar height 20px, label + status pill.
|
||||
- `spacious`: lane height 64px, bar height 28px, label + pill + description below.
|
||||
|
||||
CSS-driven via `[data-density="…"]` on the chart root. The bar & dot SVG geometry is computed from a single `--lane-height` var; switching density is a re-layout pass, not a re-render.
|
||||
|
||||
### 6.2 Status / kind / shape variants
|
||||
|
||||
The visual encoding stays consistent with `shape-timeline.ts`:
|
||||
|
||||
| Kind | Vertical glyph | Horizontal mark |
|
||||
|---|---|---|
|
||||
| `deadline` | `…` / `!` (open / overdue) | Filled circle on due date; ring around it for "open" |
|
||||
| `appointment` | `▢` | Bar from `start_at` to `end_at` (or fixed-width if same-day) |
|
||||
| `milestone` | `⊕` | Diamond at the date |
|
||||
| `projected` | `░` | Hatched circle (predicted), dashed-circle (court_set), amber-outlined (predicted_overdue) |
|
||||
|
||||
Colour saturation drives `Status` independently: done = full color; open = lighter; predicted = 50% opacity; overdue = red overlay.
|
||||
|
||||
The CSS for the vertical mode already has these variants — the SVG mode replicates them via `<circle>` / `<rect>` + `fill` / `stroke-dasharray` attributes. Same visual language across modes is a non-negotiable.
|
||||
|
||||
---
|
||||
|
||||
## 7. Export pipeline
|
||||
|
||||
This is the most-requested part of the brief. Five formats; client-side only (no Go PDF dep, no headless browser).
|
||||
|
||||
### 7.1 The five formats
|
||||
|
||||
| Format | Content | Path | Why this path |
|
||||
|---|---|---|---|
|
||||
| **SVG** | Vector chart as-rendered | Browser: `new XMLSerializer().serializeToString(svgEl)` → Blob → download | Free — SVG IS our render. |
|
||||
| **PNG** | Raster chart at 2× device pixel ratio | Browser: SVG → `<img>` → `<canvas>.drawImage` → `canvas.toBlob()` | One stdlib API call chain. |
|
||||
| **PDF** | Print-formatted page | `window.print()` with `@media print` stylesheet; user picks "Save as PDF" | Reuses browser's hardened PDF engine — no Go PDF dep, no Chromium pinned to Dokploy. |
|
||||
| **CSV** | Tabular data, flat | Server: `GET /api/projects/{id}/timeline.csv` → text/csv | Cleanest for "Excel this" use case. |
|
||||
| **JSON** | Data-as-stored | Server: `GET /api/projects/{id}/timeline?format=json` (existing endpoint, alt content type) | Zero new code beyond a `Content-Disposition: attachment`. |
|
||||
| **iCal** | Deadlines + appointments as VEVENT | Server: `GET /api/projects/{id}/timeline.ics` reusing `caldav_ical.go` formatter | Lawyers can subscribe in Outlook / Apple Calendar. |
|
||||
|
||||
### 7.2 Why client-side for SVG/PNG/PDF, server-side for CSV/JSON/iCal
|
||||
|
||||
- **SVG/PNG/PDF need the rendered pixel layout.** Client has it, server doesn't (without a headless browser). Doing it on the client is a 30 LoC flow per format using stdlib browser APIs.
|
||||
- **CSV/JSON/iCal are pure data.** Server-side they hit the existing `ProjectionService` and stream straight to the client. CSV is `encoding/csv`; JSON is `json.Marshal`; iCal reuses the existing string-builder. Three new handlers, ~120 LoC total.
|
||||
|
||||
### 7.3 Why NOT server-side PDF
|
||||
|
||||
The clean alternative is "spin up `chromedp` on the Dokploy compose host, render the chart page, return PDF". Trade-off:
|
||||
|
||||
- Pro: one canonical PDF render, works the same regardless of user's browser.
|
||||
- Con: adds a Chromium runtime dep to the paliad Docker image (~150 MB), spins up a child process per export, opens an attack surface (someone exports a hostile SVG → Chromium handles it → CVE), and needs a queue (PDF render is 1-3s; a clicky user can DoS the box).
|
||||
|
||||
Browser print, by contrast, is in-process, free, sandboxed, and produces fine-looking PDFs. It loses pixel-perfect cross-browser parity, but lawyers care about content, not subpixel kerning.
|
||||
|
||||
**Recommend client-side print for v1.** Revisit if lawyers complain about cross-browser PDF differences. Adding `chromedp` later is a one-PR move; designing it into v1 risks shipping infra weight we may never need.
|
||||
|
||||
### 7.4 Print-mode CSS
|
||||
|
||||
The PDF path needs a robust `@media print`:
|
||||
|
||||
- Fix the chart to fit on landscape A4 (1100 × 760 px viewport).
|
||||
- Force `palette="print"`.
|
||||
- Hide chrome (sidebar, footer, header → `.print-hide` class on existing layout).
|
||||
- Show project metadata (title, parties, court, proceeding type) as a printed header.
|
||||
- Page-break logic: each lane group fits on one page; if a lane has too many events, split horizontally by year.
|
||||
|
||||
This print stylesheet can be extracted as `frontend/src/styles/chart-print.css` so it's auditable separately from the screen styles.
|
||||
|
||||
### 7.5 Export menu UI
|
||||
|
||||
Single button on the chart page header opens a menu:
|
||||
|
||||
```
|
||||
[ ⤓ Export ▼ ]
|
||||
├─ SVG (Vektorgrafik)
|
||||
├─ PNG (Bild, 2× HiDPI)
|
||||
├─ PDF (Drucken)
|
||||
├─ ───
|
||||
├─ CSV (Excel-Tabelle)
|
||||
├─ JSON (Rohdaten)
|
||||
└─ iCal (.ics — Outlook / Apple)
|
||||
```
|
||||
|
||||
Translated via existing i18n (`projects.detail.chart.export.*`). One menu, one keyboard shortcut (`Cmd+E` / `Ctrl+E`) opens it.
|
||||
|
||||
### 7.6 What's exported in CSV
|
||||
|
||||
Flat schema, one row per `TimelineEvent`:
|
||||
|
||||
```
|
||||
project_id,project_title,kind,status,track,lane_id,lane_label,date,
|
||||
title,description,rule_code,depends_on_rule_code,depends_on_date,
|
||||
sub_project_id,sub_project_title,bubble_up,deadline_id,appointment_id,
|
||||
project_event_id
|
||||
```
|
||||
|
||||
Columns mirror the wire `TimelineEvent` struct. UTF-8 with BOM (Excel-DE compat). Date format ISO-8601.
|
||||
|
||||
### 7.7 What's exported in JSON
|
||||
|
||||
The wire `ResponseEnvelope` directly: `{events: TimelineEvent[], lanes: LaneInfo[], meta: ProjectionMeta, exported_at, exported_by, project_id}`. Stable JSON schema; `meta` lets a future re-importer reconstruct the projection state exactly.
|
||||
|
||||
### 7.8 What's exported in iCal
|
||||
|
||||
Only `kind IN ("deadline", "appointment")` (projected rows are not stable enough to commit to a calendar). VEVENT block per row reuses `caldav_ical.go` formatter; UID is `paliad-deadline-<id>@paliad.de` so re-export overwrites prior subscription. Future projected rows omitted by design — they would clutter every lawyer's Outlook with rule_code-derived events that may or may not fire on the predicted date.
|
||||
|
||||
---
|
||||
|
||||
## 8. Surfaces — three places the chart shows up
|
||||
|
||||
### 8.1 Verlauf tab embed (`/projects/{id}` — existing)
|
||||
|
||||
Vertical DOM mode only (existing `shape-timeline.ts`). Density `standard`. Palette `default`. Lane count obeys substrate. **No changes** in this design — the embed stays exactly as it is. The chart-mode opt-in lives below the tab.
|
||||
|
||||
A new "**Als Chart anzeigen ↗**" link in the SmartTimeline header opens `/projects/{id}/chart` in a new tab. Optionally (Q3 below) we could host a chart inline with a `[Layout: ▽ Vertikal | ▷ Horizontal]` toggle.
|
||||
|
||||
### 8.2 Standalone `/projects/{id}/chart` (new)
|
||||
|
||||
Full-page surface optimized for the horizontal SVG renderer. Layout:
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
│ Siemens AG ./. Huawei — EP3456789 — UPC-CFI München │
|
||||
│ Verfahrenstyp: UPC-Verletzung Anker: Klageschrift @ 2026-04-29 │
|
||||
│ │
|
||||
│ [Layout ▷] [Spalten Auto] [Dichte Standard] [Palette Default] [Export ⤓]│
|
||||
├───────────────────────────────────────────────────────────────────────┤
|
||||
│ ━━━━ FilterBar (existing primitive) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
├───────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──── Horizontal SVG chart (full bleed) ───┐ │
|
||||
│ │ │ │
|
||||
│ │ ←─── 2026 ────→ 2027 ────→ │ │
|
||||
│ │ Self ●─●───●──── ░──░──░ │ │
|
||||
│ │ CCR ⊕────░───░──░ │ │
|
||||
│ │ │ │
|
||||
│ └──────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
URL convention: `/projects/{id}/chart?layout=horizontal&palette=default&density=standard&zoom=4`. State persists in URL so the link is shareable and copy-pasteable. localStorage caches the last chosen state per user as the default.
|
||||
|
||||
### 8.3 Custom Views shape (`shape="timeline"`)
|
||||
|
||||
Registers `ShapeTimeline RenderShape = "timeline"` in `internal/services/render_spec.go` and adds a corresponding `frontend/src/client/views/shape-timeline-chart.ts` view-host wrapper that adapts a `ViewRow[]` → `TimelineEvent[]` array. This unlocks **cross-project timelines** as a Custom View — "all my UPC matters" or "everything where I'm in the team" rendered as one chart.
|
||||
|
||||
ViewRow → TimelineEvent is a lossy shim: `kind` and `track` map directly; `date` reuses `event_date`; cross-project lanes are auto-derived from `project_id`. Projected rows are not surfaced from `ViewService` (it doesn't run the calculator) — Custom Views show actuals only. We document that limitation, ship the shape, and revisit later if needed.
|
||||
|
||||
This is **§8.3's gating**: the standalone page (§8.2) and embed (§8.1) ship before the Custom Views shape. The shape is Slice 4 — last, optional, lower-priority.
|
||||
|
||||
---
|
||||
|
||||
## 9. Mobile behaviour
|
||||
|
||||
Three breakpoints, one rule:
|
||||
|
||||
| Width | Vertical embed | Standalone chart |
|
||||
|---|---|---|
|
||||
| ≥1024 px (desktop) | Existing | Horizontal SVG, full-bleed |
|
||||
| 640–1023 px (tablet) | Existing | Horizontal SVG, narrower viewport, density auto-switches to compact |
|
||||
| <640 px (phone) | Existing | **Force vertical** — horizontal Gantt on phone is unreadable |
|
||||
|
||||
The "force vertical on phone" rule is enforced server-side via the Accept-CH `Sec-CH-UA-Mobile` header (defensive) and client-side via `window.matchMedia("(max-width: 640px)")`. The user can override but the default flips.
|
||||
|
||||
A horizontal-on-phone variant with `overflow-x: scroll` is technically possible but UX-poor — date axis disappears off-screen, lawyer can't see context. Force vertical, force collapsing of lanes into stacked sections, keep the export menu reachable.
|
||||
|
||||
---
|
||||
|
||||
## 10. Performance
|
||||
|
||||
### 10.1 Current numbers
|
||||
|
||||
- Patent (5 child cases × 30 events) = 150 nodes typical
|
||||
- Client (100+ matters) = 100s of lane rows; aggregation already sub-filters to milestones-only at Client level → <500 nodes
|
||||
- Backend projection cost: ~285 ms cold cache for one project (per t-paliad-169 §13). Backend is not the bottleneck.
|
||||
|
||||
### 10.2 Where each renderer caps
|
||||
|
||||
| Renderer | Comfortable | Stressed | Breaks |
|
||||
|---|---|---|---|
|
||||
| DOM grid (vertical) | ≤300 nodes | 300-1000 (sluggish reflow) | 1000+ (frame drops on scroll) |
|
||||
| Hand-rolled SVG | ≤1000 nodes | 1000-3000 (slow zoom / pan) | 3000+ (paint cost) |
|
||||
| Canvas (not chosen) | ≤10 000 nodes | — | — |
|
||||
|
||||
We're sitting in the **comfortable band for both** for any plausible Paliad project. Numbers above 1000 happen only in pathological "show all my Client's matters" scenarios — and those are bound by levelPolicy aggregation already (Client-level Custom Views).
|
||||
|
||||
### 10.3 Mitigations if a real project exceeds the comfort zone
|
||||
|
||||
- **Lookahead cap** (existing): `?lookahead=N` keeps projected nodes capped at 7 by default (50 max). Future-only, doesn't help if there are 1000 actuals.
|
||||
- **Date-range filter**: chart shows only events in a date window (defaults `earliest..latest+30d` — no implicit cap). For pathological cases, user can narrow the range.
|
||||
- **Lane filter** (existing): hide / dim selected lanes on multi-lane render.
|
||||
|
||||
If a single matter genuinely has 1000+ actuals, the user has a deeper data-discipline problem and the right answer is to escalate, not to optimize a chart for it.
|
||||
|
||||
### 10.4 SVG paint budget
|
||||
|
||||
A 200-event chart in horizontal mode is ~600 SVG primitives (200 bars/dots × 3 elements: shape + label + tooltip-trigger). One initial paint = <50 ms on a low-end laptop. Subsequent zoom / pan re-runs the layout fn (10 ms) and re-attributes existing nodes (no re-create) — fast. We do not need virtualization in v1.
|
||||
|
||||
---
|
||||
|
||||
## 11. Phasing — 4 sequential slices
|
||||
|
||||
Each slice independently shippable. m's go/no-go gate after each.
|
||||
|
||||
### Slice 1 — Standalone `/projects/{id}/chart` page + horizontal SVG renderer (no exports yet)
|
||||
|
||||
What lands:
|
||||
|
||||
- New page route `GET /projects/{id}/chart` (handler `internal/handlers/chart_pages.go`, ~50 LoC). Reuses existing project gate.
|
||||
- New `frontend/src/projects-chart.tsx` page TSX (renders shell + mount target). ~100 LoC.
|
||||
- New `frontend/src/client/views/shape-timeline-chart.ts` SVG renderer (~500 LoC). Pure-function `layout(events, lanes, viewport)` + `paint(layout, palette, root)`.
|
||||
- Reuses the existing `GET /api/projects/{id}/timeline` endpoint — no backend change.
|
||||
- Mode toggle on Verlauf tab: `[Als Chart anzeigen ↗]` link → opens `/chart`.
|
||||
- Default palette + standard density + auto columns. **No** export, **no** palette picker, **no** density picker yet — controls render as inert chips.
|
||||
|
||||
What it gives m: the horizontal Gantt rendering, end-to-end. Lawyer can open `/chart`, see the matter in horizontal layout, share the URL.
|
||||
|
||||
### Slice 2 — Export pipeline (SVG / PNG / PDF / CSV / JSON / iCal)
|
||||
|
||||
What lands:
|
||||
|
||||
- Client-side: `frontend/src/client/views/chart-export.ts` (~150 LoC) handling SVG → PNG conversion, PDF print invocation, blob downloads. Three new i18n keys per format.
|
||||
- Server-side: `internal/handlers/projection.go` gains 3 new handlers — `handleProjectTimelineCSV`, `handleProjectTimelineJSON` (alt `?format=json` on existing), `handleProjectTimelineICS`. Each ~30 LoC.
|
||||
- New `frontend/src/styles/chart-print.css` for `@media print` and palette swap.
|
||||
- Export menu UI on chart page header.
|
||||
|
||||
What it gives m: every export format the brief asked for, no infra additions, lawyer-shareable PDFs.
|
||||
|
||||
### Slice 3 — Density + palette + zoom controls
|
||||
|
||||
What lands:
|
||||
|
||||
- Density toggle (`compact / standard / spacious`) — pure CSS-var + `[data-density]` attr swap, no re-fetch.
|
||||
- Palette picker (`default / kind-coded / track-coded / high-contrast / print`) — same pattern.
|
||||
- Zoom in / out controls + pan (mousewheel + drag).
|
||||
- Date-range narrower (FilterBar `time` axis already exists — wire it to chart viewport).
|
||||
- localStorage persistence per-user-per-project.
|
||||
|
||||
What it gives m: full visual customisation per the brief.
|
||||
|
||||
### Slice 4 — Custom Views integration (`shape="timeline"`)
|
||||
|
||||
What lands:
|
||||
|
||||
- Register `ShapeTimeline RenderShape = "timeline"` in `internal/services/render_spec.go` + validator.
|
||||
- New `frontend/src/client/views/shape-timeline-cv.ts` view-host adapter. Reuses Slice 1's renderer; adapts `ViewRow[]` to `TimelineEvent[]`.
|
||||
- `frontend/src/views.tsx` shape-switcher gets the 4th button.
|
||||
- Documented limitation: projected rows not surfaced in Custom Views.
|
||||
|
||||
What it gives m: "all my UPC matters as one chart" via Custom Views — cross-project chart on the existing CV substrate.
|
||||
|
||||
### What's NOT in any slice (v2 nice-to-haves)
|
||||
|
||||
- Per-user palette picker beyond fixed presets.
|
||||
- Server-side PDF render via `chromedp`.
|
||||
- Live collaborative cursors / annotation pins.
|
||||
- Animation / transitions when zoom changes.
|
||||
- Hybrid layouts (compact-strip + detail-list).
|
||||
- Color-coding with custom user-defined rules.
|
||||
|
||||
---
|
||||
|
||||
## 12. Files implementer will touch (Slice 1 only)
|
||||
|
||||
**Backend (Go):**
|
||||
- `internal/handlers/chart_pages.go` — new, ~50 LoC. `handleProjectChartPage(w, r)` returns the rendered TSX shell. Auth + project visibility gates as on `/projects/{id}`.
|
||||
- `internal/handlers/handlers.go` — register `GET /projects/{id}/chart`.
|
||||
|
||||
**Frontend (TS / TSX):**
|
||||
- `frontend/src/projects-chart.tsx` — new, ~100 LoC. Page shell with mount target + page-level controls scaffold (chips inert in Slice 1).
|
||||
- `frontend/src/client/views/shape-timeline-chart.ts` — new, ~500 LoC. SVG renderer:
|
||||
- `layout(events: TimelineEvent[], lanes: LaneInfo[], viewport: Viewport): ChartLayout` — pure function returning bar/dot positions + axis ticks + today-rule x.
|
||||
- `paint(layout: ChartLayout, palette: Palette, root: SVGSVGElement): void` — DOM-mutates the root.
|
||||
- `mount(host: HTMLElement, opts: ChartMountOpts): ChartHandle` — composes layout + paint + interaction (click → deep-link, hover → tooltip).
|
||||
- `frontend/src/client/projects-chart.ts` — new, ~150 LoC. Page boot: fetch `/api/projects/{id}/timeline`, mount renderer, wire URL state ↔ control chips (inert), wire SmartTimeline embed's `[Als Chart anzeigen ↗]` link from `frontend/src/client/projects-detail.ts`.
|
||||
- `frontend/src/styles/global.css` — `.smart-timeline-chart-*` CSS additions, ~120 LoC. Including the palette token swap CSS but not yet wired to a picker.
|
||||
- `frontend/src/client/i18n.ts` — ~25 keys under `projects.detail.chart.*` (page title, control labels, default-palette-name, etc.) DE+EN.
|
||||
- `frontend/build.ts` — register the new page bundle.
|
||||
|
||||
**Tests:**
|
||||
- `frontend/src/client/views/shape-timeline-chart.test.ts` — new, pure-function tests for `layout()` (ranges, tick generation, lane stacking, today-rule positioning, undated-row handling).
|
||||
|
||||
Slices 2-4 are scoped in §11; coder picks them up after m's gate.
|
||||
|
||||
---
|
||||
|
||||
## 13. Trade-offs flagged
|
||||
|
||||
- **SVG accessibility.** Hand-rolled SVG needs explicit ARIA scaffolding (`role="img"` + `<title>` + `<desc>` per group, `aria-label` per event mark) to be screen-reader-readable. This is real implementation work — DOM mode gets it for free. Mitigation: lockdown `role` and label conventions in the renderer and test with VoiceOver / NVDA before Slice 1 merges.
|
||||
- **Print-CSS quirks.** `window.print()` PDFs will look slightly different across Chrome / Safari / Firefox. Lawyers comparing two exports may notice. Mitigation: documentation states "use Chrome for archival exports". Pursue chromedp only if real complaints surface.
|
||||
- **No virtualization in v1.** A 1000-event chart is not virtualized — every node is in the DOM/SVG tree. Mitigation: existing levelPolicy aggregation + lookahead caps keep node counts bounded for plausible projects. Add virtualization only if a real project exceeds the comfort band.
|
||||
- **Two renderers means two paths to maintain.** A bug in vertical-mode rendering doesn't auto-fix the horizontal mode. Mitigation: both render the **same** `TimelineEvent` / `LaneInfo` data; the discriminator is just the layout fn. Rendering bugs tend to be in shared event-mark visual tokens (color, status pill) which CSS-token-swap centralizes anyway.
|
||||
- **Custom Views adapter is lossy.** Cross-project chart in CV doesn't show projected rows. Some users might expect them. Mitigation: in-page tooltip on first CV-chart open: "Custom Views show actual events only. Open the project's `/chart` for projected rules." A future v2 could push the projection through ViewService but the substrate redesign is non-trivial.
|
||||
- **Date-range default.** Defaulting to `earliest_event..latest_event+30d` means a matter with one ancient deadline forces the whole span on every render. Mitigation: clamp default range to `today-1y..today+1y`, with a chip for "Alles anzeigen" to expand. Keeps the typical render compact.
|
||||
- **`/chart` URL collision.** `/projects/{id}/chart` doesn't conflict with any existing route, but adding `/chart` at the project level forces the route table to stay tidy. Defensive: implementer greps `internal/handlers/handlers.go` before adding to confirm no collision.
|
||||
- **Browser-print PDF on Safari shows the menu bar.** Cosmetic; print stylesheet's `@page` directive helps, but Safari ignores some rules. Mitigation: documentation; lawyer-facing exports recommend Chrome.
|
||||
|
||||
---
|
||||
|
||||
## 14. Open questions for m
|
||||
|
||||
Listed with my (inventor) pick where I have one — m decides.
|
||||
|
||||
**Q1 — Default landing on `/projects/{id}/chart`: horizontal Gantt or vertical (with a toggle)?**
|
||||
My pick: horizontal Gantt as the default. The whole reason `/chart` exists is the horizontal mode; defaulting to vertical would make it a duplicate of Verlauf. Add a `[Layout ▷|▽]` toggle for users who want vertical-on-bigscreen.
|
||||
|
||||
**Q2 — Should the chart page replace Verlauf when accessed at desktop width, or stay a separate URL?**
|
||||
My pick: separate URL. Verlauf is the "scan & action" tab (click rows to mark deadlines done, add notes). Chart is the "share & overview" surface. Conflating them risks losing the inline-action affordance Verlauf was built for.
|
||||
|
||||
**Q3 — Should the chart be embeddable inside the Verlauf tab (with a layout toggle), or only standalone?**
|
||||
My pick: standalone in Slice 1; if user feedback says "I want to see horizontal on the project page directly", add the embed in a follow-up slice. Embedding doubles render cost on every project page open and creates layout pressure on the existing tab UI.
|
||||
|
||||
**Q4 — Chromedp / server-side PDF: rule out for v1, or design in?**
|
||||
My pick: rule out. Browser-print PDFs are good enough; Chromium-on-Dokploy is a heavy dep. Keep the door open by abstracting the export-button handler so a future server-side path is a one-route addition.
|
||||
|
||||
**Q5 — Color palette presets: ship the full 5 in Slice 3, or just `default` + `print` for safety?**
|
||||
My pick: ship all 5. The palette mechanism is just CSS-var swaps; adding the other three is hours of design polish, not weeks of work. More options give more lawyers their preferred read.
|
||||
|
||||
**Q6 — iCal export: only deadlines + appointments (recommendation), or include projected too?**
|
||||
My pick: only deadlines + appointments. Subscribing to a calendar that fills with rule_code-derived predicted dates that never fire would erode trust. Future projected = visualisation only, never calendar artifacts.
|
||||
|
||||
**Q7 — Custom Views integration (`shape="timeline"`): Slice 4 priority, or descope?**
|
||||
My pick: keep as Slice 4 but explicit go/no-go after Slice 3 ships. The cross-project chart is a *cool* demo but not in the original brief — descoping if real users haven't asked is fine.
|
||||
|
||||
**Q8 — Date-range default on `/chart`: data-driven (`earliest..latest+30d`) or fixed (`today-1y..today+1y`)?**
|
||||
My pick: fixed `today-1y..today+1y`, with a chip "Alles anzeigen" expanding. Old matters with one historical deadline shouldn't force a 5-year span on first render.
|
||||
|
||||
**Q9 — Should the chart support project comparison (chart 2-3 projects side-by-side)?**
|
||||
My pick: no — out of scope for this feature. That's a Custom Views job (multi-project query → chart shape), not a per-project surface concern.
|
||||
|
||||
**Q10 — Should we expose a permalink that captures *zoom + range + palette + density + lane-filter*?**
|
||||
My pick: yes, via URL query params (already designed in §8.2). Sharing a chart-URL via WhatsApp / email then renders the same view for the recipient.
|
||||
|
||||
**Q11 — Mobile: vertical-only fallback, or horizontal-with-scroll?**
|
||||
My pick: vertical-only on phones (<640px). Horizontal-with-scroll loses the date axis off-screen. Tablet (640-1023px) keeps horizontal in compact density.
|
||||
|
||||
**Q12 — On the SmartTimeline (Verlauf embed), do we also add an inline horizontal mode (Q3 follow-up)?**
|
||||
My pick: NO in v1. The standalone `/chart` is the new surface; Verlauf stays vertical. Adding both modes inline-Verlauf doubles the test matrix without clear user demand yet.
|
||||
|
||||
---
|
||||
|
||||
## 15. Recommendation for implementer
|
||||
|
||||
Pattern-fluent Sonnet coder. Slice 1 is the heaviest (new SVG renderer, new page, new TSX shell). Slice 2 needs careful CSS print-mode tuning — best paired with browser-screenshot iteration. Slice 3 is mostly CSS-token plumbing + UI controls. Slice 4 is the lightest if Slice 1 left the renderer well-decomposed.
|
||||
|
||||
Before Slice 1, the coder should sketch the `layout(events, lanes, viewport)` function on paper / a tests file — that's where the math lives, and getting it right deterministically is the difference between "works" and "subtle render glitches in obscure date ranges". Pure-function with table-driven tests for `layout()` is the correct approach.
|
||||
|
||||
Faraday (this worktree) parks. Not pre-emptively flipping to coder — m gates.
|
||||
|
||||
---
|
||||
|
||||
**DESIGN READY FOR REVIEW**
|
||||
@@ -21,12 +21,19 @@ export interface AxisCtx {
|
||||
patch(delta: Partial<BarState>): void;
|
||||
}
|
||||
|
||||
// RenderAxisOpts — per-surface tuning the bar threads through to axis
|
||||
// renderers. Currently only time-axis chip presets; future axes can grow
|
||||
// here without changing every call site.
|
||||
export interface RenderAxisOpts {
|
||||
timePresets?: NonNullable<BarState["time"]>["horizon"][];
|
||||
}
|
||||
|
||||
// renderAxis returns the HTML element for a single axis. The bar's
|
||||
// mountFilterBar appends the result to its internal toolbar. Returns
|
||||
// null when the axis is ignored (e.g. surface didn't declare it).
|
||||
export function renderAxis(axis: AxisKey, ctx: AxisCtx): HTMLElement | null {
|
||||
export function renderAxis(axis: AxisKey, ctx: AxisCtx, opts?: RenderAxisOpts): HTMLElement | null {
|
||||
switch (axis) {
|
||||
case "time": return renderTimeAxis(ctx);
|
||||
case "time": return renderTimeAxis(ctx, opts?.timePresets);
|
||||
case "project": return null; // populated lazily — see attachProjectAxis below
|
||||
case "personal_only": return renderPersonalOnlyAxis(ctx);
|
||||
case "approval_viewer_role": return renderApprovalRoleAxis(ctx);
|
||||
@@ -34,15 +41,17 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx): HTMLElement | null {
|
||||
case "approval_entity_type": return renderApprovalEntityTypeAxis(ctx);
|
||||
case "deadline_status": return renderDeadlineStatusAxis(ctx);
|
||||
case "appointment_type": return renderAppointmentTypeAxis(ctx);
|
||||
case "project_event_kind": return renderProjectEventKindAxis(ctx);
|
||||
case "timeline_status": return renderTimelineStatusAxis(ctx);
|
||||
case "timeline_track": return renderTimelineTrackAxis(ctx);
|
||||
case "shape": return renderShapeAxis(ctx);
|
||||
case "density": return renderDensityAxis(ctx);
|
||||
case "sort": return renderSortAxis(ctx);
|
||||
|
||||
// Per-source predicates that need their own widgets and a roundtrip
|
||||
// through fetched option lists. Phase 2+ will fill these in by
|
||||
// wiring the existing event-types / project-list components.
|
||||
// wiring the existing event-types component.
|
||||
case "deadline_event_type":
|
||||
case "project_event_kind":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -51,25 +60,44 @@ export function renderAxis(axis: AxisKey, ctx: AxisCtx): HTMLElement | null {
|
||||
// time — chip cluster (presets + Anpassen)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const TIME_PRESETS: Array<{ value: BarState["time"] extends infer T ? (T extends { horizon: infer H } ? H : never) : never; key: I18nKey }> = [
|
||||
{ value: "next_7d", key: "views.bar.time.next_7d" },
|
||||
{ value: "next_30d", key: "views.bar.time.next_30d" },
|
||||
{ value: "next_90d", key: "views.bar.time.next_90d" },
|
||||
{ value: "past_30d", key: "views.bar.time.past_30d" },
|
||||
{ value: "any", key: "views.bar.time.any" },
|
||||
type TimeHorizonValue = NonNullable<BarState["time"]>["horizon"];
|
||||
|
||||
const TIME_PRESET_LABELS: Record<TimeHorizonValue, I18nKey> = {
|
||||
next_7d: "views.bar.time.next_7d",
|
||||
next_30d: "views.bar.time.next_30d",
|
||||
next_90d: "views.bar.time.next_90d",
|
||||
past_7d: "views.bar.time.past_7d",
|
||||
past_30d: "views.bar.time.past_30d",
|
||||
past_90d: "views.bar.time.past_90d",
|
||||
any: "views.bar.time.any",
|
||||
all: "views.bar.time.all",
|
||||
custom: "views.bar.time.custom",
|
||||
};
|
||||
|
||||
const DEFAULT_TIME_PRESETS: TimeHorizonValue[] = [
|
||||
"next_7d", "next_30d", "next_90d", "past_30d", "any",
|
||||
];
|
||||
|
||||
function renderTimeAxis(ctx: AxisCtx): HTMLElement {
|
||||
function renderTimeAxis(ctx: AxisCtx, presetOverride?: TimeHorizonValue[]): HTMLElement {
|
||||
const wrap = group("views.bar.label.time");
|
||||
const row = chipRow();
|
||||
const presets = presetOverride && presetOverride.length ? presetOverride : DEFAULT_TIME_PRESETS;
|
||||
// "any" / "all" are both unbounded — clearing state is the cleanest
|
||||
// representation, so each maps to "no overlay" rather than a stored
|
||||
// horizon. The chip's active state then keys off "no time set".
|
||||
const current = ctx.get("time")?.horizon ?? "any";
|
||||
for (const preset of TIME_PRESETS) {
|
||||
const chip = chipBtn(t(preset.key), preset.value === current);
|
||||
for (const preset of presets) {
|
||||
if (preset === "custom") continue; // custom rendered separately below
|
||||
const isUnbounded = preset === "any" || preset === "all";
|
||||
const isActive = isUnbounded
|
||||
? !ctx.get("time")
|
||||
: preset === current;
|
||||
const chip = chipBtn(t(TIME_PRESET_LABELS[preset]), isActive);
|
||||
chip.addEventListener("click", () => {
|
||||
if (preset.value === "any") {
|
||||
if (isUnbounded) {
|
||||
ctx.patch({ time: undefined });
|
||||
} else {
|
||||
ctx.patch({ time: { horizon: preset.value } });
|
||||
ctx.patch({ time: { horizon: preset } });
|
||||
}
|
||||
});
|
||||
row.appendChild(chip);
|
||||
@@ -249,6 +277,140 @@ function renderAppointmentTypeAxis(ctx: AxisCtx): HTMLElement {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// project_event_kind — chip cluster (multi-select)
|
||||
//
|
||||
// Mirrors KnownProjectEventKinds in internal/services/filter_spec.go.
|
||||
// Labels reuse the existing `event.title.<kind>` translation table so
|
||||
// the chip text matches the Verlauf row title for the same event type.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const PROJECT_EVENT_KINDS: string[] = [
|
||||
"project_created",
|
||||
"project_archived",
|
||||
"project_reparented",
|
||||
"project_type_changed",
|
||||
"status_changed",
|
||||
"deadline_created",
|
||||
"deadline_completed",
|
||||
"deadline_reopened",
|
||||
"appointment_created",
|
||||
"appointment_updated",
|
||||
"appointment_deleted",
|
||||
"approval_decided",
|
||||
"member_role_changed",
|
||||
];
|
||||
|
||||
function renderProjectEventKindAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.project_event_kind");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("project_event_kind")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ project_event_kind: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("project_event_kind") ?? []);
|
||||
for (const kind of PROJECT_EVENT_KINDS) {
|
||||
const label = tDyn(`event.title.${kind}`);
|
||||
const chip = chipBtn(label, current.has(kind));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(kind)) current.delete(kind);
|
||||
else current.add(kind);
|
||||
ctx.patch({ project_event_kind: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// timeline_status — chip cluster (multi-select)
|
||||
//
|
||||
// SmartTimeline (t-paliad-173) status vocabulary spans actuals +
|
||||
// projections. Default: all. Macro chip pair "Zukunft anzeigen" /
|
||||
// "Nur vergangenes" toggles the [predicted, court_set] subset on
|
||||
// or off in one click.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const TIMELINE_STATUSES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "done", key: "views.bar.timeline_status.done" },
|
||||
{ value: "open", key: "views.bar.timeline_status.open" },
|
||||
{ value: "overdue", key: "views.bar.timeline_status.overdue" },
|
||||
{ value: "predicted", key: "views.bar.timeline_status.predicted" },
|
||||
{ value: "predicted_overdue", key: "views.bar.timeline_status.predicted_overdue" },
|
||||
{ value: "court_set", key: "views.bar.timeline_status.court_set" },
|
||||
{ value: "off_script", key: "views.bar.timeline_status.off_script" },
|
||||
];
|
||||
|
||||
function renderTimelineStatusAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.timeline_status");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("timeline_status")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ timeline_status: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("timeline_status") ?? []);
|
||||
for (const s of TIMELINE_STATUSES) {
|
||||
const chip = chipBtn(t(s.key), current.has(s.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(s.value)) current.delete(s.value);
|
||||
else current.add(s.value);
|
||||
ctx.patch({ timeline_status: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
// Macro chips. "Zukunft anzeigen" = include predicted+court_set; "Nur
|
||||
// vergangenes" = strip them. Implemented in terms of timeline_status.
|
||||
const future = chipBtn(t("views.bar.timeline_status.macro.future"), false);
|
||||
future.classList.add("filter-bar-chip-macro");
|
||||
future.addEventListener("click", () => {
|
||||
const next = new Set(["done", "open", "overdue", "predicted", "court_set", "predicted_overdue", "off_script"]);
|
||||
ctx.patch({ timeline_status: [...next] });
|
||||
});
|
||||
row.appendChild(future);
|
||||
const past = chipBtn(t("views.bar.timeline_status.macro.past"), false);
|
||||
past.classList.add("filter-bar-chip-macro");
|
||||
past.addEventListener("click", () => {
|
||||
ctx.patch({ timeline_status: ["done", "overdue", "off_script"] });
|
||||
});
|
||||
row.appendChild(past);
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// timeline_track — chip cluster (multi-select)
|
||||
//
|
||||
// Slice 2 only renders parent + off_script; counterclaim and child:<id>
|
||||
// values land with Slice 3's CCR sub-project FK migration. The renderer
|
||||
// stays ready for those values — chip rendering is dynamic on the
|
||||
// state set, not hard-coded to the catalogue below.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const TIMELINE_TRACKS: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "parent", key: "views.bar.timeline_track.parent" },
|
||||
{ value: "counterclaim", key: "views.bar.timeline_track.counterclaim" },
|
||||
{ value: "off_script", key: "views.bar.timeline_track.off_script" },
|
||||
];
|
||||
|
||||
function renderTimelineTrackAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.timeline_track");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("timeline_track")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ timeline_track: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("timeline_track") ?? []);
|
||||
for (const tr of TIMELINE_TRACKS) {
|
||||
const chip = chipBtn(t(tr.key), current.has(tr.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(tr.value)) current.delete(tr.value);
|
||||
else current.add(tr.value);
|
||||
ctx.patch({ timeline_track: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// shape — segmented control (list / cards / calendar)
|
||||
// ----------------------------------------------------------------------
|
||||
@@ -321,10 +483,6 @@ function renderSortAxis(ctx: AxisCtx): HTMLElement {
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// Suppress unused warning for tDyn — it's available for future axes
|
||||
// (deadline_event_type) that need dynamic enum labels.
|
||||
void tDyn;
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// shared helpers — group + chip + row
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
parseBar,
|
||||
encodeBar,
|
||||
} from "./url-codec";
|
||||
import { renderAxis, type AxisCtx } from "./axes";
|
||||
import { renderAxis, type AxisCtx, type RenderAxisOpts } from "./axes";
|
||||
import { openSaveModal } from "./save-modal";
|
||||
import type { BarState, MountOpts, BarHandle, EffectiveSpec, AxisKey } from "./types";
|
||||
|
||||
@@ -39,6 +39,11 @@ interface PrefsBlob {
|
||||
}
|
||||
|
||||
export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
|
||||
if (!!opts.customRunner === !!opts.systemViewSlug) {
|
||||
throw new Error(
|
||||
"mountFilterBar: exactly one of customRunner or systemViewSlug must be provided",
|
||||
);
|
||||
}
|
||||
let state: BarState = {};
|
||||
const ns = opts.urlNamespace;
|
||||
|
||||
@@ -64,18 +69,25 @@ export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
|
||||
lastEffective = effective;
|
||||
const myVersion = ++runVersion;
|
||||
try {
|
||||
const r = await fetch(`/api/views/${encodeURIComponent(opts.systemViewSlug)}/run`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filter: effective.filter }),
|
||||
});
|
||||
if (myVersion !== runVersion) return; // a newer click superseded us
|
||||
if (!r.ok) {
|
||||
opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective);
|
||||
return;
|
||||
let result: ViewRunResult;
|
||||
if (opts.customRunner) {
|
||||
result = await opts.customRunner(effective);
|
||||
} else {
|
||||
const slug = opts.systemViewSlug as string; // ctor guard guarantees this
|
||||
const r = await fetch(`/api/views/${encodeURIComponent(slug)}/run`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ filter: effective.filter }),
|
||||
});
|
||||
if (myVersion !== runVersion) return; // a newer click superseded us
|
||||
if (!r.ok) {
|
||||
opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective);
|
||||
return;
|
||||
}
|
||||
result = (await r.json()) as ViewRunResult;
|
||||
}
|
||||
const result = (await r.json()) as ViewRunResult;
|
||||
if (myVersion !== runVersion) return;
|
||||
opts.onResult(result, effective);
|
||||
} catch (_e) {
|
||||
if (myVersion !== runVersion) return;
|
||||
@@ -104,11 +116,15 @@ export function mountFilterBar(host: HTMLElement, opts: MountOpts): BarHandle {
|
||||
},
|
||||
};
|
||||
|
||||
const axisRenderOpts: RenderAxisOpts = {
|
||||
timePresets: opts.timePresets,
|
||||
};
|
||||
|
||||
// First paint.
|
||||
const renderToolbar = () => {
|
||||
toolbar.innerHTML = "";
|
||||
for (const axis of opts.axes) {
|
||||
const el = renderAxis(axis as AxisKey, ctx);
|
||||
const el = renderAxis(axis as AxisKey, ctx, axisRenderOpts);
|
||||
if (el) toolbar.appendChild(el);
|
||||
}
|
||||
if (showSave) {
|
||||
|
||||
@@ -21,6 +21,8 @@ export type AxisKey =
|
||||
| "approval_status"
|
||||
| "approval_entity_type"
|
||||
| "project_event_kind"
|
||||
| "timeline_status"
|
||||
| "timeline_track"
|
||||
| "shape"
|
||||
| "sort"
|
||||
| "density";
|
||||
@@ -49,6 +51,12 @@ export interface BarState {
|
||||
approval_status?: string[];
|
||||
approval_entity_type?: string[];
|
||||
project_event_kind?: string[];
|
||||
// SmartTimeline axes (t-paliad-173). timeline_status spans actuals +
|
||||
// projections; timeline_track is parent / counterclaim / off_script
|
||||
// and grows once Slice 3 lands the CCR sub-project FK (child:<id>
|
||||
// values dynamically populated then).
|
||||
timeline_status?: string[];
|
||||
timeline_track?: string[];
|
||||
|
||||
// Render
|
||||
shape?: RenderShape;
|
||||
@@ -57,7 +65,7 @@ export interface BarState {
|
||||
}
|
||||
|
||||
export interface TimeOverlay {
|
||||
horizon: "next_7d" | "next_30d" | "next_90d" | "past_30d" | "past_90d" | "any" | "all" | "custom";
|
||||
horizon: "next_7d" | "next_30d" | "next_90d" | "past_7d" | "past_30d" | "past_90d" | "any" | "all" | "custom";
|
||||
from?: string; // ISO 8601 — only when horizon === "custom"
|
||||
to?: string;
|
||||
}
|
||||
@@ -98,10 +106,23 @@ export interface MountOpts {
|
||||
showSaveAsView?: boolean;
|
||||
|
||||
// Slug of the surface's underlying system view (or saved user view).
|
||||
// POSTed to /api/views/{slug}/run with the override body. Required —
|
||||
// the bar runs through that endpoint, never the ad-hoc /api/views/run,
|
||||
// so the substrate's reserved-slug path stays the canonical entry.
|
||||
systemViewSlug: string;
|
||||
// POSTed to /api/views/{slug}/run with the override body. Required
|
||||
// unless `customRunner` is supplied — see below. When the bar runs
|
||||
// through this endpoint it is the substrate's canonical entry.
|
||||
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>;
|
||||
|
||||
// Per-surface override of the time-axis chip presets. Order is
|
||||
// preserved. Default presets are forward-looking (next_*+past_30d+any)
|
||||
// — backward-looking surfaces (Verlauf, audit) pass past_*+all here.
|
||||
timePresets?: NonNullable<BarState["time"]>["horizon"][];
|
||||
|
||||
// When true, the bar exposes an "Aktualisieren" affordance that
|
||||
// PATCHes /api/user-views/{userViewId} with the effective spec.
|
||||
|
||||
@@ -90,6 +90,12 @@ export function parseBar(params: URLSearchParams, ns?: string): BarState {
|
||||
const peKind = params.get(k("pe_kind"));
|
||||
if (peKind) out.project_event_kind = parseCSV(peKind);
|
||||
|
||||
// SmartTimeline (t-paliad-173) — status + track axes.
|
||||
const tlStatus = params.get(k("tl_status"));
|
||||
if (tlStatus) out.timeline_status = parseCSV(tlStatus);
|
||||
const tlTrack = params.get(k("tl_track"));
|
||||
if (tlTrack) out.timeline_track = parseCSV(tlTrack);
|
||||
|
||||
// render.shape
|
||||
const shape = params.get(k("shape"));
|
||||
if (shape === "list" || shape === "cards" || shape === "calendar") out.shape = shape;
|
||||
@@ -119,6 +125,7 @@ export function encodeBar(state: BarState, params: URLSearchParams, ns?: string)
|
||||
"app_type",
|
||||
"a_role", "a_status", "a_entity_type",
|
||||
"pe_kind",
|
||||
"tl_status", "tl_track",
|
||||
"shape", "sort", "density",
|
||||
]) {
|
||||
params.delete(k(key));
|
||||
@@ -155,6 +162,8 @@ export function encodeBar(state: BarState, params: URLSearchParams, ns?: string)
|
||||
if (state.approval_status?.length) params.set(k("a_status"), state.approval_status.join(","));
|
||||
if (state.approval_entity_type?.length) params.set(k("a_entity_type"), state.approval_entity_type.join(","));
|
||||
if (state.project_event_kind?.length) params.set(k("pe_kind"), state.project_event_kind.join(","));
|
||||
if (state.timeline_status?.length) params.set(k("tl_status"), state.timeline_status.join(","));
|
||||
if (state.timeline_track?.length) params.set(k("tl_track"), state.timeline_track.join(","));
|
||||
|
||||
if (state.shape) params.set(k("shape"), state.shape);
|
||||
if (state.sort) params.set(k("sort"), state.sort);
|
||||
@@ -166,6 +175,7 @@ function parseHorizon(s: string): TimeOverlay["horizon"] | null {
|
||||
case "next_7d":
|
||||
case "next_30d":
|
||||
case "next_90d":
|
||||
case "past_7d":
|
||||
case "past_30d":
|
||||
case "past_90d":
|
||||
case "any":
|
||||
|
||||
@@ -2490,8 +2490,12 @@ function writeStep1ContextToURL(ctx: Step1Context, replace = false) {
|
||||
|
||||
// isAdhocMode is read by the save-to-project CTA — ad-hoc has no
|
||||
// project to save against, so the CTA disables and renders a hint.
|
||||
// t-paliad-168: also true when no Step 1 context is set at all (the
|
||||
// "Verfahrensablauf einsehen" / sidebar deep-link browse path opens
|
||||
// Pathway A without an Akte). In both cases the user has no project
|
||||
// to save against; the CTA renders disabled with the same hint.
|
||||
function isAdhocMode(): boolean {
|
||||
return currentStep1Context.kind === "adhoc";
|
||||
return currentStep1Context.kind === "adhoc" || currentStep1Context.kind === "none";
|
||||
}
|
||||
|
||||
function adhocSummaryLabel(forum: AdhocForum): string {
|
||||
@@ -2705,6 +2709,12 @@ 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");
|
||||
});
|
||||
|
||||
// Step 3a cards — File / Draft / Enter. File drops into the existing
|
||||
// Pathway A wizard; Enter routes to the manual-create form;
|
||||
|
||||
@@ -21,6 +21,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.home": "Home",
|
||||
"nav.kostenrechner": "Kostenrechner",
|
||||
"nav.fristenrechner": "Fristenrechner",
|
||||
"nav.verfahrensablauf": "Verfahrensablauf",
|
||||
"nav.downloads": "Downloads",
|
||||
"nav.links": "Links",
|
||||
"nav.glossar": "Glossar",
|
||||
@@ -263,6 +264,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.step2.file.desc": "Outgoing — eine Frist tritt aus eigener Handlung ein.",
|
||||
"deadlines.step2.happened.title": "Etwas ist passiert",
|
||||
"deadlines.step2.happened.desc": "Incoming — ein Ereignis hat eine Frist ausgelöst.",
|
||||
"deadlines.step2.browse.title": "Verfahrensablauf einsehen",
|
||||
"deadlines.step2.browse.desc": "Browse / Learn — sehen, was wann passiert. Keine Frist eintragen.",
|
||||
"deadlines.save.cta.adhoc.hint": "Ad-hoc — kein Projekt, kein Speichern",
|
||||
"deadlines.step3a.heading": "Was möchten Sie einreichen?",
|
||||
"deadlines.step3a.back": "zurück zur Auswahl",
|
||||
@@ -1160,6 +1163,79 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.checklisten": "Checklisten",
|
||||
"projects.detail.verlauf.empty": "Noch keine Ereignisse aufgezeichnet.",
|
||||
"projects.detail.verlauf.loadMore": "Mehr laden",
|
||||
// SmartTimeline (t-paliad-171, Slice 1).
|
||||
"projects.detail.smarttimeline.empty": "Noch keine Ereignisse erfasst.",
|
||||
"projects.detail.smarttimeline.today": "Heute",
|
||||
"projects.detail.smarttimeline.section.past": "Vergangenheit",
|
||||
"projects.detail.smarttimeline.section.future": "Zukunft",
|
||||
"projects.detail.smarttimeline.section.undated": "Ohne Datum",
|
||||
"projects.detail.smarttimeline.kind.deadline": "Frist",
|
||||
"projects.detail.smarttimeline.kind.appointment": "Termin",
|
||||
"projects.detail.smarttimeline.kind.milestone": "Meilenstein",
|
||||
"projects.detail.smarttimeline.kind.projected": "Vorhersage",
|
||||
"projects.detail.smarttimeline.status.done": "Erledigt",
|
||||
"projects.detail.smarttimeline.status.open": "Offen",
|
||||
"projects.detail.smarttimeline.status.overdue": "Überfällig",
|
||||
"projects.detail.smarttimeline.status.court_set": "Datum vom Gericht",
|
||||
"projects.detail.smarttimeline.status.predicted": "Voraussichtlich",
|
||||
"projects.detail.smarttimeline.status.off_script": "Eigener Eintrag",
|
||||
"projects.detail.smarttimeline.audit.toggle.show": "Audit-Log anzeigen",
|
||||
"projects.detail.smarttimeline.audit.toggle.hide": "Nur Timeline-Einträge",
|
||||
"projects.detail.smarttimeline.add.cta": "+ Eintrag",
|
||||
"projects.detail.smarttimeline.add.modal.title": "Neuer Eintrag im SmartTimeline",
|
||||
"projects.detail.smarttimeline.add.choice.deadline": "Frist anlegen",
|
||||
"projects.detail.smarttimeline.add.choice.appointment": "Termin anlegen",
|
||||
"projects.detail.smarttimeline.add.choice.counterclaim": "Widerklage (CCR)",
|
||||
"projects.detail.smarttimeline.add.choice.amend": "Antrag auf Änderung (R.30)",
|
||||
"projects.detail.smarttimeline.add.choice.milestone": "Eigener Meilenstein",
|
||||
"projects.detail.smarttimeline.add.choice.disabled": "Kommt mit Slice 3",
|
||||
"projects.detail.smarttimeline.add.cancel": "Abbrechen",
|
||||
"projects.detail.smarttimeline.add.submit": "Speichern",
|
||||
"projects.detail.smarttimeline.milestone.title": "Titel",
|
||||
"projects.detail.smarttimeline.milestone.date": "Datum (optional)",
|
||||
"projects.detail.smarttimeline.milestone.description": "Beschreibung (optional)",
|
||||
"projects.detail.smarttimeline.error.title_required": "Bitte einen Titel angeben.",
|
||||
"projects.detail.smarttimeline.error.generic": "Konnte den Eintrag nicht speichern.",
|
||||
"projects.detail.smarttimeline.status.predicted_overdue": "Überfällig (vorhergesagt)",
|
||||
"projects.detail.smarttimeline.lookahead.more": "+ Mehr anzeigen",
|
||||
"projects.detail.smarttimeline.lookahead.less": "− Weniger",
|
||||
"projects.detail.smarttimeline.depends_on.prefix": "Folgt aus",
|
||||
"projects.detail.smarttimeline.depends_on.date_open": "Datum offen",
|
||||
"projects.detail.smarttimeline.depends_on.show_path": "Pfad anzeigen",
|
||||
"projects.detail.smarttimeline.depends_on.hide_path": "Pfad verbergen",
|
||||
"projects.detail.smarttimeline.depends_on.path_hint": "Klicke die übergeordnete Zeile, um deren Abhängigkeit zu sehen.",
|
||||
"projects.detail.smarttimeline.anchor.set": "Datum setzen",
|
||||
"projects.detail.smarttimeline.anchor.save": "Speichern",
|
||||
"projects.detail.smarttimeline.anchor.cancel": "Abbrechen",
|
||||
"projects.detail.smarttimeline.anchor.saving": "Speichere …",
|
||||
"projects.detail.smarttimeline.anchor.saved": "Gespeichert.",
|
||||
"projects.detail.smarttimeline.anchor.error": "Konnte das Datum nicht setzen.",
|
||||
"projects.detail.smarttimeline.anchor.invalid_date": "Ungültiges Datum (YYYY-MM-DD).",
|
||||
"projects.detail.smarttimeline.track.label": "Track",
|
||||
"projects.detail.smarttimeline.track.both": "Beide",
|
||||
"projects.detail.smarttimeline.track.only.parent": "Nur Hauptverfahren",
|
||||
"projects.detail.smarttimeline.track.only.counterclaim": "Nur Widerklage",
|
||||
"projects.detail.smarttimeline.track.only.parent_context": "Nur Hauptverfahren (Kontext)",
|
||||
"projects.detail.smarttimeline.track.header.parent": "Hauptverfahren",
|
||||
"projects.detail.smarttimeline.track.header.counterclaim": "Widerklage (CCR)",
|
||||
"projects.detail.smarttimeline.track.header.parent_context": "Hauptverfahren (Kontext)",
|
||||
"projects.detail.smarttimeline.counterclaim.procedure": "Verfahrenstyp",
|
||||
"projects.detail.smarttimeline.counterclaim.title": "Titel (optional)",
|
||||
"projects.detail.smarttimeline.counterclaim.case_number": "CCR-Aktenzeichen (optional)",
|
||||
"projects.detail.smarttimeline.counterclaim.flip_override": "Unsere Seite NICHT umkehren (Stimmt nicht?)",
|
||||
"projects.detail.smarttimeline.counterclaim.flip_hint": "Im Standardfall (CCR-Nichtigkeit) kehrt sich unsere Seite um (Kläger ↔ Beklagter). Aktivieren bei R.49.2.b CCI.",
|
||||
"projects.detail.smarttimeline.counterclaim.submit": "Widerklage anlegen",
|
||||
"projects.detail.smarttimeline.counterclaim.saving": "Lege Widerklage an …",
|
||||
"projects.detail.smarttimeline.lane.empty": "Keine Einträge in dieser Spur.",
|
||||
"projects.detail.smarttimeline.lane.filter.label": "Spuren",
|
||||
"projects.detail.smarttimeline.lane.filter.all": "Alle",
|
||||
"projects.detail.smarttimeline.client.toggle.lanes": "Timeline-Ansicht",
|
||||
"projects.detail.smarttimeline.client.toggle.matter_list": "Mandatsliste",
|
||||
"projects.detail.smarttimeline.client.matter_list.heading": "Verfahren des Mandanten",
|
||||
"projects.detail.smarttimeline.client.matter_list.hint": "Klicke ein Verfahren an, um die Detail-Timeline zu öffnen, oder schalte oben auf „Timeline-Ansicht“.",
|
||||
"projects.detail.smarttimeline.client.matter_list.empty": "Noch keine Verfahren angelegt.",
|
||||
"projects.detail.smarttimeline.milestone.bubble_up": "In übergeordneten Akten anzeigen",
|
||||
"projects.detail.smarttimeline.milestone.bubble_up_hint": "Beim Aktivieren erscheint dieser Meilenstein auf Patent-, Verfahrens- und Mandantsicht.",
|
||||
"projects.detail.team.form.user": "Benutzer",
|
||||
"projects.detail.team.form.role": "Rolle",
|
||||
"projects.detail.team.form.responsibility": "Rolle im Projekt",
|
||||
@@ -2179,6 +2255,21 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.bar.label.approval_entity": "Art",
|
||||
"views.bar.label.deadline_status": "Frist-Status",
|
||||
"views.bar.label.appointment_type": "Termin-Typ",
|
||||
"views.bar.label.project_event_kind": "Ereignis",
|
||||
"views.bar.label.timeline_status": "Timeline-Status",
|
||||
"views.bar.label.timeline_track": "Track",
|
||||
"views.bar.timeline_status.done": "Erledigt",
|
||||
"views.bar.timeline_status.open": "Offen",
|
||||
"views.bar.timeline_status.overdue": "Überfällig",
|
||||
"views.bar.timeline_status.predicted": "Voraussichtlich",
|
||||
"views.bar.timeline_status.predicted_overdue": "Überfällig (vorhergesagt)",
|
||||
"views.bar.timeline_status.court_set": "Gerichtsdatum",
|
||||
"views.bar.timeline_status.off_script": "Eigener Eintrag",
|
||||
"views.bar.timeline_status.macro.future": "Zukunft anzeigen",
|
||||
"views.bar.timeline_status.macro.past": "Nur vergangenes",
|
||||
"views.bar.timeline_track.parent": "Hauptverfahren",
|
||||
"views.bar.timeline_track.counterclaim": "Widerklage",
|
||||
"views.bar.timeline_track.off_script": "Off-Script",
|
||||
"views.bar.label.shape": "Darstellung",
|
||||
"views.bar.label.density": "Dichte",
|
||||
"views.bar.label.sort": "Sortierung",
|
||||
@@ -2186,8 +2277,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.bar.time.next_7d": "7 Tage",
|
||||
"views.bar.time.next_30d": "30 Tage",
|
||||
"views.bar.time.next_90d": "90 Tage",
|
||||
"views.bar.time.past_7d": "Letzte 7 T.",
|
||||
"views.bar.time.past_30d": "Letzte 30 T.",
|
||||
"views.bar.time.past_90d": "Letzte 90 T.",
|
||||
"views.bar.time.any": "Beliebig",
|
||||
"views.bar.time.all": "Alle Zeit",
|
||||
"views.bar.time.custom": "Anpassen",
|
||||
"views.bar.time.custom.coming_soon": "Benutzerdefinierter Zeitraum folgt in einer der nächsten Iterationen.",
|
||||
"views.bar.personal.on": "Nur eigene",
|
||||
@@ -2233,6 +2327,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.home": "Home",
|
||||
"nav.kostenrechner": "Cost Calculator",
|
||||
"nav.fristenrechner": "Deadline Calculator",
|
||||
"nav.verfahrensablauf": "Procedure Roadmap",
|
||||
"nav.downloads": "Downloads",
|
||||
"nav.links": "Links",
|
||||
"nav.glossar": "Glossary",
|
||||
@@ -2472,6 +2567,8 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.step2.file.desc": "Outgoing — your action triggers a deadline.",
|
||||
"deadlines.step2.happened.title": "Something happened",
|
||||
"deadlines.step2.happened.desc": "Incoming — an event triggered a deadline.",
|
||||
"deadlines.step2.browse.title": "Browse procedure roadmap",
|
||||
"deadlines.step2.browse.desc": "Browse / Learn — see what happens when. No deadline entered.",
|
||||
"deadlines.save.cta.adhoc.hint": "Ad-hoc — no matter, no save",
|
||||
"deadlines.step3a.heading": "What do you want to file?",
|
||||
"deadlines.step3a.back": "back to selection",
|
||||
@@ -3357,6 +3454,78 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.checklisten": "Checklists",
|
||||
"projects.detail.verlauf.empty": "No events recorded yet.",
|
||||
"projects.detail.verlauf.loadMore": "Load more",
|
||||
"projects.detail.smarttimeline.empty": "No events captured yet.",
|
||||
"projects.detail.smarttimeline.today": "Today",
|
||||
"projects.detail.smarttimeline.section.past": "Past",
|
||||
"projects.detail.smarttimeline.section.future": "Future",
|
||||
"projects.detail.smarttimeline.section.undated": "Undated",
|
||||
"projects.detail.smarttimeline.kind.deadline": "Deadline",
|
||||
"projects.detail.smarttimeline.kind.appointment": "Appointment",
|
||||
"projects.detail.smarttimeline.kind.milestone": "Milestone",
|
||||
"projects.detail.smarttimeline.kind.projected": "Predicted",
|
||||
"projects.detail.smarttimeline.status.done": "Done",
|
||||
"projects.detail.smarttimeline.status.open": "Open",
|
||||
"projects.detail.smarttimeline.status.overdue": "Overdue",
|
||||
"projects.detail.smarttimeline.status.court_set": "Court-set date",
|
||||
"projects.detail.smarttimeline.status.predicted": "Predicted",
|
||||
"projects.detail.smarttimeline.status.off_script": "Custom",
|
||||
"projects.detail.smarttimeline.audit.toggle.show": "Show audit log",
|
||||
"projects.detail.smarttimeline.audit.toggle.hide": "Timeline only",
|
||||
"projects.detail.smarttimeline.add.cta": "+ Entry",
|
||||
"projects.detail.smarttimeline.add.modal.title": "New SmartTimeline entry",
|
||||
"projects.detail.smarttimeline.add.choice.deadline": "Add a deadline",
|
||||
"projects.detail.smarttimeline.add.choice.appointment": "Add an appointment",
|
||||
"projects.detail.smarttimeline.add.choice.counterclaim": "Counterclaim (CCR)",
|
||||
"projects.detail.smarttimeline.add.choice.amend": "Application to amend (R.30)",
|
||||
"projects.detail.smarttimeline.add.choice.milestone": "Custom milestone",
|
||||
"projects.detail.smarttimeline.add.choice.disabled": "Coming in Slice 3",
|
||||
"projects.detail.smarttimeline.add.cancel": "Cancel",
|
||||
"projects.detail.smarttimeline.add.submit": "Save",
|
||||
"projects.detail.smarttimeline.milestone.title": "Title",
|
||||
"projects.detail.smarttimeline.milestone.date": "Date (optional)",
|
||||
"projects.detail.smarttimeline.milestone.description": "Description (optional)",
|
||||
"projects.detail.smarttimeline.error.title_required": "Please enter a title.",
|
||||
"projects.detail.smarttimeline.error.generic": "Could not save the entry.",
|
||||
"projects.detail.smarttimeline.status.predicted_overdue": "Overdue (predicted)",
|
||||
"projects.detail.smarttimeline.lookahead.more": "+ Show more",
|
||||
"projects.detail.smarttimeline.lookahead.less": "− Show less",
|
||||
"projects.detail.smarttimeline.depends_on.prefix": "Follows from",
|
||||
"projects.detail.smarttimeline.depends_on.date_open": "Date open",
|
||||
"projects.detail.smarttimeline.depends_on.show_path": "Show path",
|
||||
"projects.detail.smarttimeline.depends_on.hide_path": "Hide path",
|
||||
"projects.detail.smarttimeline.depends_on.path_hint": "Click the parent row to see its dependency.",
|
||||
"projects.detail.smarttimeline.anchor.set": "Set date",
|
||||
"projects.detail.smarttimeline.anchor.save": "Save",
|
||||
"projects.detail.smarttimeline.anchor.cancel": "Cancel",
|
||||
"projects.detail.smarttimeline.anchor.saving": "Saving…",
|
||||
"projects.detail.smarttimeline.anchor.saved": "Saved.",
|
||||
"projects.detail.smarttimeline.anchor.error": "Could not set the date.",
|
||||
"projects.detail.smarttimeline.anchor.invalid_date": "Invalid date (YYYY-MM-DD).",
|
||||
"projects.detail.smarttimeline.track.label": "Track",
|
||||
"projects.detail.smarttimeline.track.both": "Both",
|
||||
"projects.detail.smarttimeline.track.only.parent": "Main proceeding only",
|
||||
"projects.detail.smarttimeline.track.only.counterclaim": "Counterclaim only",
|
||||
"projects.detail.smarttimeline.track.only.parent_context": "Main proceeding only (context)",
|
||||
"projects.detail.smarttimeline.track.header.parent": "Main proceeding",
|
||||
"projects.detail.smarttimeline.track.header.counterclaim": "Counterclaim (CCR)",
|
||||
"projects.detail.smarttimeline.track.header.parent_context": "Main proceeding (context)",
|
||||
"projects.detail.smarttimeline.counterclaim.procedure": "Proceeding type",
|
||||
"projects.detail.smarttimeline.counterclaim.title": "Title (optional)",
|
||||
"projects.detail.smarttimeline.counterclaim.case_number": "CCR case number (optional)",
|
||||
"projects.detail.smarttimeline.counterclaim.flip_override": "Do NOT flip our side („Stimmt nicht?”)",
|
||||
"projects.detail.smarttimeline.counterclaim.flip_hint": "In the standard case (CCR on validity) our side flips (claimant ↔ defendant). Enable for the R.49.2.b CCI edge case.",
|
||||
"projects.detail.smarttimeline.counterclaim.submit": "Create counterclaim",
|
||||
"projects.detail.smarttimeline.counterclaim.saving": "Creating counterclaim…",
|
||||
"projects.detail.smarttimeline.lane.empty": "No entries in this lane.",
|
||||
"projects.detail.smarttimeline.lane.filter.label": "Lanes",
|
||||
"projects.detail.smarttimeline.lane.filter.all": "All",
|
||||
"projects.detail.smarttimeline.client.toggle.lanes": "Timeline view",
|
||||
"projects.detail.smarttimeline.client.toggle.matter_list": "Matter list",
|
||||
"projects.detail.smarttimeline.client.matter_list.heading": "Matters of this client",
|
||||
"projects.detail.smarttimeline.client.matter_list.hint": "Click a matter to open its detailed timeline, or switch to „Timeline view“ above.",
|
||||
"projects.detail.smarttimeline.client.matter_list.empty": "No matters yet.",
|
||||
"projects.detail.smarttimeline.milestone.bubble_up": "Show on parent matters",
|
||||
"projects.detail.smarttimeline.milestone.bubble_up_hint": "When checked, this milestone surfaces on patent, litigation, and client SmartTimelines.",
|
||||
"projects.detail.team.form.user": "User",
|
||||
"projects.detail.team.form.role": "Role",
|
||||
"projects.detail.team.form.responsibility": "Project role",
|
||||
@@ -4372,6 +4541,21 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.bar.label.approval_entity": "Kind",
|
||||
"views.bar.label.deadline_status": "Deadline status",
|
||||
"views.bar.label.appointment_type": "Appointment type",
|
||||
"views.bar.label.project_event_kind": "Event",
|
||||
"views.bar.label.timeline_status": "Timeline status",
|
||||
"views.bar.label.timeline_track": "Track",
|
||||
"views.bar.timeline_status.done": "Done",
|
||||
"views.bar.timeline_status.open": "Open",
|
||||
"views.bar.timeline_status.overdue": "Overdue",
|
||||
"views.bar.timeline_status.predicted": "Predicted",
|
||||
"views.bar.timeline_status.predicted_overdue": "Overdue (predicted)",
|
||||
"views.bar.timeline_status.court_set": "Court date",
|
||||
"views.bar.timeline_status.off_script": "Custom",
|
||||
"views.bar.timeline_status.macro.future": "Show future",
|
||||
"views.bar.timeline_status.macro.past": "Past only",
|
||||
"views.bar.timeline_track.parent": "Main proceeding",
|
||||
"views.bar.timeline_track.counterclaim": "Counterclaim",
|
||||
"views.bar.timeline_track.off_script": "Off-script",
|
||||
"views.bar.label.shape": "Display",
|
||||
"views.bar.label.density": "Density",
|
||||
"views.bar.label.sort": "Sort",
|
||||
@@ -4379,8 +4563,11 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.bar.time.next_7d": "7 days",
|
||||
"views.bar.time.next_30d": "30 days",
|
||||
"views.bar.time.next_90d": "90 days",
|
||||
"views.bar.time.past_7d": "Past 7d",
|
||||
"views.bar.time.past_30d": "Past 30 d.",
|
||||
"views.bar.time.past_90d": "Past 90 d.",
|
||||
"views.bar.time.any": "Any",
|
||||
"views.bar.time.all": "All time",
|
||||
"views.bar.time.custom": "Custom",
|
||||
"views.bar.time.custom.coming_soon": "Custom date range arrives in a follow-up iteration.",
|
||||
"views.bar.personal.on": "Mine only",
|
||||
|
||||
@@ -9,6 +9,9 @@ import {
|
||||
prefillForm,
|
||||
readPayload,
|
||||
} from "./project-form";
|
||||
import { mountFilterBar, type BarHandle } from "./filter-bar";
|
||||
import type { FilterSpec, RenderSpec } from "./views/types";
|
||||
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -222,6 +225,86 @@ const EVENTS_PAGE_SIZE = 50;
|
||||
let eventsHasMore = false;
|
||||
let eventsLoadingMore = false;
|
||||
|
||||
// SmartTimeline (t-paliad-171 / t-paliad-173) — row set + audit-toggle
|
||||
// + Slice 2 lookahead state. timelineRows is what we render; the count
|
||||
// of future-projected rows the backend knows about is held separately
|
||||
// in timelineProjectedTotal so "Mehr anzeigen" can be shown when the
|
||||
// cap clipped some rows.
|
||||
let timelineRows: SmartTimelineEvent[] = [];
|
||||
let timelineAuditFull = parseAuditFullPersisted();
|
||||
let timelineLookahead = 7; // backend default; overridden from localStorage
|
||||
let timelineProjectedTotal = 0;
|
||||
|
||||
// Slice 3 — counterclaim parallel tracks. timelineAvailableTracks is
|
||||
// parsed from the X-Projection-Tracks response header; selectedTrack
|
||||
// is the user's [Track ▼] choice (default "all" → render every track).
|
||||
let timelineAvailableTracks: string[] = [];
|
||||
let timelineSelectedTrack = "all";
|
||||
|
||||
// Slice 4 — parent-node lane aggregation (t-paliad-175). Lanes come
|
||||
// from the response envelope's .lanes array. selectedLanes is the
|
||||
// user's lane-filter state — null = "all selected" (the default);
|
||||
// set explicitly when the user toggles a chip.
|
||||
let timelineLanes: SmartTimelineLane[] = [];
|
||||
let timelineSelectedLanes: string[] | null = null;
|
||||
|
||||
// Slice 4 — Client-level "Timeline-Ansicht" toggle. At Client-level
|
||||
// project pages, the Verlauf tab defaults to the matter-list rendering
|
||||
// (project tree); flipping the toggle swaps to the SmartTimeline lane
|
||||
// view. State persists in localStorage per project so navigating away
|
||||
// and back keeps the user's choice.
|
||||
let timelineClientShowLanes = false;
|
||||
|
||||
// t-paliad-170 — 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.
|
||||
let verlaufBar: BarHandle | null = null;
|
||||
interface VerlaufFilters {
|
||||
eventKinds?: 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.
|
||||
fromDate?: Date;
|
||||
toDate?: Date;
|
||||
}
|
||||
let verlaufFilters: VerlaufFilters = {};
|
||||
|
||||
function applyVerlaufFilters(rows: ProjectEvent[]): ProjectEvent[] {
|
||||
const f = verlaufFilters;
|
||||
if (!f.eventKinds && !f.fromDate && !f.toDate) return rows;
|
||||
return rows.filter((r) => {
|
||||
if (f.eventKinds && !f.eventKinds.has(r.event_type ?? "")) return false;
|
||||
const created = new Date(r.created_at);
|
||||
if (f.fromDate && created < f.fromDate) return false;
|
||||
if (f.toDate && created >= f.toDate) return false;
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
// horizonBounds mirrors computeViewSpecBounds in view_service.go for the
|
||||
// horizons that show up on the Verlauf bar. Forward-looking horizons
|
||||
// (next_*) are absent on this surface — the timePresets override hides
|
||||
// them — but the function tolerates them for forward-compatibility with
|
||||
// the SmartTimeline redesign.
|
||||
function horizonBounds(horizon: string): { from?: Date; to?: Date } {
|
||||
const now = new Date();
|
||||
const day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
||||
const offset = (days: number) => new Date(day.getTime() + days * 86400000);
|
||||
switch (horizon) {
|
||||
case "past_7d": return { from: offset(-7), to: offset(1) };
|
||||
case "past_30d": return { from: offset(-30), to: offset(1) };
|
||||
case "past_90d": return { from: offset(-90), to: offset(1) };
|
||||
case "next_7d": return { from: day, to: offset(7) };
|
||||
case "next_30d": return { from: day, to: offset(30) };
|
||||
case "next_90d": return { from: day, to: offset(90) };
|
||||
default: return {};
|
||||
}
|
||||
}
|
||||
|
||||
// Subtree aggregation mode (t-paliad-139). Default true → Fristen, Termine,
|
||||
// Verlauf show rows from this project AND all descendant projects with an
|
||||
// attribution chip per non-direct row. URL param `?subtree=false` flips to
|
||||
@@ -302,27 +385,277 @@ function subtreeParam(): string {
|
||||
return subtreeMode ? "" : "&direct_only=true";
|
||||
}
|
||||
|
||||
// rawEventsCursor tracks the last *raw* (pre-filter) event ID returned by
|
||||
// the legacy endpoint so cursor pagination keeps working when filters
|
||||
// drop most rows from a page. Without it, "Mehr laden" with a tight
|
||||
// filter could stall because events[] (post-filter) wouldn't reach back
|
||||
// to the actual pagination boundary.
|
||||
let rawEventsLastID: string | null = null;
|
||||
let rawEventsLastPageFull = false;
|
||||
|
||||
async function loadEvents(id: string) {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${id}/events?limit=${EVENTS_PAGE_SIZE}${subtreeParam()}`,
|
||||
);
|
||||
if (resp.ok) {
|
||||
events = (await resp.json()) ?? [];
|
||||
eventsHasMore = events.length === EVENTS_PAGE_SIZE;
|
||||
const raw: ProjectEvent[] = (await resp.json()) ?? [];
|
||||
rawEventsLastID = raw.length ? raw[raw.length - 1].id : null;
|
||||
rawEventsLastPageFull = raw.length === EVENTS_PAGE_SIZE;
|
||||
events = applyVerlaufFilters(raw);
|
||||
eventsHasMore = rawEventsLastPageFull;
|
||||
} else {
|
||||
events = [];
|
||||
rawEventsLastID = null;
|
||||
rawEventsLastPageFull = false;
|
||||
eventsHasMore = false;
|
||||
}
|
||||
} catch {
|
||||
events = [];
|
||||
rawEventsLastID = null;
|
||||
rawEventsLastPageFull = false;
|
||||
eventsHasMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
// SmartTimeline (t-paliad-171) — fetches the merged timeline from the
|
||||
// new /api/projects/{id}/timeline endpoint. Slice 1 returns actuals
|
||||
// (deadlines + appointments + opted-in project_events); future slices
|
||||
// add projected rows additively. The audit-full toggle broadens the
|
||||
// project_events filter to include rows without timeline_kind set.
|
||||
async function loadTimeline(id: string): Promise<void> {
|
||||
const params = new URLSearchParams();
|
||||
if (timelineAuditFull) params.set("include", "audit_full");
|
||||
if (!subtreeMode) params.set("direct_only", "true");
|
||||
if (timelineLookahead && timelineLookahead !== 7) {
|
||||
params.set("lookahead", String(timelineLookahead));
|
||||
}
|
||||
const qs = params.toString();
|
||||
const url = `/api/projects/${encodeURIComponent(id)}/timeline${qs ? "?" + qs : ""}`;
|
||||
try {
|
||||
const resp = await fetch(url);
|
||||
if (resp.ok) {
|
||||
// Slice 4 (t-paliad-175) — wire shape changed from
|
||||
// []TimelineEvent to envelope {events, lanes} so lane metadata
|
||||
// can ride alongside the rows. Defensive parse: tolerate both
|
||||
// shapes during the rolling deploy window (any cached older
|
||||
// backend response is treated as events-only).
|
||||
const body = await resp.json();
|
||||
if (Array.isArray(body)) {
|
||||
timelineRows = body;
|
||||
timelineLanes = [];
|
||||
} else {
|
||||
timelineRows = (body?.events ?? []) as SmartTimelineEvent[];
|
||||
timelineLanes = (body?.lanes ?? []) as SmartTimelineLane[];
|
||||
}
|
||||
// Pull projection meta from headers (Slice 2). When absent (e.g.
|
||||
// proxy strips them), fall back to the visible projected count
|
||||
// so "Mehr anzeigen" stays hidden — defensible default.
|
||||
const totalHdr = resp.headers.get("X-Projection-Total");
|
||||
timelineProjectedTotal = totalHdr ? parseInt(totalHdr, 10) || 0 : 0;
|
||||
const lookaheadHdr = resp.headers.get("X-Projection-Lookahead");
|
||||
if (lookaheadHdr) {
|
||||
const n = parseInt(lookaheadHdr, 10);
|
||||
if (!isNaN(n) && n > 0) timelineLookahead = n;
|
||||
}
|
||||
// Slice 3 — track list comes back as comma-separated tags.
|
||||
const tracksHdr = resp.headers.get("X-Projection-Tracks");
|
||||
timelineAvailableTracks = tracksHdr
|
||||
? tracksHdr.split(",").map((s) => s.trim()).filter((s) => s.length > 0)
|
||||
: ["parent"];
|
||||
// Drop a previously-selected track if it disappeared from the
|
||||
// response (e.g. CCR child was deleted between renders) — fall
|
||||
// back to "all" so the user doesn't get an empty pane.
|
||||
if (timelineSelectedTrack !== "all" && !timelineAvailableTracks.includes(timelineSelectedTrack)) {
|
||||
timelineSelectedTrack = "all";
|
||||
}
|
||||
// Drop selected lanes that disappeared between renders (e.g. a
|
||||
// child case was deleted). null sentinel means "all" so leave it.
|
||||
if (timelineSelectedLanes !== null) {
|
||||
const laneIds = new Set(timelineLanes.map((l) => l.id));
|
||||
timelineSelectedLanes = timelineSelectedLanes.filter((id) => laneIds.has(id));
|
||||
if (timelineSelectedLanes.length === 0) {
|
||||
timelineSelectedLanes = null;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
timelineRows = [];
|
||||
timelineProjectedTotal = 0;
|
||||
timelineAvailableTracks = [];
|
||||
timelineLanes = [];
|
||||
}
|
||||
} catch {
|
||||
timelineRows = [];
|
||||
timelineProjectedTotal = 0;
|
||||
timelineAvailableTracks = [];
|
||||
timelineLanes = [];
|
||||
}
|
||||
}
|
||||
|
||||
function renderTimeline() {
|
||||
const host = document.getElementById("project-smart-timeline");
|
||||
if (!host) return;
|
||||
const projectId = project?.id;
|
||||
|
||||
// Slice 4 — Client-level Timeline-Ansicht toggle. At Client-level
|
||||
// pages, the Verlauf default is the matter-list (project tree).
|
||||
// Flipping the toggle swaps to the SmartTimeline lane view.
|
||||
if (project?.type === "client" && !timelineClientShowLanes) {
|
||||
renderClientMatterList(host);
|
||||
return;
|
||||
}
|
||||
|
||||
renderSmartTimeline(host, timelineRows, {
|
||||
projectId,
|
||||
lang: getLang() === "en" ? "en" : "de",
|
||||
lookahead: timelineLookahead,
|
||||
projectedTotal: timelineProjectedTotal,
|
||||
availableTracks: timelineAvailableTracks,
|
||||
selectedTrack: timelineSelectedTrack,
|
||||
lanes: timelineLanes,
|
||||
selectedLanes: timelineSelectedLanes ?? undefined,
|
||||
onLaneFilterChange: async (next) => {
|
||||
// Persist the explicit selection so a re-fetch doesn't reset it.
|
||||
// Empty array = user unchecked everything → fall back to "all"
|
||||
// so we never render a blank pane.
|
||||
timelineSelectedLanes = next.length === 0 ? null : next;
|
||||
renderTimeline();
|
||||
},
|
||||
onTrackChange: async (next) => {
|
||||
timelineSelectedTrack = next;
|
||||
// Track filter is purely client-side (rows are already loaded);
|
||||
// re-render in place without a re-fetch.
|
||||
renderTimeline();
|
||||
},
|
||||
onChange: async () => {
|
||||
if (!projectId) return;
|
||||
await loadTimeline(projectId);
|
||||
renderTimeline();
|
||||
},
|
||||
onLookaheadChange: async (next) => {
|
||||
if (!projectId) return;
|
||||
timelineLookahead = next;
|
||||
writeLookaheadPersisted(next);
|
||||
await loadTimeline(projectId);
|
||||
renderTimeline();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// renderClientMatterList renders the Client-level default Verlauf view
|
||||
// — a simple list of direct child litigations with their reference and
|
||||
// status. This stands in for the existing project-tree component when
|
||||
// Timeline-Ansicht is OFF (the default at Client level per design §5.1
|
||||
// + Q12). User can flip the Timeline-Ansicht toggle to see the lane
|
||||
// SmartTimeline.
|
||||
function renderClientMatterList(host: HTMLElement) {
|
||||
host.innerHTML = "";
|
||||
host.classList.add("smart-timeline");
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-matter-list";
|
||||
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "smart-timeline-matter-list-heading";
|
||||
heading.textContent = t("projects.detail.smarttimeline.client.matter_list.heading");
|
||||
wrap.appendChild(heading);
|
||||
|
||||
const hint = document.createElement("p");
|
||||
hint.className = "form-hint";
|
||||
hint.textContent = t("projects.detail.smarttimeline.client.matter_list.hint");
|
||||
wrap.appendChild(hint);
|
||||
|
||||
// The lane info from the backend already contains the direct child
|
||||
// litigations (one entry per child). When empty, the message guides
|
||||
// the user to add a litigation first.
|
||||
if (timelineLanes.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "entity-events-empty";
|
||||
empty.textContent = t("projects.detail.smarttimeline.client.matter_list.empty");
|
||||
wrap.appendChild(empty);
|
||||
host.appendChild(wrap);
|
||||
return;
|
||||
}
|
||||
|
||||
const list = document.createElement("ul");
|
||||
list.className = "smart-timeline-matter-list-items";
|
||||
for (const lane of timelineLanes) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "smart-timeline-matter-list-item";
|
||||
if (lane.project_id) {
|
||||
const link = document.createElement("a");
|
||||
link.href = `/projects/${encodeURIComponent(lane.project_id)}`;
|
||||
link.textContent = lane.label;
|
||||
li.appendChild(link);
|
||||
} else {
|
||||
li.textContent = lane.label;
|
||||
}
|
||||
list.appendChild(li);
|
||||
}
|
||||
wrap.appendChild(list);
|
||||
host.appendChild(wrap);
|
||||
}
|
||||
|
||||
function lookaheadStorageKey(): string {
|
||||
const id = project?.id ?? "_";
|
||||
return `paliad.smarttimeline.lookahead.${id}`;
|
||||
}
|
||||
|
||||
function writeLookaheadPersisted(n: number) {
|
||||
try {
|
||||
if (n === 7) localStorage.removeItem(lookaheadStorageKey());
|
||||
else localStorage.setItem(lookaheadStorageKey(), String(n));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function readLookaheadPersisted(): number {
|
||||
try {
|
||||
const raw = localStorage.getItem(lookaheadStorageKey());
|
||||
if (!raw) return 7;
|
||||
const n = parseInt(raw, 10);
|
||||
if (isNaN(n) || n < 1 || n > 50) return 7;
|
||||
return n;
|
||||
} catch {
|
||||
return 7;
|
||||
}
|
||||
}
|
||||
|
||||
// Audit-full toggle persistence: per-project flag in localStorage so a
|
||||
// user who flips the legacy view on for one project doesn't see the
|
||||
// audit clutter on every other project they open.
|
||||
function auditFullStorageKey(): string {
|
||||
const id = project?.id ?? "_";
|
||||
return `paliad.smarttimeline.audit_full.${id}`;
|
||||
}
|
||||
|
||||
function parseAuditFullPersisted(): boolean {
|
||||
// Project ID isn't known yet at module init; fall back to false here
|
||||
// and re-read in initSmartTimelineAuditToggle once project is loaded.
|
||||
return false;
|
||||
}
|
||||
|
||||
function readPersistedAuditFull(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(auditFullStorageKey()) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writePersistedAuditFull(on: boolean) {
|
||||
try {
|
||||
if (on) localStorage.setItem(auditFullStorageKey(), "1");
|
||||
else localStorage.removeItem(auditFullStorageKey());
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMoreEvents(id: string) {
|
||||
if (eventsLoadingMore || !eventsHasMore || events.length === 0) return;
|
||||
const cursor = events[events.length - 1].id;
|
||||
if (eventsLoadingMore || !eventsHasMore || !rawEventsLastID) return;
|
||||
const cursor = rawEventsLastID;
|
||||
const btn = document.getElementById("project-events-loadmore") as HTMLButtonElement | null;
|
||||
eventsLoadingMore = true;
|
||||
if (btn) {
|
||||
@@ -335,8 +668,10 @@ async function loadMoreEvents(id: string) {
|
||||
);
|
||||
if (resp.ok) {
|
||||
const page: ProjectEvent[] = await resp.json();
|
||||
events = events.concat(page);
|
||||
eventsHasMore = page.length === EVENTS_PAGE_SIZE;
|
||||
rawEventsLastID = page.length ? page[page.length - 1].id : rawEventsLastID;
|
||||
rawEventsLastPageFull = page.length === EVENTS_PAGE_SIZE;
|
||||
events = events.concat(applyVerlaufFilters(page));
|
||||
eventsHasMore = rawEventsLastPageFull;
|
||||
}
|
||||
} catch {
|
||||
/* swallow — the button re-enables and the user can retry */
|
||||
@@ -346,7 +681,7 @@ async function loadMoreEvents(id: string) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = t("projects.detail.verlauf.loadMore");
|
||||
}
|
||||
renderEvents();
|
||||
renderTimeline();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,8 +864,8 @@ function initProjectAppointmentForm() {
|
||||
addBtn.style.display = "";
|
||||
await loadAppointments(project.id);
|
||||
renderAppointments();
|
||||
await loadEvents(project.id);
|
||||
renderEvents();
|
||||
await loadTimeline(project.id);
|
||||
renderTimeline();
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = data.error || t("projects.error.generic");
|
||||
@@ -618,8 +953,8 @@ function renderDeadlines() {
|
||||
if (resp.ok) {
|
||||
await loadDeadlines(project.id);
|
||||
renderDeadlines();
|
||||
await loadEvents(project.id);
|
||||
renderEvents();
|
||||
await loadTimeline(project.id);
|
||||
renderTimeline();
|
||||
} else {
|
||||
cb.checked = false;
|
||||
cb.disabled = false;
|
||||
@@ -727,62 +1062,10 @@ function renderHeader() {
|
||||
}
|
||||
}
|
||||
|
||||
function renderEvents() {
|
||||
const list = document.getElementById("project-events-list")!;
|
||||
const empty = document.getElementById("project-events-empty")!;
|
||||
const moreWrap = document.getElementById("project-events-loadmore-wrap");
|
||||
if (events.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "block";
|
||||
if (moreWrap) moreWrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = events
|
||||
.map((e) => {
|
||||
const { title, description } = translateEvent(e.event_type, e.title, e.description ?? null);
|
||||
const titleHTML = wrapEventTitleLink(e, esc(title));
|
||||
return `<li class="entity-event">
|
||||
<div class="entity-event-date">${fmtDateTime(e.created_at)}</div>
|
||||
<div class="entity-event-body">
|
||||
<div class="entity-event-title">${titleHTML}${attributionChip(e.project_id, e.project_title)}</div>
|
||||
${description ? `<div class="entity-event-desc">${esc(description)}</div>` : ""}
|
||||
</div>
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
// Row-level click handler: clicking anywhere on the card navigates to the
|
||||
// same target as the inner .entity-event-link, but inner <a>/<button> still
|
||||
// win (so the title link, Cmd-click open-in-new-tab, and any future action
|
||||
// buttons keep working) and text selection is unaffected — same pattern as
|
||||
// .entity-table rows (t-098/099). Cards without a link target render no
|
||||
// .entity-event-link and stay non-clickable. Replaces the t-102 ::before
|
||||
// overlay (t-paliad-103).
|
||||
list.querySelectorAll<HTMLLIElement>(".entity-event").forEach((eventEl) => {
|
||||
const link = eventEl.querySelector<HTMLAnchorElement>(".entity-event-link");
|
||||
if (!link) return;
|
||||
eventEl.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("a") || target.closest("button")) return;
|
||||
window.location.href = link.href;
|
||||
});
|
||||
});
|
||||
if (moreWrap) moreWrap.style.display = eventsHasMore ? "" : "none";
|
||||
}
|
||||
|
||||
// wrapEventTitleLink turns the event title into a hyperlink to the originating
|
||||
// entity when the metadata carries the right ID. Wired-up event families:
|
||||
// - checklist_* (except _deleted) → /checklists/instances/{checklist_instance_id}
|
||||
// - deadline_* (except _deleted, deadlines_imported) → /deadlines/{deadline_id}
|
||||
// - appointment_* (except _deleted) → /appointments/{appointment_id}
|
||||
// - note_created → /appointments/{id} | /deadlines/{id} | /projects/{id}
|
||||
// (notes have no standalone page; route to the most-specific parent)
|
||||
// _deleted events are intentionally not linked — the entity is gone.
|
||||
// deadlines_imported is bulk and has no single deadline_id, so it stays plain.
|
||||
// Pairs with the row-level click handler in renderEvents() (t-paliad-103):
|
||||
// the inner <a class="entity-event-link"> is the canonical, keyboard-tabbable
|
||||
// target; the surrounding card grows the click surface without breaking
|
||||
// text-selection or nested anchors.
|
||||
// wrapEventTitleLink — kept for the dashboard activity feed which reuses
|
||||
// eventDetailHref. The renderEvents() orphan it paired with was removed
|
||||
// in t-paliad-173; the SmartTimeline (renderTimeline) is now the only
|
||||
// project-page render path.
|
||||
function wrapEventTitleLink(e: ProjectEvent, escapedTitle: string): string {
|
||||
const href = eventDetailHref(e);
|
||||
if (href) {
|
||||
@@ -835,6 +1118,346 @@ function initEventsLoadMore() {
|
||||
});
|
||||
}
|
||||
|
||||
// initSmartTimelineAuditToggle — wires the "Audit-Log anzeigen" button
|
||||
// in the Verlauf tab header. When ON, the next /timeline fetch passes
|
||||
// ?include=audit_full so every paliad.project_events row surfaces (the
|
||||
// legacy chronological Verlauf view); OFF only shows rows that opted
|
||||
// into timeline_kind. State persists in localStorage per project.
|
||||
function initSmartTimelineAuditToggle(id: string) {
|
||||
const btn = document.getElementById("smart-timeline-audit-toggle") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
// Re-read from localStorage now that project is loaded.
|
||||
timelineAuditFull = readPersistedAuditFull();
|
||||
// Slice 2: lookahead state is also project-scoped — same pattern.
|
||||
timelineLookahead = readLookaheadPersisted();
|
||||
refreshAuditToggleLabel();
|
||||
|
||||
btn.addEventListener("click", async () => {
|
||||
timelineAuditFull = !timelineAuditFull;
|
||||
writePersistedAuditFull(timelineAuditFull);
|
||||
refreshAuditToggleLabel();
|
||||
await loadTimeline(id);
|
||||
renderTimeline();
|
||||
});
|
||||
}
|
||||
|
||||
function refreshAuditToggleLabel() {
|
||||
const btn = document.getElementById("smart-timeline-audit-toggle") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.setAttribute("aria-pressed", timelineAuditFull ? "true" : "false");
|
||||
btn.textContent = timelineAuditFull
|
||||
? t("projects.detail.smarttimeline.audit.toggle.hide")
|
||||
: t("projects.detail.smarttimeline.audit.toggle.show");
|
||||
btn.classList.toggle("subtree-toggle--active", timelineAuditFull);
|
||||
}
|
||||
|
||||
// Slice 4 — Client-level "Timeline-Ansicht" toggle (t-paliad-175 §5.1
|
||||
// Q12). Visible only on Client-level projects; default OFF (matter-list
|
||||
// view). When ON, the SmartTimeline lane view replaces the matter list.
|
||||
// State persists in localStorage per project.
|
||||
function clientShowLanesStorageKey(): string {
|
||||
const id = project?.id ?? "_";
|
||||
return `paliad.smarttimeline.client_show_lanes.${id}`;
|
||||
}
|
||||
|
||||
function readClientShowLanes(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(clientShowLanesStorageKey()) === "1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeClientShowLanes(on: boolean) {
|
||||
try {
|
||||
if (on) localStorage.setItem(clientShowLanesStorageKey(), "1");
|
||||
else localStorage.removeItem(clientShowLanesStorageKey());
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function initSmartTimelineClientToggle(id: string) {
|
||||
const btn = document.getElementById("smart-timeline-client-toggle") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
// Toggle is markup-rendered always; hide on non-Client projects.
|
||||
if (project?.type !== "client") {
|
||||
btn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
btn.style.display = "";
|
||||
timelineClientShowLanes = readClientShowLanes();
|
||||
refreshClientToggleLabel();
|
||||
btn.addEventListener("click", async () => {
|
||||
timelineClientShowLanes = !timelineClientShowLanes;
|
||||
writeClientShowLanes(timelineClientShowLanes);
|
||||
refreshClientToggleLabel();
|
||||
// Reload to make sure lanes are populated when flipping ON.
|
||||
await loadTimeline(id);
|
||||
renderTimeline();
|
||||
});
|
||||
}
|
||||
|
||||
function refreshClientToggleLabel() {
|
||||
const btn = document.getElementById("smart-timeline-client-toggle") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
btn.setAttribute("aria-pressed", timelineClientShowLanes ? "true" : "false");
|
||||
btn.textContent = timelineClientShowLanes
|
||||
? t("projects.detail.smarttimeline.client.toggle.matter_list")
|
||||
: t("projects.detail.smarttimeline.client.toggle.lanes");
|
||||
btn.classList.toggle("subtree-toggle--active", timelineClientShowLanes);
|
||||
}
|
||||
|
||||
// initSmartTimelineAddModal — wires the "+ Eintrag" CTA + modal. Only
|
||||
// the "Eigener Meilenstein" route is fully wired in Slice 1 (writes
|
||||
// to /api/projects/{id}/timeline/milestone); Frist + Termin are link
|
||||
// buttons to the existing flows; CCR + R.30 are disabled with a
|
||||
// "Slice 3" tooltip per the brief.
|
||||
function initSmartTimelineAddModal(id: string) {
|
||||
const cta = document.getElementById("smart-timeline-add-btn") as HTMLButtonElement | null;
|
||||
const modal = document.getElementById("smart-timeline-add-modal") as HTMLDivElement | null;
|
||||
if (!cta || !modal) return;
|
||||
|
||||
const choices = document.querySelector<HTMLDivElement>(".smart-timeline-add-choices");
|
||||
const form = document.getElementById("smart-timeline-milestone-form") as HTMLFormElement | null;
|
||||
const milestoneBtn = document.getElementById("smart-timeline-add-milestone") as HTMLButtonElement | null;
|
||||
const cancelBtn = document.getElementById("smart-timeline-milestone-cancel") as HTMLButtonElement | null;
|
||||
const closeBtn = document.getElementById("smart-timeline-modal-close") as HTMLButtonElement | null;
|
||||
const titleInput = document.getElementById("smart-timeline-milestone-title") as HTMLInputElement | null;
|
||||
const dateInput = document.getElementById("smart-timeline-milestone-date") as HTMLInputElement | null;
|
||||
const descInput = document.getElementById("smart-timeline-milestone-desc") as HTMLTextAreaElement | null;
|
||||
const msg = document.getElementById("smart-timeline-milestone-msg") as HTMLDivElement | null;
|
||||
const dlLink = document.getElementById("smart-timeline-add-deadline") as HTMLAnchorElement | null;
|
||||
const apptLink = document.getElementById("smart-timeline-add-appointment") as HTMLAnchorElement | null;
|
||||
|
||||
if (dlLink) dlLink.href = `/deadlines/new?project=${encodeURIComponent(id)}`;
|
||||
if (apptLink) apptLink.href = `/appointments/new?project=${encodeURIComponent(id)}`;
|
||||
|
||||
const open = () => {
|
||||
modal.style.display = "";
|
||||
if (choices) choices.style.display = "";
|
||||
if (form) form.style.display = "none";
|
||||
if (msg) {
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
}
|
||||
};
|
||||
const close = () => {
|
||||
modal.style.display = "none";
|
||||
if (form) form.reset();
|
||||
};
|
||||
|
||||
cta.addEventListener("click", open);
|
||||
if (closeBtn) closeBtn.addEventListener("click", close);
|
||||
if (cancelBtn) cancelBtn.addEventListener("click", close);
|
||||
|
||||
// Click outside the card → close.
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) close();
|
||||
});
|
||||
|
||||
if (milestoneBtn && form) {
|
||||
milestoneBtn.addEventListener("click", () => {
|
||||
if (choices) choices.style.display = "none";
|
||||
form.style.display = "";
|
||||
titleInput?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
if (form && titleInput) {
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const title = titleInput.value.trim();
|
||||
if (!title) {
|
||||
if (msg) {
|
||||
msg.textContent = t("projects.detail.smarttimeline.error.title_required");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const payload: Record<string, unknown> = { title };
|
||||
const desc = descInput?.value.trim();
|
||||
if (desc) payload.description = desc;
|
||||
const date = dateInput?.value;
|
||||
if (date) payload.occurred_at = date;
|
||||
// Slice 4 — bubble-up checkbox (t-paliad-175 §7.2 Q5). Default OFF
|
||||
// for custom_milestone; user opts in to surface this milestone on
|
||||
// Patent / Litigation / Client SmartTimelines.
|
||||
const bubbleEl = document.getElementById("smart-timeline-milestone-bubble-up") as HTMLInputElement | null;
|
||||
if (bubbleEl?.checked) payload.bubble_up = true;
|
||||
|
||||
const submitBtn = form.querySelector<HTMLButtonElement>("button[type=submit]")!;
|
||||
submitBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}/timeline/milestone`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (resp.ok) {
|
||||
close();
|
||||
await loadTimeline(id);
|
||||
renderTimeline();
|
||||
} else {
|
||||
const data = (await resp.json().catch(() => ({}))) as { error?: string };
|
||||
if (msg) {
|
||||
msg.textContent = data.error || t("projects.detail.smarttimeline.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
if (msg) {
|
||||
msg.textContent = t("projects.detail.smarttimeline.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Slice 3 — Widerklage (CCR) route: opens an inline form, fetches
|
||||
// proceeding types lazily on first open, posts to
|
||||
// /api/projects/{id}/counterclaim, navigates to the new child page on
|
||||
// success.
|
||||
initCounterclaimRoute(id, modal, choices, form);
|
||||
}
|
||||
|
||||
interface ProceedingTypeRow {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string;
|
||||
is_active: boolean;
|
||||
}
|
||||
|
||||
let proceedingTypesCache: ProceedingTypeRow[] | null = null;
|
||||
|
||||
async function loadProceedingTypes(): Promise<ProceedingTypeRow[]> {
|
||||
if (proceedingTypesCache) return proceedingTypesCache;
|
||||
try {
|
||||
const resp = await fetch("/api/proceeding-types-db");
|
||||
if (!resp.ok) return [];
|
||||
const rows = ((await resp.json()) ?? []) as ProceedingTypeRow[];
|
||||
proceedingTypesCache = rows.filter((r) => r.is_active);
|
||||
return proceedingTypesCache;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function initCounterclaimRoute(
|
||||
id: string,
|
||||
modal: HTMLDivElement,
|
||||
choices: HTMLDivElement | null,
|
||||
milestoneForm: HTMLFormElement | null,
|
||||
) {
|
||||
const trigger = document.getElementById("smart-timeline-add-counterclaim") as HTMLButtonElement | null;
|
||||
const form = document.getElementById("smart-timeline-counterclaim-form") as HTMLFormElement | null;
|
||||
const cancel = document.getElementById("smart-timeline-counterclaim-cancel") as HTMLButtonElement | null;
|
||||
const procedureSel = document.getElementById("smart-timeline-counterclaim-procedure") as HTMLSelectElement | null;
|
||||
const titleInput = document.getElementById("smart-timeline-counterclaim-title") as HTMLInputElement | null;
|
||||
const caseNumberInput = document.getElementById("smart-timeline-counterclaim-case-number") as HTMLInputElement | null;
|
||||
const flipToggle = document.getElementById("smart-timeline-counterclaim-flip-toggle") as HTMLInputElement | null;
|
||||
const msg = document.getElementById("smart-timeline-counterclaim-msg") as HTMLDivElement | null;
|
||||
|
||||
if (!trigger || !form) return;
|
||||
|
||||
const closeModal = () => {
|
||||
modal.style.display = "none";
|
||||
form.reset();
|
||||
};
|
||||
|
||||
trigger.addEventListener("click", async () => {
|
||||
if (choices) choices.style.display = "none";
|
||||
if (milestoneForm) milestoneForm.style.display = "none";
|
||||
form.style.display = "";
|
||||
if (msg) {
|
||||
msg.textContent = "";
|
||||
msg.className = "form-msg";
|
||||
}
|
||||
// Populate proceeding-type select on first open. Only UPC types
|
||||
// make sense for a CCR (Nichtigkeit/CCI); pre-select UPC_REV.
|
||||
if (procedureSel && procedureSel.options.length === 0) {
|
||||
const types = await loadProceedingTypes();
|
||||
const upcTypes = types.filter((t) => (t.jurisdiction ?? "").toUpperCase() === "UPC");
|
||||
const langEN = getLang() === "en";
|
||||
for (const ty of upcTypes) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = String(ty.id);
|
||||
opt.textContent = `${ty.code} — ${langEN ? ty.name_en || ty.name : ty.name}`;
|
||||
if (ty.code === "UPC_REV") opt.selected = true;
|
||||
procedureSel.appendChild(opt);
|
||||
}
|
||||
}
|
||||
titleInput?.focus();
|
||||
});
|
||||
|
||||
if (cancel) cancel.addEventListener("click", closeModal);
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const submitBtn = form.querySelector<HTMLButtonElement>("button[type=submit]")!;
|
||||
submitBtn.disabled = true;
|
||||
if (msg) {
|
||||
msg.textContent = t("projects.detail.smarttimeline.counterclaim.saving");
|
||||
msg.className = "form-msg";
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (procedureSel && procedureSel.value) {
|
||||
const n = parseInt(procedureSel.value, 10);
|
||||
if (!isNaN(n)) payload.proceeding_type_id = n;
|
||||
}
|
||||
const titleVal = titleInput?.value.trim();
|
||||
if (titleVal) payload.title = titleVal;
|
||||
const caseNum = caseNumberInput?.value.trim();
|
||||
if (caseNum) payload.case_number = caseNum;
|
||||
// flipToggle CHECKED = "Stimmt nicht?" = do NOT flip our_side.
|
||||
// Backend interprets flip_our_side=false as "keep parent's side".
|
||||
if (flipToggle && flipToggle.checked) {
|
||||
payload.flip_our_side = false;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${encodeURIComponent(id)}/counterclaim`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
const data = (await resp.json()) as { id?: string; url?: string };
|
||||
const dest = data.url ?? (data.id ? `/projects/${data.id}` : null);
|
||||
if (dest) {
|
||||
window.location.href = dest;
|
||||
return;
|
||||
}
|
||||
// No id back? Defensive: just close + reload timeline.
|
||||
closeModal();
|
||||
await loadTimeline(id);
|
||||
renderTimeline();
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json().catch(() => ({}))) as { error?: string };
|
||||
if (msg) {
|
||||
msg.textContent = data.error || t("projects.detail.smarttimeline.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
} catch {
|
||||
if (msg) {
|
||||
msg.textContent = t("projects.detail.smarttimeline.error.generic");
|
||||
msg.className = "form-msg form-msg-error";
|
||||
}
|
||||
} finally {
|
||||
submitBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderParties() {
|
||||
const tbody = document.getElementById("parties-body")!;
|
||||
const empty = document.getElementById("parties-empty")!;
|
||||
@@ -1153,10 +1776,10 @@ function initEditModal() {
|
||||
project = await resp.json();
|
||||
closeEditModal();
|
||||
if (project) {
|
||||
await Promise.all([loadAncestors(project.id), loadEvents(project.id)]);
|
||||
await Promise.all([loadAncestors(project.id), loadTimeline(project.id)]);
|
||||
renderHeader();
|
||||
renderBreadcrumb();
|
||||
renderEvents();
|
||||
renderTimeline();
|
||||
}
|
||||
} catch (err) {
|
||||
msg.textContent = t("projects.error.generic");
|
||||
@@ -1217,8 +1840,8 @@ function initPartiesForm() {
|
||||
addBtn.style.display = "";
|
||||
await loadParties(project.id);
|
||||
renderParties();
|
||||
await loadEvents(project.id);
|
||||
renderEvents();
|
||||
await loadTimeline(project.id);
|
||||
renderTimeline();
|
||||
} else {
|
||||
const data = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = data.error || t("projects.error.generic");
|
||||
@@ -1294,9 +1917,15 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// loadEvents stays in this Promise.all so the unfiltered Verlauf is
|
||||
// ready by first paint (avoids an empty-state flash before the bar's
|
||||
// customRunner finishes its first run, t-paliad-170). When the URL
|
||||
// carries filter params (?time=…, ?pe_kind=…) the bar's mount triggers
|
||||
// a second fetch that narrows to the requested rows — accepted cost.
|
||||
await Promise.all([
|
||||
loadParties(id),
|
||||
loadEvents(id),
|
||||
loadTimeline(id),
|
||||
loadDeadlines(id),
|
||||
loadAppointments(id),
|
||||
loadAncestors(id),
|
||||
@@ -1314,7 +1943,7 @@ async function main() {
|
||||
renderHeader();
|
||||
renderBreadcrumb();
|
||||
renderParties();
|
||||
renderEvents();
|
||||
renderTimeline();
|
||||
renderDeadlines();
|
||||
renderAppointments();
|
||||
renderChildren();
|
||||
@@ -1329,11 +1958,65 @@ async function main() {
|
||||
initDelete();
|
||||
initEventsLoadMore();
|
||||
initSubtreeToggles(id);
|
||||
initSmartTimelineAuditToggle(id);
|
||||
initSmartTimelineClientToggle(id);
|
||||
initSmartTimelineAddModal(id);
|
||||
initAttachUnitForm(id);
|
||||
initNotesContainer(id);
|
||||
mountVerlaufFilterBar(id);
|
||||
showTab(parseTab());
|
||||
}
|
||||
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
function mountVerlaufFilterBar(id: string): void {
|
||||
const host = document.getElementById("project-events-filter-bar");
|
||||
if (!host) return;
|
||||
|
||||
// Synthetic spec — never reaches the substrate (customRunner short-
|
||||
// circuits the bar's POST), but the bar's contract requires shapes
|
||||
// that the substrate validator would accept. Sources / scope mirror
|
||||
// what a future ProjectHistorySystemView would look like.
|
||||
const baseFilter: FilterSpec = {
|
||||
version: 1,
|
||||
sources: ["project_event"],
|
||||
scope: { projects: { mode: "explicit", ids: [id] } },
|
||||
time: { horizon: "any" },
|
||||
};
|
||||
const baseRender: RenderSpec = { shape: "list" };
|
||||
|
||||
verlaufBar = mountFilterBar(host, {
|
||||
baseFilter,
|
||||
baseRender,
|
||||
axes: ["time", "project_event_kind"],
|
||||
surfaceKey: "project-history",
|
||||
showSaveAsView: false,
|
||||
timePresets: ["past_7d", "past_30d", "past_90d", "any"],
|
||||
customRunner: async (effective) => {
|
||||
const kinds = effective.filter.predicates?.project_event?.event_types;
|
||||
verlaufFilters = {
|
||||
eventKinds: kinds && kinds.length ? new Set(kinds) : undefined,
|
||||
...horizonBounds(effective.filter.time?.horizon ?? "any"),
|
||||
};
|
||||
await loadEvents(id);
|
||||
return { rows: [], inaccessible_project_ids: [] };
|
||||
},
|
||||
onResult: () => renderTimeline(),
|
||||
});
|
||||
}
|
||||
|
||||
// initAttachUnitForm wires the "Partner Unit zuordnen" form on the Team
|
||||
// tab (project lead / global_admin only). The select is populated from
|
||||
// /api/partner-units excluding units already attached.
|
||||
@@ -1431,8 +2114,18 @@ function initSubtreeToggles(id: string) {
|
||||
subtreeMode = !subtreeMode;
|
||||
persistSubtreeMode();
|
||||
refreshLabels();
|
||||
await Promise.all([loadEvents(id), loadDeadlines(id), loadAppointments(id)]);
|
||||
renderEvents();
|
||||
// verlaufBar.refresh() drives loadEvents through the bar's
|
||||
// customRunner (so the current filter state stays applied).
|
||||
// verlaufBar.refresh() drives loadEvents through the bar's
|
||||
// customRunner, but render is now driven entirely by loadTimeline.
|
||||
const barRefresh = verlaufBar ? verlaufBar.refresh() : Promise.resolve();
|
||||
await Promise.all([
|
||||
barRefresh,
|
||||
loadTimeline(id),
|
||||
loadDeadlines(id),
|
||||
loadAppointments(id),
|
||||
]);
|
||||
renderTimeline();
|
||||
renderDeadlines();
|
||||
renderAppointments();
|
||||
});
|
||||
@@ -2046,7 +2739,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
onLangChange(() => {
|
||||
renderHeader();
|
||||
renderBreadcrumb();
|
||||
renderEvents();
|
||||
renderTimeline();
|
||||
renderParties();
|
||||
renderDeadlines();
|
||||
renderAppointments();
|
||||
|
||||
@@ -75,6 +75,7 @@ export function initSidebar() {
|
||||
initPaliadinLinks();
|
||||
initUserViewsGroup();
|
||||
initThemeToggle();
|
||||
fixVerfahrensablaufActive();
|
||||
const sidebar = document.querySelector<HTMLElement>(".sidebar");
|
||||
if (!sidebar) return;
|
||||
initSidebarResize(sidebar);
|
||||
@@ -443,6 +444,30 @@ 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");
|
||||
}
|
||||
}
|
||||
|
||||
function renderUserViewItem(view: UserViewLite, currentPath: string): HTMLElement {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/views/${encodeURIComponent(view.slug)}`;
|
||||
|
||||
960
frontend/src/client/views/shape-timeline.ts
Normal file
960
frontend/src/client/views/shape-timeline.ts
Normal file
@@ -0,0 +1,960 @@
|
||||
import { t, getLang } from "../i18n";
|
||||
|
||||
// shape-timeline (t-paliad-171 → t-paliad-175) — vertical timeline render
|
||||
// for the SmartTimeline. Two-column layout (date / event card), "Heute →"
|
||||
// rule separating past from future, status icon + kind chip per row.
|
||||
//
|
||||
// Slice 2 (t-paliad-173) adds:
|
||||
// - Kind="projected" rows in three flavours via Status:
|
||||
// "predicted" — fade-grey (future)
|
||||
// "court_set" — dashed border (court-determined)
|
||||
// "predicted_overdue" — amber-faded (past, no anchor yet)
|
||||
// - "[Datum setzen]" inline date editor → POST /timeline/anchor.
|
||||
// 200 → re-fetch + re-render. 409 → render the predecessor_missing
|
||||
// payload as inline error with a "Stattdessen <predecessor> erfassen"
|
||||
// link that pre-fills the editor for the parent rule.
|
||||
// - "Folgt aus: <Name> (<Date|„Datum offen“>)" footer on every row
|
||||
// with depends_on_rule_code, plus a "[Pfad anzeigen]" expander that
|
||||
// walks the parent chain back to the trigger.
|
||||
// - "[+ Mehr anzeigen]" / "[− Weniger]" lookahead toggle after the 7th
|
||||
// projected row, cap remembered in localStorage per project.
|
||||
//
|
||||
// Slice 4 (t-paliad-175) adds parent-node lane aggregation:
|
||||
// - When `lanes.length > 1` (Patent / Litigation / Client view), render
|
||||
// a horizontal lane-strip with one column per lane. Time axis stays
|
||||
// vertical within each lane; the lane sub-header names the child
|
||||
// project. CSS Grid handles the desktop side-by-side and collapses
|
||||
// to single-column on mobile (≤640px).
|
||||
// - Lane filter chip (multiselect) sits in the timeline header above
|
||||
// the strip; selecting a subset dims the others.
|
||||
// - Single-column flow stays the default at Case level (lanes mirror
|
||||
// tracks one-for-one).
|
||||
//
|
||||
// Wire shape: renderSmartTimeline(host, rows, opts). The TimelineEvent
|
||||
// shape is the wire contract from /api/projects/{id}/timeline.events;
|
||||
// LaneInfo[] from .lanes drives the lane-grouped layout.
|
||||
//
|
||||
// Design ref: docs/design-smart-timeline-2026-05-08.md §3 + §5 + §6 +
|
||||
// m/paliad#31 layered requirements.
|
||||
|
||||
export interface TimelineEvent {
|
||||
kind: "deadline" | "appointment" | "milestone" | "projected";
|
||||
status:
|
||||
| "done"
|
||||
| "open"
|
||||
| "overdue"
|
||||
| "court_set"
|
||||
| "predicted"
|
||||
| "predicted_overdue"
|
||||
| "off_script";
|
||||
track: string;
|
||||
date?: string | null;
|
||||
title: string;
|
||||
description?: string;
|
||||
rule_code?: string;
|
||||
|
||||
deadline_id?: string;
|
||||
appointment_id?: string;
|
||||
project_event_id?: string;
|
||||
|
||||
deadline_rule_id?: string;
|
||||
deadline_rule_party?: string;
|
||||
|
||||
sub_project_id?: string;
|
||||
sub_project_title?: string;
|
||||
|
||||
depends_on_rule_code?: string;
|
||||
depends_on_date?: string | null;
|
||||
depends_on_rule_name?: string;
|
||||
|
||||
// Slice 4 — parent-node aggregation (t-paliad-175). lane_id buckets
|
||||
// the row into one of the columns described by RenderOptions.lanes.
|
||||
// Empty / missing is treated as "self" (the legacy single-lane case).
|
||||
lane_id?: string;
|
||||
bubble_up?: boolean;
|
||||
}
|
||||
|
||||
export interface LaneInfo {
|
||||
id: string;
|
||||
label: string;
|
||||
project_id?: string;
|
||||
primary?: boolean;
|
||||
}
|
||||
|
||||
export interface PredecessorMissingPayload {
|
||||
error: "predecessor_missing";
|
||||
missing_rule_code: string;
|
||||
missing_rule_name_de: string;
|
||||
missing_rule_name_en: string;
|
||||
requested_rule_code: string;
|
||||
requested_rule_name_de: string;
|
||||
requested_rule_name_en: string;
|
||||
message_de: string;
|
||||
message_en: string;
|
||||
}
|
||||
|
||||
export interface RenderOptions {
|
||||
// Today's date as ISO YYYY-MM-DD; defaults to "now in browser TZ".
|
||||
today?: string;
|
||||
// The project the timeline belongs to. Required for anchor / skip
|
||||
// POSTs. When undefined, projected rows don't expose "Datum setzen".
|
||||
projectId?: string;
|
||||
// Language hint — falls back to getLang() when omitted.
|
||||
lang?: "de" | "en";
|
||||
// Called after a successful anchor write so the host can re-fetch
|
||||
// and re-render. Skipped when omitted.
|
||||
onChange?: () => void | Promise<void>;
|
||||
// Lookahead state for projected rows. Default 7 = backend default.
|
||||
lookahead?: number;
|
||||
// Total number of future predicted rows the backend knows about
|
||||
// (read from X-Projection-Total). When > visible projected count,
|
||||
// "Mehr anzeigen" is shown.
|
||||
projectedTotal?: number;
|
||||
// Called when the user toggles "Mehr / Weniger anzeigen". The host
|
||||
// updates state + re-fetches with the new ?lookahead=N.
|
||||
onLookaheadChange?: (next: number) => void | Promise<void>;
|
||||
|
||||
// Slice 3 — counterclaim parallel tracks. availableTracks lists every
|
||||
// track tag present in the response (parsed from X-Projection-Tracks).
|
||||
// When the list contains a non-"parent" entry, the [Track ▼] chip
|
||||
// surfaces. selectedTrack is the user's filter ("all" = render every
|
||||
// available track in parallel; otherwise render only the named tag).
|
||||
availableTracks?: string[];
|
||||
selectedTrack?: string;
|
||||
onTrackChange?: (next: string) => void | Promise<void>;
|
||||
|
||||
// Slice 4 — parent-node lane aggregation. When lanes.length > 1,
|
||||
// renderSmartTimeline renders a lane-strip layout (one column per
|
||||
// lane) instead of the single-column flow. selectedLanes is the
|
||||
// user's lane-filter chip; defaults to all lanes selected. Empty
|
||||
// array = nothing rendered (defensible for the user explicitly
|
||||
// unchecking every lane).
|
||||
lanes?: LaneInfo[];
|
||||
selectedLanes?: string[]; // ids; undefined = all lanes selected
|
||||
onLaneFilterChange?: (next: string[]) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function renderSmartTimeline(
|
||||
host: HTMLElement,
|
||||
rows: TimelineEvent[],
|
||||
opts: RenderOptions = {},
|
||||
): void {
|
||||
host.innerHTML = "";
|
||||
host.classList.add("smart-timeline");
|
||||
|
||||
// Slice 4 — lane-grouped rendering (t-paliad-175 §5). When the
|
||||
// backend reports more than one lane, every event already carries a
|
||||
// lane_id and the layout switches from single-column to lane strip.
|
||||
// Lane mode takes precedence over Track-mode (the two are different
|
||||
// axes — lanes group by *direct child project*, tracks group by
|
||||
// CCR-vs-parent on a single Case).
|
||||
const lanes = opts.lanes ?? [];
|
||||
const isLaneMode = lanes.length > 1;
|
||||
if (isLaneMode) {
|
||||
host.appendChild(renderLaneStrip(rows, lanes, opts));
|
||||
return;
|
||||
}
|
||||
|
||||
// Slice 3 — track filtering. The bar header carries the [Track ▼]
|
||||
// chip whenever the response advertised more than the default
|
||||
// "parent" track; the filter is applied here before any flow render.
|
||||
const availableTracks = (opts.availableTracks ?? []).filter((t) => !!t);
|
||||
const hasMultipleTracks = availableTracks.length > 1;
|
||||
const selectedTrack = opts.selectedTrack ?? "all";
|
||||
if (hasMultipleTracks) {
|
||||
host.appendChild(renderTrackChip(availableTracks, selectedTrack, opts));
|
||||
}
|
||||
|
||||
// Filter rows by the selected track. "all" leaves rows untouched
|
||||
// (parallel layout decides per-track partitioning below).
|
||||
const filteredRows =
|
||||
selectedTrack === "all"
|
||||
? rows
|
||||
: rows.filter((r) => (r.track ?? "parent") === selectedTrack);
|
||||
|
||||
if (filteredRows.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "smart-timeline-empty";
|
||||
empty.textContent = t("projects.detail.smarttimeline.empty");
|
||||
host.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
// When the user has selected "all" AND there are multiple tracks
|
||||
// present, render parallel columns side-by-side. Otherwise the
|
||||
// existing single-column flow serves both single-track projects and
|
||||
// an explicit "Nur Hauptverfahren / Nur Widerklage" filter.
|
||||
if (selectedTrack === "all" && hasMultipleTracks) {
|
||||
host.appendChild(renderParallelTracks(filteredRows, availableTracks, opts));
|
||||
return;
|
||||
}
|
||||
|
||||
// Single-column flow.
|
||||
host.appendChild(renderTimelineFlow(filteredRows, opts));
|
||||
}
|
||||
|
||||
// renderLaneStrip builds the parent-node aggregated layout (Slice 4).
|
||||
// One column per lane, each column shows the lane's own past/today/
|
||||
// future flow. Lane filter chip (multiselect) sits above the strip.
|
||||
// Lanes the user has unchecked render dimmed but still take up the
|
||||
// column slot — this preserves the time-axis alignment across lanes.
|
||||
function renderLaneStrip(
|
||||
rows: TimelineEvent[],
|
||||
lanes: LaneInfo[],
|
||||
opts: RenderOptions,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-lanes-wrap";
|
||||
|
||||
// Lane filter chip (Slice 4) — multiselect with "alle" / "keine".
|
||||
// Sits above the strip.
|
||||
wrap.appendChild(renderLaneFilterChip(lanes, opts));
|
||||
|
||||
const selected = new Set(opts.selectedLanes ?? lanes.map((l) => l.id));
|
||||
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "smart-timeline-lanes";
|
||||
grid.style.setProperty("--smart-timeline-lane-count", String(lanes.length));
|
||||
|
||||
// Group rows by lane_id. Rows without a lane_id default to the first
|
||||
// lane id so they don't disappear. For lane mode the backend always
|
||||
// sets lane_id explicitly; this fallback is defensive.
|
||||
const byLane = new Map<string, TimelineEvent[]>();
|
||||
for (const l of lanes) byLane.set(l.id, []);
|
||||
for (const r of rows) {
|
||||
const id = r.lane_id || lanes[0].id;
|
||||
if (!byLane.has(id)) byLane.set(id, []);
|
||||
byLane.get(id)!.push(r);
|
||||
}
|
||||
|
||||
for (const lane of lanes) {
|
||||
const col = document.createElement("div");
|
||||
col.className = "smart-timeline-lane";
|
||||
if (!selected.has(lane.id)) {
|
||||
col.classList.add("smart-timeline-lane--dimmed");
|
||||
}
|
||||
if (lane.primary) {
|
||||
col.classList.add("smart-timeline-lane--primary");
|
||||
}
|
||||
|
||||
const header = document.createElement("h4");
|
||||
header.className = "smart-timeline-lane-header";
|
||||
if (lane.project_id) {
|
||||
const link = document.createElement("a");
|
||||
link.href = `/projects/${encodeURIComponent(lane.project_id)}`;
|
||||
link.textContent = lane.label;
|
||||
link.className = "smart-timeline-lane-header-link";
|
||||
header.appendChild(link);
|
||||
} else {
|
||||
header.textContent = lane.label;
|
||||
}
|
||||
col.appendChild(header);
|
||||
|
||||
const laneRows = byLane.get(lane.id) ?? [];
|
||||
if (laneRows.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "smart-timeline-lane-empty";
|
||||
empty.textContent = t("projects.detail.smarttimeline.lane.empty");
|
||||
col.appendChild(empty);
|
||||
} else {
|
||||
col.appendChild(renderTimelineFlow(laneRows, opts));
|
||||
}
|
||||
grid.appendChild(col);
|
||||
}
|
||||
|
||||
wrap.appendChild(grid);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// renderLaneFilterChip — multiselect chip-row for the lane filter.
|
||||
// Defaults to all lanes selected; user toggles individual chips. The
|
||||
// "Alle" pseudo-chip resets to all selected.
|
||||
function renderLaneFilterChip(
|
||||
lanes: LaneInfo[],
|
||||
opts: RenderOptions,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-lane-filter";
|
||||
|
||||
const label = document.createElement("span");
|
||||
label.className = "smart-timeline-lane-filter-label";
|
||||
label.textContent = t("projects.detail.smarttimeline.lane.filter.label");
|
||||
wrap.appendChild(label);
|
||||
|
||||
const selected = new Set(opts.selectedLanes ?? lanes.map((l) => l.id));
|
||||
|
||||
const allBtn = document.createElement("button");
|
||||
allBtn.type = "button";
|
||||
allBtn.className = "smart-timeline-lane-chip smart-timeline-lane-chip--all";
|
||||
if (selected.size === lanes.length) {
|
||||
allBtn.classList.add("is-active");
|
||||
}
|
||||
allBtn.textContent = t("projects.detail.smarttimeline.lane.filter.all");
|
||||
allBtn.addEventListener("click", () => {
|
||||
if (opts.onLaneFilterChange) void opts.onLaneFilterChange(lanes.map((l) => l.id));
|
||||
});
|
||||
wrap.appendChild(allBtn);
|
||||
|
||||
for (const lane of lanes) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "smart-timeline-lane-chip";
|
||||
if (selected.has(lane.id)) chip.classList.add("is-active");
|
||||
chip.textContent = lane.label;
|
||||
chip.addEventListener("click", () => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(lane.id)) {
|
||||
next.delete(lane.id);
|
||||
} else {
|
||||
next.add(lane.id);
|
||||
}
|
||||
if (opts.onLaneFilterChange) void opts.onLaneFilterChange(Array.from(next));
|
||||
});
|
||||
wrap.appendChild(chip);
|
||||
}
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// renderParallelTracks builds a CSS-grid wrapper with one column per
|
||||
// track. Each column is a self-contained smart-timeline-flow with its
|
||||
// own past / today / future sections, plus a sub-header that names the
|
||||
// track ("Hauptverfahren" / "Widerklage — <CCR title>" / "Hauptverfahren
|
||||
// (Kontext)" for the parent_context view on a CCR child).
|
||||
//
|
||||
// Mobile collapse (≤640px) is owned by CSS via .smart-timeline-tracks
|
||||
// and a media query — the grid switches to a single column there with
|
||||
// each sub-header preserved so the user knows which track they're on.
|
||||
function renderParallelTracks(
|
||||
rows: TimelineEvent[],
|
||||
availableTracks: string[],
|
||||
opts: RenderOptions,
|
||||
): HTMLElement {
|
||||
const grid = document.createElement("div");
|
||||
grid.className = "smart-timeline-tracks";
|
||||
grid.style.setProperty("--smart-timeline-track-count", String(availableTracks.length));
|
||||
|
||||
// Group rows by track. Rows with no track default to "parent".
|
||||
const byTrack = new Map<string, TimelineEvent[]>();
|
||||
for (const tr of availableTracks) byTrack.set(tr, []);
|
||||
for (const r of rows) {
|
||||
const key = r.track && byTrack.has(r.track) ? r.track : "parent";
|
||||
if (!byTrack.has(key)) byTrack.set(key, []);
|
||||
byTrack.get(key)!.push(r);
|
||||
}
|
||||
|
||||
for (const trackTag of availableTracks) {
|
||||
const trackRows = byTrack.get(trackTag) ?? [];
|
||||
const col = document.createElement("div");
|
||||
col.className = `smart-timeline-track ${trackClassFor(trackTag)}`;
|
||||
|
||||
const header = document.createElement("h4");
|
||||
header.className = "smart-timeline-track-header";
|
||||
header.textContent = trackHeaderLabel(trackTag, trackRows);
|
||||
col.appendChild(header);
|
||||
|
||||
if (trackRows.length === 0) {
|
||||
const empty = document.createElement("div");
|
||||
empty.className = "smart-timeline-track-empty";
|
||||
empty.textContent = t("projects.detail.smarttimeline.empty");
|
||||
col.appendChild(empty);
|
||||
} else {
|
||||
col.appendChild(renderTimelineFlow(trackRows, opts));
|
||||
}
|
||||
grid.appendChild(col);
|
||||
}
|
||||
|
||||
return grid;
|
||||
}
|
||||
|
||||
// renderTimelineFlow renders the past / today / future / undated flow
|
||||
// for the given row set into a fresh container. Extracted from the
|
||||
// pre-Slice-3 renderSmartTimeline so it can be reused as a per-track
|
||||
// column in the parallel layout.
|
||||
function renderTimelineFlow(rows: TimelineEvent[], opts: RenderOptions): HTMLElement {
|
||||
const todayISO = opts.today ?? todayLocalISO();
|
||||
const past: TimelineEvent[] = [];
|
||||
const todays: TimelineEvent[] = [];
|
||||
const future: TimelineEvent[] = [];
|
||||
const undated: TimelineEvent[] = [];
|
||||
for (const r of rows) {
|
||||
const iso = dateOnlyISO(r.date);
|
||||
if (!iso) {
|
||||
undated.push(r);
|
||||
continue;
|
||||
}
|
||||
if (iso < todayISO) past.push(r);
|
||||
else if (iso === todayISO) todays.push(r);
|
||||
else future.push(r);
|
||||
}
|
||||
past.sort(byDateAsc);
|
||||
todays.sort(byDateAsc);
|
||||
future.sort(byDateAsc);
|
||||
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-flow";
|
||||
|
||||
if (past.length > 0) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "smart-timeline-section smart-timeline-section--past";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "smart-timeline-heading";
|
||||
heading.textContent = t("projects.detail.smarttimeline.section.past");
|
||||
section.appendChild(heading);
|
||||
for (const ev of past) section.appendChild(renderRow(ev, opts));
|
||||
wrap.appendChild(section);
|
||||
}
|
||||
|
||||
const todayRule = document.createElement("div");
|
||||
todayRule.className = "smart-timeline-today-rule";
|
||||
const todayLabel = document.createElement("span");
|
||||
todayLabel.className = "smart-timeline-today-label";
|
||||
todayLabel.textContent = `${t("projects.detail.smarttimeline.today")} (${formatDateOnly(todayISO)})`;
|
||||
todayRule.appendChild(todayLabel);
|
||||
wrap.appendChild(todayRule);
|
||||
|
||||
if (todays.length > 0) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "smart-timeline-section smart-timeline-section--today";
|
||||
for (const ev of todays) section.appendChild(renderRow(ev, opts));
|
||||
wrap.appendChild(section);
|
||||
}
|
||||
|
||||
if (future.length > 0) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "smart-timeline-section smart-timeline-section--future";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "smart-timeline-heading";
|
||||
heading.textContent = t("projects.detail.smarttimeline.section.future");
|
||||
section.appendChild(heading);
|
||||
for (const ev of future) section.appendChild(renderRow(ev, opts));
|
||||
section.appendChild(renderLookaheadToggle(future, opts));
|
||||
wrap.appendChild(section);
|
||||
} else {
|
||||
const lookaheadHost = renderLookaheadToggle(future, opts);
|
||||
if (lookaheadHost.childElementCount > 0) {
|
||||
wrap.appendChild(lookaheadHost);
|
||||
}
|
||||
}
|
||||
|
||||
if (undated.length > 0) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "smart-timeline-section smart-timeline-section--undated";
|
||||
const heading = document.createElement("h3");
|
||||
heading.className = "smart-timeline-heading";
|
||||
heading.textContent = t("projects.detail.smarttimeline.section.undated");
|
||||
section.appendChild(heading);
|
||||
for (const ev of undated) section.appendChild(renderRow(ev, opts));
|
||||
wrap.appendChild(section);
|
||||
}
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// renderTrackChip builds the [Track ▼] selector. Options are derived
|
||||
// from the response's available_tracks header — i18n keys translate
|
||||
// each option label, with the sub-project title surfacing for CCR
|
||||
// tracks ("Widerklage — <title>"). Persists the user's selection via
|
||||
// the host through opts.onTrackChange.
|
||||
function renderTrackChip(
|
||||
availableTracks: string[],
|
||||
selected: string,
|
||||
opts: RenderOptions,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-track-chip";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.className = "smart-timeline-track-chip-label";
|
||||
label.textContent = t("projects.detail.smarttimeline.track.label");
|
||||
wrap.appendChild(label);
|
||||
|
||||
const select = document.createElement("select");
|
||||
select.className = "smart-timeline-track-chip-select";
|
||||
|
||||
const allOpt = document.createElement("option");
|
||||
allOpt.value = "all";
|
||||
allOpt.textContent = t("projects.detail.smarttimeline.track.both");
|
||||
select.appendChild(allOpt);
|
||||
|
||||
for (const trackTag of availableTracks) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = trackTag;
|
||||
opt.textContent = trackOnlyLabel(trackTag);
|
||||
select.appendChild(opt);
|
||||
}
|
||||
|
||||
select.value = selected;
|
||||
select.addEventListener("change", () => {
|
||||
if (opts.onTrackChange) void opts.onTrackChange(select.value);
|
||||
});
|
||||
wrap.appendChild(select);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// trackClassFor maps a track tag to its CSS modifier so the column
|
||||
// gets the appropriate visual treatment (lime for parent, light shade
|
||||
// for counterclaim, faded for parent_context).
|
||||
function trackClassFor(trackTag: string): string {
|
||||
if (trackTag === "parent") return "smart-timeline-track--parent";
|
||||
if (trackTag.startsWith("counterclaim:")) return "smart-timeline-track--counterclaim";
|
||||
if (trackTag.startsWith("parent_context:")) return "smart-timeline-track--parent-context";
|
||||
return "smart-timeline-track--other";
|
||||
}
|
||||
|
||||
// trackHeaderLabel picks the column sub-header. For CCR tracks pulls
|
||||
// the sub_project_title from the first row in the track so the user
|
||||
// sees "Widerklage — <child title>". Falls back to a generic label
|
||||
// when the title is empty.
|
||||
function trackHeaderLabel(trackTag: string, rows: TimelineEvent[]): string {
|
||||
if (trackTag === "parent") {
|
||||
return t("projects.detail.smarttimeline.track.header.parent");
|
||||
}
|
||||
const firstWithTitle = rows.find((r) => r.sub_project_title);
|
||||
const subTitle = firstWithTitle?.sub_project_title ?? "";
|
||||
if (trackTag.startsWith("counterclaim:")) {
|
||||
const base = t("projects.detail.smarttimeline.track.header.counterclaim");
|
||||
return subTitle ? `${base} — ${subTitle}` : base;
|
||||
}
|
||||
if (trackTag.startsWith("parent_context:")) {
|
||||
const base = t("projects.detail.smarttimeline.track.header.parent_context");
|
||||
return subTitle ? `${base} — ${subTitle}` : base;
|
||||
}
|
||||
return trackTag;
|
||||
}
|
||||
|
||||
// trackOnlyLabel is the chip dropdown label for "show only this track".
|
||||
function trackOnlyLabel(trackTag: string): string {
|
||||
if (trackTag === "parent") {
|
||||
return t("projects.detail.smarttimeline.track.only.parent");
|
||||
}
|
||||
if (trackTag.startsWith("counterclaim:")) {
|
||||
return t("projects.detail.smarttimeline.track.only.counterclaim");
|
||||
}
|
||||
if (trackTag.startsWith("parent_context:")) {
|
||||
return t("projects.detail.smarttimeline.track.only.parent_context");
|
||||
}
|
||||
return trackTag;
|
||||
}
|
||||
|
||||
function renderLookaheadToggle(
|
||||
futureRows: TimelineEvent[],
|
||||
opts: RenderOptions,
|
||||
): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-lookahead";
|
||||
const total = opts.projectedTotal ?? 0;
|
||||
const projectedShown = futureRows.filter((r) => r.kind === "projected").length;
|
||||
const cur = opts.lookahead ?? 7;
|
||||
|
||||
if (total > projectedShown && opts.onLookaheadChange) {
|
||||
const more = document.createElement("button");
|
||||
more.type = "button";
|
||||
more.className = "smart-timeline-lookahead-btn";
|
||||
more.textContent = t("projects.detail.smarttimeline.lookahead.more");
|
||||
more.setAttribute(
|
||||
"aria-label",
|
||||
`${t("projects.detail.smarttimeline.lookahead.more")} (${total - projectedShown})`,
|
||||
);
|
||||
more.addEventListener("click", () => {
|
||||
const next = Math.min(50, cur + 7);
|
||||
void opts.onLookaheadChange?.(next);
|
||||
});
|
||||
wrap.appendChild(more);
|
||||
}
|
||||
if (cur > 7 && opts.onLookaheadChange) {
|
||||
const less = document.createElement("button");
|
||||
less.type = "button";
|
||||
less.className = "smart-timeline-lookahead-btn smart-timeline-lookahead-btn--less";
|
||||
less.textContent = t("projects.detail.smarttimeline.lookahead.less");
|
||||
less.addEventListener("click", () => {
|
||||
void opts.onLookaheadChange?.(7);
|
||||
});
|
||||
wrap.appendChild(less);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderRow(ev: TimelineEvent, opts: RenderOptions): HTMLElement {
|
||||
const li = document.createElement("article");
|
||||
li.className =
|
||||
`smart-timeline-row smart-timeline-row--${ev.kind} ` +
|
||||
`smart-timeline-row--${ev.status}`;
|
||||
if (ev.deadline_rule_party) {
|
||||
li.classList.add(`smart-timeline-row--party-${ev.deadline_rule_party}`);
|
||||
}
|
||||
|
||||
const dateCol = document.createElement("div");
|
||||
dateCol.className = "smart-timeline-date";
|
||||
dateCol.textContent = ev.date ? formatDateOnly(dateOnlyISO(ev.date) ?? "") : "—";
|
||||
li.appendChild(dateCol);
|
||||
|
||||
const body = document.createElement("div");
|
||||
body.className = "smart-timeline-body";
|
||||
|
||||
const head = document.createElement("div");
|
||||
head.className = "smart-timeline-row-head";
|
||||
|
||||
const icon = document.createElement("span");
|
||||
icon.className = "smart-timeline-status-icon";
|
||||
icon.textContent = statusGlyph(ev.status);
|
||||
icon.setAttribute("aria-label", t(statusKey(ev.status)));
|
||||
head.appendChild(icon);
|
||||
|
||||
const titleEl = document.createElement("span");
|
||||
titleEl.className = "smart-timeline-title";
|
||||
const href = deepLinkHref(ev);
|
||||
if (href) {
|
||||
const a = document.createElement("a");
|
||||
a.className = "smart-timeline-link";
|
||||
a.href = href;
|
||||
a.textContent = ev.title;
|
||||
titleEl.appendChild(a);
|
||||
} else {
|
||||
titleEl.textContent = ev.title;
|
||||
}
|
||||
head.appendChild(titleEl);
|
||||
|
||||
const kindChip = document.createElement("span");
|
||||
kindChip.className = `smart-timeline-kind-chip smart-timeline-kind-chip--${ev.kind}`;
|
||||
kindChip.textContent = t(kindKey(ev.kind));
|
||||
head.appendChild(kindChip);
|
||||
|
||||
if (ev.rule_code) {
|
||||
const ruleChip = document.createElement("span");
|
||||
ruleChip.className = "smart-timeline-rule-chip";
|
||||
ruleChip.textContent = ev.rule_code;
|
||||
head.appendChild(ruleChip);
|
||||
}
|
||||
|
||||
// "voraussichtlich" / "vom Gericht" / "überfällig" status pill on
|
||||
// projected rows so the user reads the row's nature at a glance.
|
||||
if (ev.kind === "projected") {
|
||||
const statusPill = document.createElement("span");
|
||||
statusPill.className = `smart-timeline-status-pill smart-timeline-status-pill--${ev.status}`;
|
||||
statusPill.textContent = t(statusKey(ev.status));
|
||||
head.appendChild(statusPill);
|
||||
}
|
||||
|
||||
body.appendChild(head);
|
||||
|
||||
if (ev.description) {
|
||||
const desc = document.createElement("div");
|
||||
desc.className = "smart-timeline-desc";
|
||||
desc.textContent = ev.description;
|
||||
body.appendChild(desc);
|
||||
}
|
||||
|
||||
// Depends-on footer (#31 layer 2) — surface the parent rule + its
|
||||
// date right under the title so the user reads the dependency at a
|
||||
// glance. "[Pfad anzeigen]" expands the full chain on demand.
|
||||
if (ev.depends_on_rule_code) {
|
||||
body.appendChild(renderDependsOn(ev));
|
||||
}
|
||||
|
||||
// Click-to-anchor affordance (Slice 2 §6.2) — projected rows expose
|
||||
// "[Datum setzen]" inline editor; actuals from rules expose a
|
||||
// "[Datum ändern]" variant that PATCHes via the same endpoint.
|
||||
if (ev.kind === "projected" && ev.deadline_rule_id && opts.projectId) {
|
||||
body.appendChild(renderAnchorAction(ev, opts));
|
||||
}
|
||||
|
||||
li.appendChild(body);
|
||||
|
||||
// Row-level navigation — same pattern as .entity-event (t-paliad-103).
|
||||
if (href) {
|
||||
li.classList.add("smart-timeline-row--clickable");
|
||||
li.addEventListener("click", (e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.closest("a") || target.closest("button") || target.closest("input")) return;
|
||||
window.location.href = href;
|
||||
});
|
||||
}
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderDependsOn(ev: TimelineEvent): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-depends-on";
|
||||
const code = ev.depends_on_rule_code ?? "";
|
||||
const name = ev.depends_on_rule_name || code;
|
||||
const dateText = ev.depends_on_date
|
||||
? formatDateOnly(dateOnlyISO(ev.depends_on_date) ?? "")
|
||||
: t("projects.detail.smarttimeline.depends_on.date_open");
|
||||
const prefix = t("projects.detail.smarttimeline.depends_on.prefix");
|
||||
const txt = document.createElement("span");
|
||||
txt.textContent = `${prefix}: ${name} (${code}, ${dateText})`;
|
||||
wrap.appendChild(txt);
|
||||
|
||||
const expand = document.createElement("button");
|
||||
expand.type = "button";
|
||||
expand.className = "smart-timeline-depends-on-expand";
|
||||
expand.textContent = t("projects.detail.smarttimeline.depends_on.show_path");
|
||||
expand.addEventListener("click", () => {
|
||||
if (wrap.classList.contains("smart-timeline-depends-on--expanded")) {
|
||||
wrap.classList.remove("smart-timeline-depends-on--expanded");
|
||||
const list = wrap.querySelector(".smart-timeline-depends-on-path");
|
||||
if (list) list.remove();
|
||||
expand.textContent = t("projects.detail.smarttimeline.depends_on.show_path");
|
||||
return;
|
||||
}
|
||||
wrap.classList.add("smart-timeline-depends-on--expanded");
|
||||
const list = document.createElement("div");
|
||||
list.className = "smart-timeline-depends-on-path";
|
||||
// The walked chain isn't pre-computed server-side beyond the
|
||||
// immediate parent; the backend annotation gives one hop. Future
|
||||
// slice can deepen this — for v1 we surface the immediate parent
|
||||
// (already in the prefix line) and a hint that the user can click
|
||||
// the parent's row to see its own dependency.
|
||||
const hint = document.createElement("span");
|
||||
hint.className = "smart-timeline-depends-on-hint";
|
||||
hint.textContent = t("projects.detail.smarttimeline.depends_on.path_hint");
|
||||
list.appendChild(hint);
|
||||
wrap.appendChild(list);
|
||||
expand.textContent = t("projects.detail.smarttimeline.depends_on.hide_path");
|
||||
});
|
||||
wrap.appendChild(expand);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function renderAnchorAction(ev: TimelineEvent, opts: RenderOptions): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "smart-timeline-anchor";
|
||||
|
||||
const trigger = document.createElement("button");
|
||||
trigger.type = "button";
|
||||
trigger.className = "smart-timeline-anchor-btn";
|
||||
trigger.textContent = t("projects.detail.smarttimeline.anchor.set");
|
||||
wrap.appendChild(trigger);
|
||||
|
||||
trigger.addEventListener("click", () => {
|
||||
if (wrap.classList.contains("smart-timeline-anchor--editing")) return;
|
||||
wrap.classList.add("smart-timeline-anchor--editing");
|
||||
trigger.style.display = "none";
|
||||
wrap.appendChild(buildAnchorEditor(ev, opts, wrap));
|
||||
});
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function buildAnchorEditor(
|
||||
ev: TimelineEvent,
|
||||
opts: RenderOptions,
|
||||
wrap: HTMLElement,
|
||||
): HTMLElement {
|
||||
const editor = document.createElement("form");
|
||||
editor.className = "smart-timeline-anchor-form";
|
||||
editor.setAttribute("aria-label", t("projects.detail.smarttimeline.anchor.set"));
|
||||
editor.addEventListener("submit", (e) => e.preventDefault());
|
||||
|
||||
const dateInput = document.createElement("input");
|
||||
dateInput.type = "date";
|
||||
dateInput.className = "smart-timeline-anchor-date";
|
||||
dateInput.required = true;
|
||||
if (ev.date) dateInput.value = dateOnlyISO(ev.date) ?? "";
|
||||
editor.appendChild(dateInput);
|
||||
|
||||
const submit = document.createElement("button");
|
||||
submit.type = "submit";
|
||||
submit.className = "smart-timeline-anchor-submit";
|
||||
submit.textContent = t("projects.detail.smarttimeline.anchor.save");
|
||||
editor.appendChild(submit);
|
||||
|
||||
const cancel = document.createElement("button");
|
||||
cancel.type = "button";
|
||||
cancel.className = "smart-timeline-anchor-cancel";
|
||||
cancel.textContent = t("projects.detail.smarttimeline.anchor.cancel");
|
||||
cancel.addEventListener("click", () => {
|
||||
wrap.innerHTML = "";
|
||||
const trig = document.createElement("button");
|
||||
trig.type = "button";
|
||||
trig.className = "smart-timeline-anchor-btn";
|
||||
trig.textContent = t("projects.detail.smarttimeline.anchor.set");
|
||||
wrap.classList.remove("smart-timeline-anchor--editing");
|
||||
wrap.appendChild(trig);
|
||||
trig.addEventListener("click", () => {
|
||||
wrap.innerHTML = "";
|
||||
wrap.appendChild(buildAnchorEditor(ev, opts, wrap));
|
||||
wrap.classList.add("smart-timeline-anchor--editing");
|
||||
});
|
||||
});
|
||||
editor.appendChild(cancel);
|
||||
|
||||
const msg = document.createElement("div");
|
||||
msg.className = "smart-timeline-anchor-msg";
|
||||
editor.appendChild(msg);
|
||||
|
||||
editor.addEventListener("submit", async () => {
|
||||
if (!opts.projectId) return;
|
||||
if (!ev.rule_code) return;
|
||||
const date = dateInput.value;
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||
msg.textContent = t("projects.detail.smarttimeline.anchor.invalid_date");
|
||||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||||
return;
|
||||
}
|
||||
submit.disabled = true;
|
||||
cancel.disabled = true;
|
||||
msg.classList.remove("smart-timeline-anchor-msg--error");
|
||||
msg.textContent = t("projects.detail.smarttimeline.anchor.saving");
|
||||
try {
|
||||
const resp = await fetch(
|
||||
`/api/projects/${encodeURIComponent(opts.projectId)}/timeline/anchor`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
rule_code: ev.rule_code,
|
||||
actual_date: date,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (resp.ok) {
|
||||
msg.textContent = t("projects.detail.smarttimeline.anchor.saved");
|
||||
if (opts.onChange) await opts.onChange();
|
||||
return;
|
||||
}
|
||||
if (resp.status === 409) {
|
||||
const payload = (await resp.json()) as PredecessorMissingPayload;
|
||||
renderPredecessorError(msg, payload, ev, opts, dateInput, submit, cancel);
|
||||
return;
|
||||
}
|
||||
msg.textContent = t("projects.detail.smarttimeline.anchor.error");
|
||||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||||
} catch {
|
||||
msg.textContent = t("projects.detail.smarttimeline.anchor.error");
|
||||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||||
} finally {
|
||||
submit.disabled = false;
|
||||
cancel.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
return editor;
|
||||
}
|
||||
|
||||
function renderPredecessorError(
|
||||
msg: HTMLElement,
|
||||
payload: PredecessorMissingPayload,
|
||||
_ev: TimelineEvent,
|
||||
opts: RenderOptions,
|
||||
_dateInput: HTMLInputElement,
|
||||
_submit: HTMLButtonElement,
|
||||
_cancel: HTMLButtonElement,
|
||||
): void {
|
||||
msg.innerHTML = "";
|
||||
msg.classList.add("smart-timeline-anchor-msg--error");
|
||||
msg.classList.add("smart-timeline-anchor-msg--predecessor");
|
||||
|
||||
const lang = (opts.lang ?? getLang()) === "en" ? "en" : "de";
|
||||
const message = lang === "en" ? payload.message_en : payload.message_de;
|
||||
const main = document.createElement("p");
|
||||
main.textContent = message;
|
||||
msg.appendChild(main);
|
||||
|
||||
// "Stattdessen <predecessor> erfassen" — pre-fills the editor for
|
||||
// the missing parent rule, scrolls to its row if present, falls back
|
||||
// to a fresh editor in-place.
|
||||
const link = document.createElement("button");
|
||||
link.type = "button";
|
||||
link.className = "smart-timeline-anchor-predecessor-link";
|
||||
const predName =
|
||||
lang === "en" ? payload.missing_rule_name_en : payload.missing_rule_name_de;
|
||||
link.textContent =
|
||||
lang === "en"
|
||||
? `Anchor „${predName}“ instead`
|
||||
: `Stattdessen „${predName}“ erfassen`;
|
||||
link.addEventListener("click", () => {
|
||||
// Find the projected row for missing_rule_code and scroll into view;
|
||||
// the row's own [Datum setzen] button takes it from there.
|
||||
const targetRow = findRowForRuleCode(payload.missing_rule_code);
|
||||
if (targetRow) {
|
||||
targetRow.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
const btn = targetRow.querySelector<HTMLButtonElement>(
|
||||
".smart-timeline-anchor-btn",
|
||||
);
|
||||
if (btn) btn.click();
|
||||
}
|
||||
});
|
||||
msg.appendChild(link);
|
||||
}
|
||||
|
||||
function findRowForRuleCode(ruleCode: string): HTMLElement | null {
|
||||
const rows = document.querySelectorAll<HTMLElement>(".smart-timeline-row");
|
||||
for (const r of Array.from(rows)) {
|
||||
const chip = r.querySelector(".smart-timeline-rule-chip");
|
||||
if (chip && chip.textContent === ruleCode) return r;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function deepLinkHref(ev: TimelineEvent): string | null {
|
||||
if (ev.kind === "deadline" && ev.deadline_id) {
|
||||
return `/deadlines/${ev.deadline_id}`;
|
||||
}
|
||||
if (ev.kind === "appointment" && ev.appointment_id) {
|
||||
return `/appointments/${ev.appointment_id}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function statusGlyph(status: TimelineEvent["status"]): string {
|
||||
switch (status) {
|
||||
case "done": return "✓";
|
||||
case "open": return "…";
|
||||
case "overdue": return "!";
|
||||
case "court_set": return "▢";
|
||||
case "predicted": return "░";
|
||||
case "predicted_overdue": return "░!";
|
||||
case "off_script": return "⊕";
|
||||
default: return "·";
|
||||
}
|
||||
}
|
||||
|
||||
function statusKey(status: TimelineEvent["status"]) {
|
||||
return `projects.detail.smarttimeline.status.${status}` as const;
|
||||
}
|
||||
|
||||
function kindKey(kind: TimelineEvent["kind"]) {
|
||||
return `projects.detail.smarttimeline.kind.${kind}` as const;
|
||||
}
|
||||
|
||||
function dateOnlyISO(raw: string | null | undefined): string | null {
|
||||
if (!raw) return null;
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
|
||||
const d = new Date(raw);
|
||||
if (isNaN(d.getTime())) return null;
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function todayLocalISO(): string {
|
||||
const d = new Date();
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function byDateAsc(a: TimelineEvent, b: TimelineEvent): number {
|
||||
const ai = dateOnlyISO(a.date) ?? "";
|
||||
const bi = dateOnlyISO(b.date) ?? "";
|
||||
if (ai === bi) return a.title.localeCompare(b.title);
|
||||
return ai < bi ? -1 : 1;
|
||||
}
|
||||
|
||||
function formatDateOnly(iso: string): string {
|
||||
if (!iso) return "—";
|
||||
const parts = iso.split("-");
|
||||
if (parts.length !== 3) return iso;
|
||||
const d = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
|
||||
if (isNaN(d.getTime())) return iso;
|
||||
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
}
|
||||
@@ -20,7 +20,7 @@ export interface ScopeSpec {
|
||||
|
||||
export type TimeHorizon =
|
||||
| "next_7d" | "next_30d" | "next_90d"
|
||||
| "past_30d" | "past_90d"
|
||||
| "past_7d" | "past_30d" | "past_90d"
|
||||
| "any" | "all" | "custom";
|
||||
|
||||
export type TimeField = "auto" | "created_at";
|
||||
|
||||
@@ -7,6 +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.
|
||||
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>';
|
||||
const ICON_GLOBE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/></svg>';
|
||||
@@ -157,6 +161,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/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,6 +207,20 @@ 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>
|
||||
</div>
|
||||
<div className="fristen-step2-shortcut">
|
||||
<div className="fristen-pathway-fork-shortcut-label" data-i18n="deadlines.pathway.shortcut.label">
|
||||
|
||||
@@ -970,6 +970,8 @@ export type I18nKey =
|
||||
| "deadlines.step1.selected"
|
||||
| "deadlines.step1.summary.adhoc.suffix"
|
||||
| "deadlines.step2"
|
||||
| "deadlines.step2.browse.desc"
|
||||
| "deadlines.step2.browse.title"
|
||||
| "deadlines.step2.file.desc"
|
||||
| "deadlines.step2.file.title"
|
||||
| "deadlines.step2.happened.desc"
|
||||
@@ -1466,6 +1468,7 @@ export type I18nKey =
|
||||
| "nav.team"
|
||||
| "nav.termine"
|
||||
| "nav.user_views.new"
|
||||
| "nav.verfahrensablauf"
|
||||
| "notes.cancel"
|
||||
| "notes.delete"
|
||||
| "notes.delete.confirm"
|
||||
@@ -1692,6 +1695,78 @@ export type I18nKey =
|
||||
| "projects.detail.parteien.role.defendant"
|
||||
| "projects.detail.parteien.role.thirdparty"
|
||||
| "projects.detail.save"
|
||||
| "projects.detail.smarttimeline.add.cancel"
|
||||
| "projects.detail.smarttimeline.add.choice.amend"
|
||||
| "projects.detail.smarttimeline.add.choice.appointment"
|
||||
| "projects.detail.smarttimeline.add.choice.counterclaim"
|
||||
| "projects.detail.smarttimeline.add.choice.deadline"
|
||||
| "projects.detail.smarttimeline.add.choice.disabled"
|
||||
| "projects.detail.smarttimeline.add.choice.milestone"
|
||||
| "projects.detail.smarttimeline.add.cta"
|
||||
| "projects.detail.smarttimeline.add.modal.title"
|
||||
| "projects.detail.smarttimeline.add.submit"
|
||||
| "projects.detail.smarttimeline.anchor.cancel"
|
||||
| "projects.detail.smarttimeline.anchor.error"
|
||||
| "projects.detail.smarttimeline.anchor.invalid_date"
|
||||
| "projects.detail.smarttimeline.anchor.save"
|
||||
| "projects.detail.smarttimeline.anchor.saved"
|
||||
| "projects.detail.smarttimeline.anchor.saving"
|
||||
| "projects.detail.smarttimeline.anchor.set"
|
||||
| "projects.detail.smarttimeline.audit.toggle.hide"
|
||||
| "projects.detail.smarttimeline.audit.toggle.show"
|
||||
| "projects.detail.smarttimeline.client.matter_list.empty"
|
||||
| "projects.detail.smarttimeline.client.matter_list.heading"
|
||||
| "projects.detail.smarttimeline.client.matter_list.hint"
|
||||
| "projects.detail.smarttimeline.client.toggle.lanes"
|
||||
| "projects.detail.smarttimeline.client.toggle.matter_list"
|
||||
| "projects.detail.smarttimeline.counterclaim.case_number"
|
||||
| "projects.detail.smarttimeline.counterclaim.flip_hint"
|
||||
| "projects.detail.smarttimeline.counterclaim.flip_override"
|
||||
| "projects.detail.smarttimeline.counterclaim.procedure"
|
||||
| "projects.detail.smarttimeline.counterclaim.saving"
|
||||
| "projects.detail.smarttimeline.counterclaim.submit"
|
||||
| "projects.detail.smarttimeline.counterclaim.title"
|
||||
| "projects.detail.smarttimeline.depends_on.date_open"
|
||||
| "projects.detail.smarttimeline.depends_on.hide_path"
|
||||
| "projects.detail.smarttimeline.depends_on.path_hint"
|
||||
| "projects.detail.smarttimeline.depends_on.prefix"
|
||||
| "projects.detail.smarttimeline.depends_on.show_path"
|
||||
| "projects.detail.smarttimeline.empty"
|
||||
| "projects.detail.smarttimeline.error.generic"
|
||||
| "projects.detail.smarttimeline.error.title_required"
|
||||
| "projects.detail.smarttimeline.kind.appointment"
|
||||
| "projects.detail.smarttimeline.kind.deadline"
|
||||
| "projects.detail.smarttimeline.kind.milestone"
|
||||
| "projects.detail.smarttimeline.kind.projected"
|
||||
| "projects.detail.smarttimeline.lane.empty"
|
||||
| "projects.detail.smarttimeline.lane.filter.all"
|
||||
| "projects.detail.smarttimeline.lane.filter.label"
|
||||
| "projects.detail.smarttimeline.lookahead.less"
|
||||
| "projects.detail.smarttimeline.lookahead.more"
|
||||
| "projects.detail.smarttimeline.milestone.bubble_up"
|
||||
| "projects.detail.smarttimeline.milestone.bubble_up_hint"
|
||||
| "projects.detail.smarttimeline.milestone.date"
|
||||
| "projects.detail.smarttimeline.milestone.description"
|
||||
| "projects.detail.smarttimeline.milestone.title"
|
||||
| "projects.detail.smarttimeline.section.future"
|
||||
| "projects.detail.smarttimeline.section.past"
|
||||
| "projects.detail.smarttimeline.section.undated"
|
||||
| "projects.detail.smarttimeline.status.court_set"
|
||||
| "projects.detail.smarttimeline.status.done"
|
||||
| "projects.detail.smarttimeline.status.off_script"
|
||||
| "projects.detail.smarttimeline.status.open"
|
||||
| "projects.detail.smarttimeline.status.overdue"
|
||||
| "projects.detail.smarttimeline.status.predicted"
|
||||
| "projects.detail.smarttimeline.status.predicted_overdue"
|
||||
| "projects.detail.smarttimeline.today"
|
||||
| "projects.detail.smarttimeline.track.both"
|
||||
| "projects.detail.smarttimeline.track.header.counterclaim"
|
||||
| "projects.detail.smarttimeline.track.header.parent"
|
||||
| "projects.detail.smarttimeline.track.header.parent_context"
|
||||
| "projects.detail.smarttimeline.track.label"
|
||||
| "projects.detail.smarttimeline.track.only.counterclaim"
|
||||
| "projects.detail.smarttimeline.track.only.parent"
|
||||
| "projects.detail.smarttimeline.track.only.parent_context"
|
||||
| "projects.detail.tab.checklisten"
|
||||
| "projects.detail.tab.fristen"
|
||||
| "projects.detail.tab.kinder"
|
||||
@@ -1971,9 +2046,12 @@ export type I18nKey =
|
||||
| "views.bar.label.deadline_status"
|
||||
| "views.bar.label.density"
|
||||
| "views.bar.label.personal"
|
||||
| "views.bar.label.project_event_kind"
|
||||
| "views.bar.label.shape"
|
||||
| "views.bar.label.sort"
|
||||
| "views.bar.label.time"
|
||||
| "views.bar.label.timeline_status"
|
||||
| "views.bar.label.timeline_track"
|
||||
| "views.bar.personal.on"
|
||||
| "views.bar.save.cancel"
|
||||
| "views.bar.save.confirm"
|
||||
@@ -1991,6 +2069,7 @@ export type I18nKey =
|
||||
| "views.bar.shape.list"
|
||||
| "views.bar.sort.date_asc"
|
||||
| "views.bar.sort.date_desc"
|
||||
| "views.bar.time.all"
|
||||
| "views.bar.time.any"
|
||||
| "views.bar.time.custom"
|
||||
| "views.bar.time.custom.coming_soon"
|
||||
@@ -1998,6 +2077,20 @@ export type I18nKey =
|
||||
| "views.bar.time.next_7d"
|
||||
| "views.bar.time.next_90d"
|
||||
| "views.bar.time.past_30d"
|
||||
| "views.bar.time.past_7d"
|
||||
| "views.bar.time.past_90d"
|
||||
| "views.bar.timeline_status.court_set"
|
||||
| "views.bar.timeline_status.done"
|
||||
| "views.bar.timeline_status.macro.future"
|
||||
| "views.bar.timeline_status.macro.past"
|
||||
| "views.bar.timeline_status.off_script"
|
||||
| "views.bar.timeline_status.open"
|
||||
| "views.bar.timeline_status.overdue"
|
||||
| "views.bar.timeline_status.predicted"
|
||||
| "views.bar.timeline_status.predicted_overdue"
|
||||
| "views.bar.timeline_track.counterclaim"
|
||||
| "views.bar.timeline_track.off_script"
|
||||
| "views.bar.timeline_track.parent"
|
||||
| "views.calendar.mobile_fallback"
|
||||
| "views.col.actor"
|
||||
| "views.col.appointment_type"
|
||||
|
||||
@@ -82,21 +82,132 @@ export function renderProjectsDetail(): string {
|
||||
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
|
||||
</nav>
|
||||
|
||||
{/* History (Verlauf) */}
|
||||
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
|
||||
The legacy <ul.entity-events> + Mehr-laden controls are
|
||||
replaced by the vertical timeline (rendered by
|
||||
client/views/shape-timeline.ts). The bar from t-paliad-170
|
||||
keeps driving filter state via its customRunner. */}
|
||||
<section className="entity-tab-panel" id="tab-history">
|
||||
<div className="party-controls">
|
||||
<div className="smart-timeline-controls">
|
||||
<button type="button" className="btn-secondary btn-small subtree-toggle" aria-pressed="false">
|
||||
Inkl. Unterprojekte
|
||||
</button>
|
||||
</div>
|
||||
<ul className="entity-events" id="project-events-list" />
|
||||
<p className="entity-events-empty" id="project-events-empty" style="display:none" data-i18n="projects.detail.verlauf.empty">
|
||||
Noch keine Ereignisse aufgezeichnet.
|
||||
</p>
|
||||
<div className="entity-events-loadmore" id="project-events-loadmore-wrap" style="display:none">
|
||||
<button type="button" className="btn-secondary" id="project-events-loadmore" data-i18n="projects.detail.verlauf.loadMore">
|
||||
Mehr laden
|
||||
<button type="button" className="btn-secondary btn-small" id="smart-timeline-audit-toggle" aria-pressed="false" data-i18n="projects.detail.smarttimeline.audit.toggle.show">
|
||||
Audit-Log anzeigen
|
||||
</button>
|
||||
{/* Slice 4 — Client-level Timeline-Ansicht toggle.
|
||||
Hidden by default (display:none); the client TS
|
||||
flips it visible only when project.type === 'client'. */}
|
||||
<button type="button" className="btn-secondary btn-small" id="smart-timeline-client-toggle" aria-pressed="false" style="display:none" data-i18n="projects.detail.smarttimeline.client.toggle.lanes">
|
||||
Timeline-Ansicht
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
<div id="project-events-filter-bar" />
|
||||
<div id="project-smart-timeline" className="smart-timeline" />
|
||||
|
||||
{/* "Eigener Meilenstein" modal. Hidden by default; opened
|
||||
by the "+ Eintrag" CTA above. The other modal options
|
||||
route to existing flows (see client wiring). */}
|
||||
<div id="smart-timeline-add-modal" className="smart-timeline-modal" style="display:none" role="dialog" aria-modal="true">
|
||||
<div className="smart-timeline-modal-card">
|
||||
<h3 data-i18n="projects.detail.smarttimeline.add.modal.title">
|
||||
Neuer Eintrag im SmartTimeline
|
||||
</h3>
|
||||
|
||||
<div className="smart-timeline-add-choices">
|
||||
<a id="smart-timeline-add-deadline" className="smart-timeline-add-choice" href="#" data-i18n="projects.detail.smarttimeline.add.choice.deadline">
|
||||
Frist anlegen
|
||||
</a>
|
||||
<a id="smart-timeline-add-appointment" className="smart-timeline-add-choice" href="#" data-i18n="projects.detail.smarttimeline.add.choice.appointment">
|
||||
Termin anlegen
|
||||
</a>
|
||||
<button type="button" id="smart-timeline-add-counterclaim" className="smart-timeline-add-choice" data-i18n="projects.detail.smarttimeline.add.choice.counterclaim">
|
||||
Widerklage (CCR)
|
||||
</button>
|
||||
<button type="button" className="smart-timeline-add-choice smart-timeline-add-choice--disabled" disabled title="Slice 3" data-i18n="projects.detail.smarttimeline.add.choice.amend">
|
||||
Antrag auf Änderung (R.30)
|
||||
</button>
|
||||
<button type="button" id="smart-timeline-add-milestone" className="smart-timeline-add-choice smart-timeline-add-choice--primary" data-i18n="projects.detail.smarttimeline.add.choice.milestone">
|
||||
Eigener Meilenstein
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="smart-timeline-milestone-form" className="entity-form" style="display:none" autocomplete="off">
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-milestone-title" data-i18n="projects.detail.smarttimeline.milestone.title">Titel</label>
|
||||
<input type="text" id="smart-timeline-milestone-title" required maxLength={200} />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-milestone-date" data-i18n="projects.detail.smarttimeline.milestone.date">Datum (optional)</label>
|
||||
<input type="date" id="smart-timeline-milestone-date" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-milestone-desc" data-i18n="projects.detail.smarttimeline.milestone.description">Beschreibung (optional)</label>
|
||||
<textarea id="smart-timeline-milestone-desc" rows={3} />
|
||||
</div>
|
||||
{/* Slice 4 — bubble-up override (t-paliad-175 §7.2 Q5). */}
|
||||
<div className="form-field form-field--checkbox">
|
||||
<label className="form-checkbox">
|
||||
<input type="checkbox" id="smart-timeline-milestone-bubble-up" />
|
||||
<span data-i18n="projects.detail.smarttimeline.milestone.bubble_up">In übergeordneten Akten anzeigen</span>
|
||||
</label>
|
||||
<small className="form-field-hint" data-i18n="projects.detail.smarttimeline.milestone.bubble_up_hint">
|
||||
Beim Aktivieren erscheint dieser Meilenstein auf Patent-, Verfahrens- und Mandantsicht.
|
||||
</small>
|
||||
</div>
|
||||
<div id="smart-timeline-milestone-msg" className="form-msg" />
|
||||
<div className="form-field-row">
|
||||
<button type="button" className="btn-secondary" id="smart-timeline-milestone-cancel" data-i18n="projects.detail.smarttimeline.add.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projects.detail.smarttimeline.add.submit">Speichern</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* CCR sub-project create form (Slice 3, t-paliad-174). The
|
||||
proceeding-type select is populated by the client at
|
||||
runtime; our_side defaults to inverted with a
|
||||
"Stimmt nicht?" override toggle for the R.49.2.b
|
||||
edge case. Title is auto-suggested server-side and
|
||||
can be overridden inline. */}
|
||||
<form id="smart-timeline-counterclaim-form" className="entity-form" style="display:none" autocomplete="off">
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-counterclaim-procedure" data-i18n="projects.detail.smarttimeline.counterclaim.procedure">Verfahrenstyp</label>
|
||||
<select id="smart-timeline-counterclaim-procedure">
|
||||
{/* Options injected from client; defaults to UPC_REV */}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-counterclaim-title" data-i18n="projects.detail.smarttimeline.counterclaim.title">Titel (optional)</label>
|
||||
<input type="text" id="smart-timeline-counterclaim-title" maxLength={200} placeholder="Auto-Vorschlag aus Patentnummer" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="smart-timeline-counterclaim-case-number" data-i18n="projects.detail.smarttimeline.counterclaim.case_number">CCR-Aktenzeichen (optional)</label>
|
||||
<input type="text" id="smart-timeline-counterclaim-case-number" maxLength={200} placeholder="ACT_xxx_2026" />
|
||||
</div>
|
||||
<div className="form-field form-field--checkbox">
|
||||
<label className="form-checkbox">
|
||||
<input type="checkbox" id="smart-timeline-counterclaim-flip-toggle" />
|
||||
<span data-i18n="projects.detail.smarttimeline.counterclaim.flip_override">Unsere Seite NICHT umkehren (Stimmt nicht?)</span>
|
||||
</label>
|
||||
<small className="form-field-hint" data-i18n="projects.detail.smarttimeline.counterclaim.flip_hint">
|
||||
Im Standardfall (CCR-Nichtigkeit) kehrt sich unsere Seite um (Kläger ↔ Beklagter). Aktivieren bei R.49.2.b CCI.
|
||||
</small>
|
||||
</div>
|
||||
<div id="smart-timeline-counterclaim-msg" className="form-msg" />
|
||||
<div className="form-field-row">
|
||||
<button type="button" className="btn-secondary" id="smart-timeline-counterclaim-cancel" data-i18n="projects.detail.smarttimeline.add.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projects.detail.smarttimeline.counterclaim.submit">Widerklage anlegen</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="smart-timeline-modal-close-row">
|
||||
<button type="button" className="btn-secondary btn-small" id="smart-timeline-modal-close" data-i18n="projects.detail.smarttimeline.add.cancel">
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -13451,3 +13451,724 @@ dialog.quick-add-sheet::backdrop {
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* ========================================================================
|
||||
SmartTimeline (t-paliad-171, Slice 1).
|
||||
Vertical two-column timeline replacing the legacy <ul.entity-events>
|
||||
on the Verlauf tab. Past chronological → "Heute →" rule → Future
|
||||
chronological. Status icon + kind chip per row, deep-link via a
|
||||
row-level click handler on .smart-timeline-row--clickable (NOT a
|
||||
::before overlay — text selection must stay intact, project CLAUDE.md
|
||||
"Whole-card click → use a JS row handler").
|
||||
======================================================================== */
|
||||
|
||||
.smart-timeline-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.smart-timeline-controls #smart-timeline-add-btn {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.smart-timeline {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.smart-timeline-empty {
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.smart-timeline-flow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.smart-timeline-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
|
||||
.smart-timeline-heading {
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 0.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* "Heute →" horizontal rule. Anchors past vs future visually even when
|
||||
one side is empty, so the user always has a temporal reference. */
|
||||
.smart-timeline-today-rule {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
color: var(--color-accent-fg);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
margin: 0.25rem 0;
|
||||
}
|
||||
|
||||
.smart-timeline-today-rule::before,
|
||||
.smart-timeline-today-rule::after {
|
||||
content: "";
|
||||
flex: 1;
|
||||
height: 0;
|
||||
border-top: 2px solid var(--hlc-lime, var(--color-accent-fg));
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.smart-timeline-today-label {
|
||||
flex: 0 0 auto;
|
||||
padding: 0 0.4rem;
|
||||
}
|
||||
|
||||
.smart-timeline-row {
|
||||
display: grid;
|
||||
grid-template-columns: 130px 1fr;
|
||||
gap: 1rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.smart-timeline-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.smart-timeline-row--clickable {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
|
||||
.smart-timeline-row--clickable:hover {
|
||||
border-color: var(--color-accent-fg);
|
||||
box-shadow: var(--shadow-hover, var(--shadow));
|
||||
}
|
||||
|
||||
.smart-timeline-date {
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
font-family: var(--font-mono);
|
||||
padding-top: 0.15rem;
|
||||
}
|
||||
|
||||
.smart-timeline-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.smart-timeline-row-head {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.smart-timeline-status-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: 50%;
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.smart-timeline-row--done .smart-timeline-status-icon {
|
||||
background: var(--hlc-lime, #c6f41c);
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.smart-timeline-row--overdue .smart-timeline-status-icon {
|
||||
background: #f8d7da;
|
||||
color: #842029;
|
||||
}
|
||||
|
||||
.smart-timeline-row--off_script .smart-timeline-status-icon {
|
||||
background: #fff3cd;
|
||||
color: #664d03;
|
||||
}
|
||||
|
||||
.smart-timeline-row--court_set .smart-timeline-status-icon {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--color-text-muted);
|
||||
}
|
||||
|
||||
.smart-timeline-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.92rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.smart-timeline-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.smart-timeline-link:hover {
|
||||
color: var(--color-accent-fg);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.smart-timeline-link:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.smart-timeline-kind-chip,
|
||||
.smart-timeline-rule-chip {
|
||||
flex: 0 0 auto;
|
||||
font-size: 0.72rem;
|
||||
padding: 0.1rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
color: var(--color-text-muted);
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.smart-timeline-kind-chip--deadline {
|
||||
background: #e0ecff;
|
||||
color: #1a4a8a;
|
||||
}
|
||||
|
||||
.smart-timeline-kind-chip--appointment {
|
||||
background: #e7f5ee;
|
||||
color: #2c6b46;
|
||||
}
|
||||
|
||||
.smart-timeline-kind-chip--milestone {
|
||||
background: #fdecd2;
|
||||
color: #7a4f15;
|
||||
}
|
||||
|
||||
.smart-timeline-kind-chip--projected {
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.smart-timeline-rule-chip {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.smart-timeline-desc {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
/* Modal — minimal scrim + centred card. The new-entry CTA opens this;
|
||||
"Eigener Meilenstein" expands the form inline, every other choice
|
||||
is a plain link / disabled button. */
|
||||
.smart-timeline-modal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.smart-timeline-modal-card {
|
||||
background: var(--color-surface);
|
||||
border-radius: var(--radius);
|
||||
padding: 1.5rem;
|
||||
max-width: 480px;
|
||||
width: 100%;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
.smart-timeline-modal-card h3 {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.smart-timeline-add-choices {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.smart-timeline-add-choice {
|
||||
display: block;
|
||||
text-align: left;
|
||||
padding: 0.6rem 0.9rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.smart-timeline-add-choice:hover:not(:disabled) {
|
||||
border-color: var(--color-accent-fg);
|
||||
background: var(--color-surface-alt, #fafafa);
|
||||
}
|
||||
|
||||
.smart-timeline-add-choice--primary {
|
||||
border-color: var(--hlc-lime, var(--color-accent-fg));
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.smart-timeline-add-choice--disabled,
|
||||
.smart-timeline-add-choice:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.smart-timeline-modal-close-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------
|
||||
SmartTimeline Slice 2 (t-paliad-173) — projected rows, depends-on
|
||||
footer, click-to-anchor inline editor, lookahead toggle.
|
||||
---------------------------------------------------------------------- */
|
||||
|
||||
/* Predicted future rows fade so the eye reads "not yet real". */
|
||||
.smart-timeline-row--projected {
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.smart-timeline-row--projected .smart-timeline-status-icon,
|
||||
.smart-timeline-row--predicted .smart-timeline-status-icon {
|
||||
color: var(--color-text-muted, #777);
|
||||
}
|
||||
|
||||
/* Court-set rows: dashed border on the left rail to read "the court
|
||||
binds the date, not us". */
|
||||
.smart-timeline-row--court_set {
|
||||
border-left: 2px dashed var(--color-border-strong, #aaa);
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Predicted-overdue rows: amber-faded so the user notices the projection
|
||||
should have happened by now. */
|
||||
.smart-timeline-row--predicted_overdue {
|
||||
opacity: 0.85;
|
||||
border-left: 2px solid var(--color-status-amber, #d68a1a);
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
.smart-timeline-row--predicted_overdue .smart-timeline-status-icon {
|
||||
color: var(--color-status-amber, #d68a1a);
|
||||
}
|
||||
|
||||
/* Status pill on projected rows — small, low-key, sits next to the kind
|
||||
chip. */
|
||||
.smart-timeline-status-pill {
|
||||
display: inline-block;
|
||||
padding: 0.1rem 0.5rem;
|
||||
margin-left: 0.4rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text-muted, #555);
|
||||
line-height: 1.3;
|
||||
}
|
||||
.smart-timeline-status-pill--court_set {
|
||||
border: 1px dashed var(--color-border-strong, #999);
|
||||
background: transparent;
|
||||
}
|
||||
.smart-timeline-status-pill--predicted_overdue {
|
||||
background: var(--color-bg-amber-tint, #fff5e0);
|
||||
color: var(--color-status-amber, #b56a00);
|
||||
}
|
||||
|
||||
/* Depends-on footer — quiet line under the row title, "Folgt aus: …". */
|
||||
.smart-timeline-depends-on {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #555);
|
||||
}
|
||||
.smart-timeline-depends-on-expand {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--color-link, #1a6dc5);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
.smart-timeline-depends-on-expand:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.smart-timeline-depends-on-path {
|
||||
flex-basis: 100%;
|
||||
margin-top: 0.2rem;
|
||||
padding-left: 0.75rem;
|
||||
border-left: 2px solid var(--color-border, #ddd);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
.smart-timeline-depends-on-hint {
|
||||
font-style: italic;
|
||||
color: var(--color-text-muted, #777);
|
||||
}
|
||||
|
||||
/* Click-to-anchor — editor lives inline under the row body. The trigger
|
||||
is a low-emphasis link button; the editor flips into a small flex row
|
||||
with a date input + Speichern / Abbrechen. */
|
||||
.smart-timeline-anchor {
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
.smart-timeline-anchor-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
color: var(--color-link, #1a6dc5);
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.smart-timeline-anchor-btn:hover {
|
||||
background: var(--color-bg-lime-tint, #f4fdd1);
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
}
|
||||
.smart-timeline-anchor-form {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
.smart-timeline-anchor-date {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.smart-timeline-anchor-submit {
|
||||
background: var(--color-accent, #c6f41c);
|
||||
border: 1px solid var(--color-accent, #c6f41c);
|
||||
color: var(--color-text, #333);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
.smart-timeline-anchor-submit:disabled,
|
||||
.smart-timeline-anchor-cancel:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.smart-timeline-anchor-cancel {
|
||||
background: transparent;
|
||||
border: 1px solid var(--color-border, #ddd);
|
||||
color: var(--color-text-muted, #555);
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.smart-timeline-anchor-msg {
|
||||
flex-basis: 100%;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #555);
|
||||
}
|
||||
.smart-timeline-anchor-msg--error {
|
||||
color: var(--status-red-fg, #b03030);
|
||||
}
|
||||
.smart-timeline-anchor-msg--predecessor {
|
||||
background: var(--status-red-bg, #fde8e8);
|
||||
border: 1px solid var(--status-red-border, #f0bcbc);
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.smart-timeline-anchor-msg--predecessor p {
|
||||
margin: 0 0 0.4rem 0;
|
||||
}
|
||||
.smart-timeline-anchor-predecessor-link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
color: var(--color-link, #1a6dc5);
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
/* Lookahead toggle row — small, centred under the future section. */
|
||||
.smart-timeline-lookahead {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
margin: 0.75rem 0;
|
||||
}
|
||||
.smart-timeline-lookahead-btn {
|
||||
background: transparent;
|
||||
border: 1px dashed var(--color-border, #ccc);
|
||||
color: var(--color-text-muted, #555);
|
||||
padding: 0.3rem 0.85rem;
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.smart-timeline-lookahead-btn:hover {
|
||||
border-style: solid;
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
background: var(--color-bg-lime-tint, #f4fdd1);
|
||||
}
|
||||
|
||||
/* SmartTimeline Slice 3 — counterclaim parallel tracks (t-paliad-174).
|
||||
.smart-timeline-tracks is the grid wrapper. Each .smart-timeline-track
|
||||
is a self-contained column with its own past/today/future flow. CSS
|
||||
Grid handles the side-by-side layout on desktop and collapses to a
|
||||
single column on mobile (≤640px) per the existing Paliad breakpoint
|
||||
convention. */
|
||||
.smart-timeline-tracks {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--smart-timeline-track-count, 2), minmax(0, 1fr));
|
||||
gap: 1.25rem;
|
||||
align-items: start;
|
||||
}
|
||||
.smart-timeline-track {
|
||||
border: 1px solid var(--color-border, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 0.85rem 1rem;
|
||||
background: var(--color-surface, #fff);
|
||||
min-width: 0;
|
||||
}
|
||||
.smart-timeline-track--parent {
|
||||
border-left: 3px solid var(--color-accent, #c6f41c);
|
||||
}
|
||||
.smart-timeline-track--counterclaim {
|
||||
border-left: 3px solid var(--color-text-muted, #999);
|
||||
background: var(--color-surface-2, #f7f7f7);
|
||||
}
|
||||
.smart-timeline-track--parent-context {
|
||||
border-left: 3px dashed var(--color-text-muted, #999);
|
||||
background: var(--color-surface-2, #f7f7f7);
|
||||
opacity: 0.85;
|
||||
}
|
||||
.smart-timeline-track-header {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted, #555);
|
||||
border-bottom: 1px solid var(--color-border, #e0e0e0);
|
||||
padding-bottom: 0.4rem;
|
||||
}
|
||||
.smart-timeline-track-empty {
|
||||
color: var(--color-text-muted, #888);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.75rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.smart-timeline-tracks {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Track-selector chip — sits above the timeline. Style follows the
|
||||
existing chip-row affordances. */
|
||||
.smart-timeline-track-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin: 0.5rem 0 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.smart-timeline-track-chip-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #555);
|
||||
font-weight: 500;
|
||||
}
|
||||
.smart-timeline-track-chip-select {
|
||||
border: 1px solid var(--color-border, #ccc);
|
||||
background: var(--color-surface, #fff);
|
||||
border-radius: 999px;
|
||||
padding: 0.25rem 0.65rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.smart-timeline-track-chip-select:focus-visible {
|
||||
outline: 2px solid var(--color-accent, #c6f41c);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* SmartTimeline Slice 4 — parent-node lane aggregation (t-paliad-175).
|
||||
.smart-timeline-lanes is the grid wrapper; .smart-timeline-lane is
|
||||
each direct-child column. Layout mirrors .smart-timeline-tracks but
|
||||
carries its own modifier so the visual treatment can diverge as the
|
||||
product evolves (lane widths can become richer with sub-headers).
|
||||
Mobile collapse to single-column at ≤640px. */
|
||||
.smart-timeline-lanes-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.smart-timeline-lanes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(var(--smart-timeline-lane-count, 2), minmax(0, 1fr));
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
.smart-timeline-lane {
|
||||
border: 1px solid var(--color-border, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
padding: 0.75rem 0.85rem 1rem;
|
||||
background: var(--color-surface, #fff);
|
||||
min-width: 0;
|
||||
transition: opacity 120ms ease-out;
|
||||
}
|
||||
.smart-timeline-lane--primary {
|
||||
border-left: 3px solid var(--color-accent, #c6f41c);
|
||||
}
|
||||
.smart-timeline-lane--dimmed {
|
||||
opacity: 0.35;
|
||||
}
|
||||
.smart-timeline-lane-header {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted, #555);
|
||||
border-bottom: 1px solid var(--color-border, #e0e0e0);
|
||||
padding-bottom: 0.4rem;
|
||||
}
|
||||
.smart-timeline-lane-header-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
.smart-timeline-lane-header-link:hover {
|
||||
color: var(--color-link, #1a8aff);
|
||||
text-decoration: underline;
|
||||
}
|
||||
.smart-timeline-lane-empty {
|
||||
color: var(--color-text-muted, #888);
|
||||
font-size: 0.85rem;
|
||||
padding: 0.75rem 0;
|
||||
font-style: italic;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.smart-timeline-lanes {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Lane filter chip-row — multiselect chips above the strip. Mirrors the
|
||||
FilterBar chip pattern; "Alle" pseudo-chip is highlighted when every
|
||||
lane is selected. */
|
||||
.smart-timeline-lane-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
margin: 0.25rem 0 0.5rem;
|
||||
}
|
||||
.smart-timeline-lane-filter-label {
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted, #555);
|
||||
font-weight: 500;
|
||||
}
|
||||
.smart-timeline-lane-chip {
|
||||
border: 1px solid var(--color-border, #ccc);
|
||||
background: var(--color-surface, #fff);
|
||||
color: var(--color-text, #222);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.7rem;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
transition: background 120ms ease-out, border-color 120ms ease-out;
|
||||
}
|
||||
.smart-timeline-lane-chip:hover {
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
}
|
||||
.smart-timeline-lane-chip.is-active {
|
||||
background: var(--color-bg-lime-tint, #f4fdd1);
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
}
|
||||
.smart-timeline-lane-chip--all {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Client-level matter-list (Slice 4 default at type=client). Simple
|
||||
list, slot for each direct child litigation. The Timeline-Ansicht
|
||||
toggle in the Verlauf controls flips between this and the lane view. */
|
||||
.smart-timeline-matter-list {
|
||||
border: 1px solid var(--color-border, #e0e0e0);
|
||||
border-radius: 8px;
|
||||
padding: 1rem 1.25rem;
|
||||
background: var(--color-surface, #fff);
|
||||
}
|
||||
.smart-timeline-matter-list-heading {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.smart-timeline-matter-list-items {
|
||||
list-style: none;
|
||||
margin: 0.5rem 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
.smart-timeline-matter-list-item {
|
||||
padding: 0.4rem 0.6rem;
|
||||
border-radius: 6px;
|
||||
background: var(--color-surface-2, #f7f7f7);
|
||||
}
|
||||
.smart-timeline-matter-list-item a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
display: block;
|
||||
}
|
||||
.smart-timeline-matter-list-item a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Counterclaim form layout follow-ups — inherits .entity-form, just
|
||||
tightens the optional checkbox row + hint. */
|
||||
.form-field--checkbox {
|
||||
align-items: flex-start;
|
||||
}
|
||||
.form-field--checkbox .form-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.45rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.form-field--checkbox .form-field-hint {
|
||||
color: var(--color-text-muted, #777);
|
||||
font-size: 0.8rem;
|
||||
margin-top: 0.35rem;
|
||||
display: block;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
-- t-paliad-171 down — drop the SmartTimeline opt-in column.
|
||||
|
||||
DROP INDEX IF EXISTS paliad.project_events_timeline_kind_idx;
|
||||
|
||||
ALTER TABLE paliad.project_events
|
||||
DROP COLUMN IF EXISTS timeline_kind;
|
||||
@@ -0,0 +1,32 @@
|
||||
-- t-paliad-171 — SmartTimeline Slice 1.
|
||||
-- Add the `timeline_kind` opt-in column to paliad.project_events so a
|
||||
-- subset of audit rows can surface as timeline content. Existing rows
|
||||
-- stay NULL (audit-only) and are filtered out of the SmartTimeline
|
||||
-- read path; new write paths (custom milestone, counterclaim_created
|
||||
-- in later slices) set the column on insert.
|
||||
--
|
||||
-- Value space (enforced in code, not via CHECK — see
|
||||
-- internal/services/projection_service.go):
|
||||
-- 'milestone' — structural event worth pinning to the timeline
|
||||
-- (counterclaim_filed, third_party_intervened,
|
||||
-- party_amendment, our_side_changed, scope_change)
|
||||
-- 'custom_milestone' — free-text user-added event ("Eigener Meilenstein")
|
||||
-- NULL — audit only (default, all existing rows)
|
||||
--
|
||||
-- Design ref: docs/design-smart-timeline-2026-05-08.md §2.2.
|
||||
|
||||
ALTER TABLE paliad.project_events
|
||||
ADD COLUMN IF NOT EXISTS timeline_kind text NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.project_events.timeline_kind IS
|
||||
'When non-NULL, this audit event also surfaces as a SmartTimeline '
|
||||
'milestone. NULL keeps the row audit-only. See '
|
||||
'internal/services/projection_service.go for the value space.';
|
||||
|
||||
-- Partial index — the SmartTimeline read path filters on
|
||||
-- (project_id, timeline_kind IS NOT NULL); making the index partial
|
||||
-- keeps it tiny (most rows stay audit-only) while still serving the
|
||||
-- common lookup.
|
||||
CREATE INDEX IF NOT EXISTS project_events_timeline_kind_idx
|
||||
ON paliad.project_events (project_id, timeline_kind)
|
||||
WHERE timeline_kind IS NOT NULL;
|
||||
@@ -0,0 +1,7 @@
|
||||
-- t-paliad-173 down — reverses 076_smart_timeline_slice_2.up.sql.
|
||||
|
||||
ALTER TABLE paliad.deadlines DROP CONSTRAINT IF EXISTS deadlines_source_check;
|
||||
|
||||
DROP INDEX IF EXISTS paliad.appointments_deadline_rule_id_idx;
|
||||
|
||||
ALTER TABLE paliad.appointments DROP COLUMN IF EXISTS deadline_rule_id;
|
||||
57
internal/db/migrations/076_smart_timeline_slice_2.up.sql
Normal file
57
internal/db/migrations/076_smart_timeline_slice_2.up.sql
Normal file
@@ -0,0 +1,57 @@
|
||||
-- t-paliad-173 — SmartTimeline Slice 2.
|
||||
-- Two structural additions for click-to-anchor (§6 of
|
||||
-- docs/design-smart-timeline-2026-05-08.md) + the layered SoC→SoD
|
||||
-- sequence enforcement from m/paliad#31:
|
||||
--
|
||||
-- 1. paliad.appointments.deadline_rule_id — nullable FK to
|
||||
-- paliad.deadline_rules. Court-set rules (Hauptverhandlung,
|
||||
-- Decision, Order) anchor as appointments rather than deadlines
|
||||
-- and need to remember which rule they came from so downstream
|
||||
-- reflow has the parent_id chain.
|
||||
--
|
||||
-- 2. paliad.deadlines.source CHECK — adds 'anchor' alongside
|
||||
-- the existing 'manual' / 'fristenrechner' values + the two
|
||||
-- legacy values the design doc mentions ('rule', 'import') for
|
||||
-- forward-compat. 'anchor' separates a click-to-anchor write from
|
||||
-- a user-typed-it-in 'manual' write so analytics + a future
|
||||
-- Outlook-import path can tell them apart.
|
||||
--
|
||||
-- paliad.project_events.event_type is intentionally NOT constrained —
|
||||
-- the column is free-text in prod (every event_type today lives in
|
||||
-- code, not in a CHECK). Slice 2 needs to write 'rule_skipped' rows
|
||||
-- (§6.4); no schema change is required for that.
|
||||
--
|
||||
-- Idempotent: re-applying is a no-op. Tracker advances 75 → 76.
|
||||
|
||||
-- 1. paliad.appointments.deadline_rule_id ----------------------------------
|
||||
|
||||
ALTER TABLE paliad.appointments
|
||||
ADD COLUMN IF NOT EXISTS deadline_rule_id uuid NULL
|
||||
REFERENCES paliad.deadline_rules(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.appointments.deadline_rule_id IS
|
||||
'When non-NULL, this appointment is the actual occurrence of a '
|
||||
'standard-course rule (Hauptverhandlung, Decision, Order). '
|
||||
'Anchors downstream re-projection via FristenrechnerService '
|
||||
'AnchorOverrides. See docs/design-smart-timeline-2026-05-08.md §6.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS appointments_deadline_rule_id_idx
|
||||
ON paliad.appointments (deadline_rule_id)
|
||||
WHERE deadline_rule_id IS NOT NULL;
|
||||
|
||||
-- 2. paliad.deadlines.source CHECK -----------------------------------------
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'deadlines_source_check'
|
||||
AND conrelid = 'paliad.deadlines'::regclass
|
||||
) THEN
|
||||
ALTER TABLE paliad.deadlines DROP CONSTRAINT deadlines_source_check;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE paliad.deadlines
|
||||
ADD CONSTRAINT deadlines_source_check
|
||||
CHECK (source IN ('manual', 'fristenrechner', 'rule', 'import', 'anchor'));
|
||||
@@ -0,0 +1,9 @@
|
||||
-- t-paliad-174 — revert SmartTimeline Slice 3 schema.
|
||||
|
||||
DROP TRIGGER IF EXISTS projects_no_two_level_ccr ON paliad.projects;
|
||||
DROP FUNCTION IF EXISTS paliad.projects_no_two_level_ccr();
|
||||
|
||||
DROP INDEX IF EXISTS paliad.projects_counterclaim_of_idx;
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
DROP COLUMN IF EXISTS counterclaim_of;
|
||||
89
internal/db/migrations/077_projects_counterclaim_of.up.sql
Normal file
89
internal/db/migrations/077_projects_counterclaim_of.up.sql
Normal file
@@ -0,0 +1,89 @@
|
||||
-- t-paliad-174 — SmartTimeline Slice 3.
|
||||
-- Two structural additions for the counterclaim sub-project shape
|
||||
-- (§4 of docs/design-smart-timeline-2026-05-08.md):
|
||||
--
|
||||
-- 1. paliad.projects.counterclaim_of — nullable FK referencing
|
||||
-- paliad.projects(id) ON DELETE SET NULL. When non-NULL the row
|
||||
-- represents the CCR (counterclaim) sub-project filed against the
|
||||
-- target row. Standard parent_id keeps governing the project tree;
|
||||
-- counterclaim_of is the *additional* relation describing the CCR
|
||||
-- link. parent_id of the CCR child is set to the target's parent
|
||||
-- (sibling-under-patent placement, §4.4) — that placement is owned
|
||||
-- by ProjectService.CreateCounterclaim, not the schema.
|
||||
--
|
||||
-- 2. Two-level-CCR rejection trigger — UPC practice does NOT have
|
||||
-- counterclaim-of-a-counterclaim chains. Reject the malformed shape
|
||||
-- at the schema level so the application can never write it. CHECK
|
||||
-- can't reference other rows; trigger function raises explicitly.
|
||||
--
|
||||
-- Idempotent: re-applying is a no-op. Tracker advances 76 → 77.
|
||||
|
||||
-- 1. paliad.projects.counterclaim_of ---------------------------------------
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN IF NOT EXISTS counterclaim_of uuid NULL
|
||||
REFERENCES paliad.projects(id) ON DELETE SET NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.counterclaim_of IS
|
||||
'When non-NULL this project is the CCR (counterclaim) filed against '
|
||||
'the referenced parent project. parent_id continues to govern the '
|
||||
'project tree (CCR is placed as a sibling under the same patent — '
|
||||
'see ProjectService.CreateCounterclaim). ON DELETE SET NULL keeps '
|
||||
'the CCR row alive when the parent is hard-deleted (rare; default '
|
||||
'is archival) so the audit trail survives.';
|
||||
|
||||
CREATE INDEX IF NOT EXISTS projects_counterclaim_of_idx
|
||||
ON paliad.projects (counterclaim_of)
|
||||
WHERE counterclaim_of IS NOT NULL;
|
||||
|
||||
-- 2. Two-level-CCR rejection trigger ---------------------------------------
|
||||
|
||||
CREATE OR REPLACE FUNCTION paliad.projects_no_two_level_ccr() RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
-- A project that is itself a CCR may NOT be the target of another CCR.
|
||||
-- Two cases to reject:
|
||||
--
|
||||
-- (a) NEW row points at a parent that is itself a CCR:
|
||||
-- NEW.counterclaim_of -> some row with counterclaim_of NOT NULL.
|
||||
--
|
||||
-- (b) NEW row claims to be a CCR (NEW.counterclaim_of IS NOT NULL)
|
||||
-- but already has another CCR pointing AT it (NEW.id is the
|
||||
-- target of some other row's counterclaim_of). The cleaner
|
||||
-- phrasing: "no row may simultaneously have a CCR child AND
|
||||
-- a CCR parent".
|
||||
IF NEW.counterclaim_of IS NOT NULL THEN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM paliad.projects p
|
||||
WHERE p.id = NEW.counterclaim_of
|
||||
AND p.counterclaim_of IS NOT NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'two-level counterclaim chains are not allowed: parent project % is itself a counterclaim',
|
||||
NEW.counterclaim_of;
|
||||
END IF;
|
||||
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM paliad.projects p
|
||||
WHERE p.counterclaim_of = NEW.id
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'project % already has a counterclaim child and cannot itself be a counterclaim',
|
||||
NEW.id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION paliad.projects_no_two_level_ccr() IS
|
||||
'Rejects two-level counterclaim chains. UPC practice does not have '
|
||||
'CCR-of-a-CCR; reject the malformed shape at write time so the app '
|
||||
'layer never has to defend against it. See migration 077.';
|
||||
|
||||
DROP TRIGGER IF EXISTS projects_no_two_level_ccr ON paliad.projects;
|
||||
CREATE TRIGGER projects_no_two_level_ccr
|
||||
BEFORE INSERT OR UPDATE OF counterclaim_of ON paliad.projects
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION paliad.projects_no_two_level_ccr();
|
||||
@@ -68,6 +68,7 @@ type Services struct {
|
||||
Broadcast *services.BroadcastService
|
||||
Pin *services.PinService
|
||||
CardLayout *services.CardLayoutService
|
||||
Projection *services.ProjectionService
|
||||
|
||||
// Paliadin is wired when DATABASE_URL is set. The concrete backend
|
||||
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
|
||||
@@ -119,6 +120,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
broadcast: svc.Broadcast,
|
||||
pin: svc.Pin,
|
||||
cardLayout: svc.CardLayout,
|
||||
projection: svc.Projection,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +216,18 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("PATCH /api/projects/{id}", handleUpdateProject)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}", handleDeleteProject)
|
||||
protected.HandleFunc("GET /api/projects/{id}/events", handleListProjectEvents)
|
||||
// t-paliad-171 / t-paliad-173 — SmartTimeline (Verlauf-tab redesign).
|
||||
// /timeline returns the merged timeline (actuals + Slice 2 projections).
|
||||
// /timeline/milestone is the "Eigener Meilenstein" write path.
|
||||
// /timeline/anchor is the click-to-anchor write (Slice 2).
|
||||
// /timeline/skip is the "ist nicht eingetreten" decision (§6.4).
|
||||
protected.HandleFunc("GET /api/projects/{id}/timeline", handleGetProjectTimeline)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/milestone", handleCreateProjectTimelineMilestone)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/anchor", handleProjectTimelineAnchor)
|
||||
protected.HandleFunc("POST /api/projects/{id}/timeline/skip", handleProjectTimelineSkip)
|
||||
// /counterclaim creates a CCR sub-project linked via the new
|
||||
// paliad.projects.counterclaim_of FK (t-paliad-174 Slice 3).
|
||||
protected.HandleFunc("POST /api/projects/{id}/counterclaim", handleCreateProjectCounterclaim)
|
||||
protected.HandleFunc("GET /api/projects/{id}/children", handleListProjectChildren)
|
||||
protected.HandleFunc("GET /api/projects/{id}/tree", handleGetProjectTree)
|
||||
protected.HandleFunc("POST /api/projects/{id}/pin", handlePinProject)
|
||||
|
||||
393
internal/handlers/projection.go
Normal file
393
internal/handlers/projection.go
Normal file
@@ -0,0 +1,393 @@
|
||||
package handlers
|
||||
|
||||
// HTTP surface for the SmartTimeline (t-paliad-171, design doc
|
||||
// docs/design-smart-timeline-2026-05-08.md). Two endpoints:
|
||||
//
|
||||
// GET /api/projects/{id}/timeline — read the merged timeline
|
||||
// POST /api/projects/{id}/timeline/milestone — write a custom milestone
|
||||
//
|
||||
// Both go through ProjectionService, which delegates visibility + RLS
|
||||
// to DeadlineService / AppointmentService and enforces the project_events
|
||||
// gate inline. No new RLS surface here.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
|
||||
// GET /api/projects/{id}/timeline
|
||||
//
|
||||
// Query parameters:
|
||||
//
|
||||
// ?include=audit_full — when present, project_events are returned
|
||||
// without the timeline_kind filter (legacy
|
||||
// Verlauf chronological view, behind the
|
||||
// "Audit-Log anzeigen" toggle).
|
||||
// ?direct_only=1|true — narrow to events whose project_id exactly
|
||||
// matches; default is project + descendants.
|
||||
func handleGetProjectTimeline(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.projection == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "projection service unavailable",
|
||||
})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
q := r.URL.Query()
|
||||
opts := services.ProjectionOpts{
|
||||
IncludeAuditFull: q.Get("include") == "audit_full",
|
||||
DirectOnly: parseDirectOnly(q.Get("direct_only")),
|
||||
LookaheadCap: parseLookahead(q.Get("lookahead")),
|
||||
Lang: q.Get("lang"),
|
||||
}
|
||||
rows, meta, err := dbSvc.projection.For(r.Context(), uid, id, opts)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
// Always return [], never null — the frontend reads .length on the
|
||||
// result and would crash on a JSON null.
|
||||
if rows == nil {
|
||||
rows = []services.TimelineEvent{}
|
||||
}
|
||||
lanes := meta.Lanes
|
||||
if lanes == nil {
|
||||
lanes = []services.LaneInfo{}
|
||||
}
|
||||
// Surface projection meta via headers — Slice 1-3 frontends still
|
||||
// read X-Projection-Total / Lookahead / Tracks for the lookahead
|
||||
// toggle and Track chip.
|
||||
w.Header().Set("X-Projection-Has", boolStr(meta.HasProjection))
|
||||
w.Header().Set("X-Projection-Total", itoa(meta.ProjectedTotal))
|
||||
w.Header().Set("X-Projection-Shown", itoa(meta.ProjectedShown))
|
||||
w.Header().Set("X-Projection-Overdue", itoa(meta.PredictedOverdue))
|
||||
w.Header().Set("X-Projection-Lookahead", itoa(meta.Lookahead))
|
||||
if len(meta.AvailableTracks) > 0 {
|
||||
// Comma-separated list of track tags ("parent", "counterclaim:<id>",
|
||||
// "parent_context:<id>"). Track ids are UUIDs — safe in headers.
|
||||
w.Header().Set("X-Projection-Tracks", strings.Join(meta.AvailableTracks, ","))
|
||||
}
|
||||
// Slice 4 changed the wire shape from []TimelineEvent to an envelope
|
||||
// {events, lanes} so lane metadata can ride alongside the rows
|
||||
// without exceeding header-size limits when a Client-level
|
||||
// projection has many lanes. The frontend reads .events for the
|
||||
// per-row contract and .lanes for parallel-column rendering.
|
||||
writeJSON(w, http.StatusOK, services.ResponseEnvelope{
|
||||
Events: rows,
|
||||
Lanes: lanes,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/projects/{id}/timeline/anchor
|
||||
//
|
||||
// Body: {"rule_code":"inf.sod","actual_date":"2026-08-31","kind":"deadline"}
|
||||
//
|
||||
// 200 → AnchorResult JSON.
|
||||
// 409 → predecessor_missing payload (m/paliad#31 layer 3 sequence guard).
|
||||
// The frontend renders the message in the active language as an
|
||||
// inline error and offers a "Stattdessen <predecessor> erfassen"
|
||||
// link that pre-fills the editor for the parent rule.
|
||||
func handleProjectTimelineAnchor(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.projection == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "projection service unavailable",
|
||||
})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
RuleCode string `json:"rule_code"`
|
||||
ActualDate string `json:"actual_date"`
|
||||
Kind string `json:"kind,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
d, err := time.Parse("2006-01-02", body.ActualDate)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid actual_date — expected YYYY-MM-DD",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
res, err := dbSvc.projection.RecordAnchor(r.Context(), uid, id, services.AnchorInput{
|
||||
RuleCode: body.RuleCode,
|
||||
ActualDate: d,
|
||||
Kind: body.Kind,
|
||||
})
|
||||
if err != nil {
|
||||
if pme, ok := services.IsPredecessorMissing(err); ok {
|
||||
writeJSON(w, http.StatusConflict, map[string]any{
|
||||
"error": "predecessor_missing",
|
||||
"missing_rule_code": pme.MissingRuleCode,
|
||||
"missing_rule_name_de": pme.MissingRuleNameDE,
|
||||
"missing_rule_name_en": pme.MissingRuleNameEN,
|
||||
"requested_rule_code": pme.RequestedRuleCode,
|
||||
"requested_rule_name_de": pme.RequestedRuleNameDE,
|
||||
"requested_rule_name_en": pme.RequestedRuleNameEN,
|
||||
"message_de": "Bitte zuerst „" + pme.MissingRuleNameDE +
|
||||
"“ (" + pme.MissingRuleCode + ") erfassen — daraus folgt die Frist „" +
|
||||
pme.RequestedRuleNameDE + "“.",
|
||||
"message_en": "Anchor „" + pme.MissingRuleNameEN +
|
||||
"“ (" + pme.MissingRuleCode + ") first — „" +
|
||||
pme.RequestedRuleNameEN + "“ flows from it.",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
out := map[string]any{"updated": res.Updated}
|
||||
if res.DeadlineID != nil {
|
||||
out["deadline_id"] = res.DeadlineID.String()
|
||||
out["kind"] = "deadline"
|
||||
}
|
||||
if res.AppointmentID != nil {
|
||||
out["appointment_id"] = res.AppointmentID.String()
|
||||
out["kind"] = "appointment"
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// POST /api/projects/{id}/timeline/skip
|
||||
//
|
||||
// Body: {"rule_code":"inf.prelim","reason":"Beklagter hat keinen PO eingelegt"}
|
||||
//
|
||||
// Marks the rule as "ist nicht eingetreten / wurde verschoben" — the
|
||||
// projected row drops out of future reads until the user clears the
|
||||
// rule_skipped event (admin / audit-log path).
|
||||
func handleProjectTimelineSkip(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.projection == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "projection service unavailable",
|
||||
})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
RuleCode string `json:"rule_code"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
if err := dbSvc.projection.RecordRuleSkipped(r.Context(), uid, id, body.RuleCode, body.Reason); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
// parseLookahead reads the ?lookahead=N query parameter; clamps to
|
||||
// [1, MaxLookaheadCap] in the service. Returns 0 to mean "default" when
|
||||
// the parameter is missing or malformed.
|
||||
func parseLookahead(s string) int {
|
||||
if s == "" {
|
||||
return 0
|
||||
}
|
||||
n := 0
|
||||
for _, c := range s {
|
||||
if c < '0' || c > '9' {
|
||||
return 0
|
||||
}
|
||||
n = n*10 + int(c-'0')
|
||||
if n > 1000 {
|
||||
return 1000
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func boolStr(b bool) string {
|
||||
if b {
|
||||
return "true"
|
||||
}
|
||||
return "false"
|
||||
}
|
||||
|
||||
func itoa(n int) string {
|
||||
if n == 0 {
|
||||
return "0"
|
||||
}
|
||||
neg := n < 0
|
||||
if neg {
|
||||
n = -n
|
||||
}
|
||||
var buf [20]byte
|
||||
i := len(buf)
|
||||
for n > 0 {
|
||||
i--
|
||||
buf[i] = byte('0' + n%10)
|
||||
n /= 10
|
||||
}
|
||||
if neg {
|
||||
i--
|
||||
buf[i] = '-'
|
||||
}
|
||||
return string(buf[i:])
|
||||
}
|
||||
|
||||
// POST /api/projects/{id}/counterclaim
|
||||
//
|
||||
// Body: {
|
||||
// "proceeding_type_id": 9, // optional, defaults to UPC_REV
|
||||
// "flip_our_side": false, // optional, default-flip otherwise
|
||||
// "title": "EP3456789 — Widerklage (CCR)", // optional, auto-suggested
|
||||
// "case_number": "ACT_xxx_2026" // optional CCR case number
|
||||
// }
|
||||
//
|
||||
// Creates the CCR sub-project, writes audit rows on parent + child,
|
||||
// returns the new project's id + canonical URL.
|
||||
func handleCreateProjectCounterclaim(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
parentID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
FlipOurSide *bool `json:"flip_our_side,omitempty"`
|
||||
Title *string `json:"title,omitempty"`
|
||||
CaseNumber *string `json:"case_number,omitempty"`
|
||||
}
|
||||
// Empty body is fine — full default behaviour.
|
||||
if r.ContentLength > 0 {
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
opts := services.CounterclaimOpts{
|
||||
ProceedingTypeID: body.ProceedingTypeID,
|
||||
FlipOurSide: body.FlipOurSide,
|
||||
Title: body.Title,
|
||||
CaseNumber: body.CaseNumber,
|
||||
}
|
||||
child, err := dbSvc.projects.CreateCounterclaim(r.Context(), uid, parentID, opts)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, map[string]any{
|
||||
"id": child.ID,
|
||||
"url": "/projects/" + child.ID.String(),
|
||||
"counterclaim_of": child.CounterclaimOf,
|
||||
"parent_id": child.ParentID,
|
||||
"title": child.Title,
|
||||
"our_side": child.OurSide,
|
||||
"proceeding_type": child.ProceedingTypeID,
|
||||
"case_number": child.CaseNumber,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/projects/{id}/timeline/milestone
|
||||
//
|
||||
// Body shape: {"title": "...", "description": "...", "occurred_at": "YYYY-MM-DD"}
|
||||
//
|
||||
// Writes a paliad.project_events row with event_type='custom_milestone'
|
||||
// and timeline_kind='custom_milestone'. Returns the resulting
|
||||
// TimelineEvent so the caller can append it without a re-fetch.
|
||||
func handleCreateProjectTimelineMilestone(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.projection == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "projection service unavailable",
|
||||
})
|
||||
return
|
||||
}
|
||||
id, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Title string `json:"title"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
OccurredAt *string `json:"occurred_at,omitempty"`
|
||||
BubbleUp bool `json:"bubble_up,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||
return
|
||||
}
|
||||
|
||||
var occurred *time.Time
|
||||
if body.OccurredAt != nil && *body.OccurredAt != "" {
|
||||
t, err := time.Parse("2006-01-02", *body.OccurredAt)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||
"error": "invalid occurred_at — expected YYYY-MM-DD",
|
||||
})
|
||||
return
|
||||
}
|
||||
occurred = &t
|
||||
}
|
||||
|
||||
ev, err := dbSvc.projection.RecordCustomMilestone(r.Context(), uid, id,
|
||||
body.Title, body.Description, occurred, body.BubbleUp)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, ev)
|
||||
}
|
||||
@@ -49,6 +49,7 @@ type dbServices struct {
|
||||
broadcast *services.BroadcastService
|
||||
pin *services.PinService
|
||||
cardLayout *services.CardLayoutService
|
||||
projection *services.ProjectionService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
@@ -163,6 +163,14 @@ type Project struct {
|
||||
// claimant, defendant, court, both.
|
||||
OurSide *string `db:"our_side" json:"our_side,omitempty"`
|
||||
|
||||
// CounterclaimOf is the parent project this row is a counterclaim
|
||||
// (CCR) against (t-paliad-174 SmartTimeline Slice 3). NULL on
|
||||
// regular projects; non-NULL rows are CCR sub-projects rendered as
|
||||
// the parallel right-track on the parent's SmartTimeline. parent_id
|
||||
// keeps governing the project tree — the CCR child is placed as a
|
||||
// sibling under the same patent (§4.4 of the design doc).
|
||||
CounterclaimOf *uuid.UUID `db:"counterclaim_of" json:"counterclaim_of,omitempty"`
|
||||
|
||||
Metadata json.RawMessage `db:"metadata" json:"metadata"`
|
||||
AISummary *string `db:"ai_summary" json:"ai_summary,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
|
||||
@@ -114,14 +114,15 @@ type TimeSpec struct {
|
||||
type TimeHorizon string
|
||||
|
||||
const (
|
||||
HorizonNext7d TimeHorizon = "next_7d"
|
||||
HorizonNext30d TimeHorizon = "next_30d"
|
||||
HorizonNext90d TimeHorizon = "next_90d"
|
||||
HorizonPast30d TimeHorizon = "past_30d"
|
||||
HorizonPast90d TimeHorizon = "past_90d"
|
||||
HorizonAny TimeHorizon = "any"
|
||||
HorizonAll TimeHorizon = "all"
|
||||
HorizonCustom TimeHorizon = "custom"
|
||||
HorizonNext7d TimeHorizon = "next_7d"
|
||||
HorizonNext30d TimeHorizon = "next_30d"
|
||||
HorizonNext90d TimeHorizon = "next_90d"
|
||||
HorizonPast7d TimeHorizon = "past_7d"
|
||||
HorizonPast30d TimeHorizon = "past_30d"
|
||||
HorizonPast90d TimeHorizon = "past_90d"
|
||||
HorizonAny TimeHorizon = "any"
|
||||
HorizonAll TimeHorizon = "all"
|
||||
HorizonCustom TimeHorizon = "custom"
|
||||
)
|
||||
|
||||
type TimeField string
|
||||
@@ -279,7 +280,7 @@ func (s *ScopeSpec) validate() error {
|
||||
func (t *TimeSpec) validate(scope ScopeSpec) error {
|
||||
switch t.Horizon {
|
||||
case HorizonNext7d, HorizonNext30d, HorizonNext90d,
|
||||
HorizonPast30d, HorizonPast90d, HorizonAny:
|
||||
HorizonPast7d, HorizonPast30d, HorizonPast90d, HorizonAny:
|
||||
// fine
|
||||
case HorizonAll:
|
||||
// Q26: reject "all" unless scope.projects is explicit. Performance
|
||||
|
||||
@@ -97,7 +97,7 @@ func (s *ProjectService) DB() *sqlx.DB { return s.db }
|
||||
const projectColumns = `id, type, parent_id, path, title, reference, description, status,
|
||||
created_by, industry, country, billing_reference, client_number, matter_number,
|
||||
netdocuments_url, patent_number, filing_date, grant_date, court, case_number,
|
||||
proceeding_type_id, our_side, metadata, ai_summary, created_at, updated_at`
|
||||
proceeding_type_id, our_side, counterclaim_of, metadata, ai_summary, created_at, updated_at`
|
||||
|
||||
// CreateProjectInput is the payload for Create.
|
||||
type CreateProjectInput struct {
|
||||
@@ -122,6 +122,13 @@ type CreateProjectInput struct {
|
||||
CaseNumber *string `json:"case_number,omitempty"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
OurSide *string `json:"our_side,omitempty"`
|
||||
|
||||
// CounterclaimOf marks this project as a CCR sub-project filed
|
||||
// against the referenced parent project (t-paliad-174 Slice 3).
|
||||
// Set by ProjectService.CreateCounterclaim — direct callers of
|
||||
// Create rarely need it. The two-level-CCR rejection trigger
|
||||
// (migration 077) will reject malformed shapes regardless.
|
||||
CounterclaimOf *uuid.UUID `json:"counterclaim_of,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateProjectInput is the partial-update payload.
|
||||
@@ -831,9 +838,10 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
|
||||
(id, type, parent_id, path, title, reference, description, status,
|
||||
created_by, industry, country, billing_reference, client_number,
|
||||
matter_number, netdocuments_url, patent_number, filing_date, grant_date,
|
||||
court, case_number, proceeding_type_id, our_side, metadata, created_at, updated_at)
|
||||
court, case_number, proceeding_type_id, our_side, counterclaim_of,
|
||||
metadata, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $1::text, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13,
|
||||
$14, $15, $16, $17, $18, $19, $20, $21, '{}'::jsonb, $22, $22)`,
|
||||
$14, $15, $16, $17, $18, $19, $20, $21, $22, '{}'::jsonb, $23, $23)`,
|
||||
id, input.Type, input.ParentID,
|
||||
input.Title, input.Reference, input.Description, status,
|
||||
userID,
|
||||
@@ -842,6 +850,7 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
|
||||
input.PatentNumber, input.FilingDate, input.GrantDate,
|
||||
input.Court, input.CaseNumber, input.ProceedingTypeID,
|
||||
nullableOurSide(input.OurSide),
|
||||
input.CounterclaimOf,
|
||||
now,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert project: %w", err)
|
||||
@@ -1096,6 +1105,268 @@ func (s *ProjectService) Delete(ctx context.Context, userID, id uuid.UUID) error
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// CounterclaimOpts narrows CreateCounterclaim. Empty zero values fall back
|
||||
// to the design defaults: proceeding_type_id = UPC_REV, our_side = inverted
|
||||
// from the parent, title = "<patent reference> — Widerklage (CCR)" when a
|
||||
// patent reference is resolvable, else "<parent title> — Widerklage".
|
||||
//
|
||||
// FlipOurSide is a tri-state via *bool to distinguish "default-flip" (nil)
|
||||
// from the explicit "Stimmt nicht?" override (false = keep parent's side,
|
||||
// true = flip explicitly). The R.49.2.b CCI edge case is the reason this
|
||||
// override exists (see docs/design-smart-timeline-2026-05-08.md §11 Q2).
|
||||
type CounterclaimOpts struct {
|
||||
ProceedingTypeID *int
|
||||
FlipOurSide *bool
|
||||
Title *string
|
||||
CaseNumber *string
|
||||
}
|
||||
|
||||
// LoadCounterclaimChildrenVisible returns the CCR sub-projects filed
|
||||
// against parentID that the caller can see. Each row is a normal
|
||||
// paliad.projects row with counterclaim_of=parentID. Used by the
|
||||
// SmartTimeline to render parallel right-tracks (t-paliad-174 §4.5).
|
||||
func (s *ProjectService) LoadCounterclaimChildrenVisible(ctx context.Context, userID, parentID uuid.UUID) ([]models.Project, error) {
|
||||
user, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return []models.Project{}, nil
|
||||
}
|
||||
rows := []models.Project{}
|
||||
query := `SELECT ` + projectColumns + ` FROM paliad.projects p
|
||||
WHERE p.counterclaim_of = $1
|
||||
AND ` + visibilityPredicatePositional("p", 2) + `
|
||||
ORDER BY p.created_at ASC, p.id ASC`
|
||||
if err := s.db.SelectContext(ctx, &rows, query, parentID, userID); err != nil {
|
||||
return nil, fmt.Errorf("load counterclaim children: %w", err)
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// CreateCounterclaim creates a CCR sub-project against parentID. Atomic:
|
||||
// project + creator-as-lead team membership + audit rows on parent AND
|
||||
// child are all written in a single transaction.
|
||||
//
|
||||
// Placement (§4.4): the CCR child is a sibling under the same patent —
|
||||
// child.parent_id = parent.parent_id. When the parent has no parent_id
|
||||
// (root case at the top of its tree) we fall back to parent.id as the
|
||||
// CCR child's parent so the row remains in the same subtree.
|
||||
//
|
||||
// our_side flip (§11 Q2): default-inverts claimant↔defendant; "court"
|
||||
// and "both" pass through unchanged. The opts.FlipOurSide override
|
||||
// supports the rare R.49.2.b CCI shape where flipping is wrong.
|
||||
//
|
||||
// proceeding_type_id default (§4.4): UPC_REV for the standard CCR-on-
|
||||
// validity. UPC_CCI is the rarer R.49.2.b path; callers pass the id
|
||||
// explicitly when they want it.
|
||||
func (s *ProjectService) CreateCounterclaim(ctx context.Context, userID, parentID uuid.UUID, opts CounterclaimOpts) (*models.Project, error) {
|
||||
user, err := s.users.GetByID(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, fmt.Errorf("%w: user has no paliad.users row — onboarding required", ErrForbidden)
|
||||
}
|
||||
parent, err := s.GetByID(ctx, userID, parentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if parent.CounterclaimOf != nil {
|
||||
return nil, fmt.Errorf("%w: parent project is itself a counterclaim — two-level CCR chains are not allowed", ErrInvalidInput)
|
||||
}
|
||||
|
||||
// Resolve proceeding_type_id default to UPC_REV when caller didn't
|
||||
// override. The DB row is required because the projection layer
|
||||
// dereferences it (paliad.proceeding_types.code).
|
||||
procTypeID := 0
|
||||
if opts.ProceedingTypeID != nil {
|
||||
procTypeID = *opts.ProceedingTypeID
|
||||
} else {
|
||||
err := s.db.GetContext(ctx, &procTypeID,
|
||||
`SELECT id FROM paliad.proceeding_types
|
||||
WHERE code = 'UPC_REV' AND is_active = true`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("resolve default UPC_REV proceeding type: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
childOurSide := derivedCounterclaimOurSide(parent.OurSide, opts.FlipOurSide)
|
||||
childParentID := parent.ParentID
|
||||
if childParentID == nil {
|
||||
// Parent has no parent_id (root case at the top of its tree).
|
||||
// Fall back to parent.id so the CCR child stays in the same
|
||||
// subtree rather than becoming a new root. The visibility
|
||||
// predicate inherits cleanly either way.
|
||||
fallback := parent.ID
|
||||
childParentID = &fallback
|
||||
}
|
||||
|
||||
// Resolve the best patent reference for the suggested title — when
|
||||
// parent is a case, the patent_number lives on its patent ancestor.
|
||||
patentRef := s.resolvePatentReferenceForTitle(ctx, userID, parent)
|
||||
title := derivedCounterclaimTitle(parent, patentRef, opts.Title)
|
||||
|
||||
tx, err := s.db.BeginTxx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("begin tx: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
id := uuid.New()
|
||||
now := time.Now().UTC()
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects
|
||||
(id, type, parent_id, path, title, status, created_by,
|
||||
court, case_number, proceeding_type_id, our_side, counterclaim_of,
|
||||
metadata, created_at, updated_at)
|
||||
VALUES ($1, 'case', $2, $1::text, $3, 'active', $4,
|
||||
$5, $6, $7, $8, $9, '{}'::jsonb, $10, $10)`,
|
||||
id, childParentID, title, userID,
|
||||
parent.Court, opts.CaseNumber, procTypeID,
|
||||
nullableOurSide(&childOurSide), parentID, now,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("insert counterclaim project: %w", err)
|
||||
}
|
||||
|
||||
// Auto-add creator as team lead on the new CCR row so RLS lets the
|
||||
// caller see the project they just made. Mirrors Create.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
|
||||
VALUES ($1, $2, 'lead', 'lead', false, $2)`, id, userID); err != nil {
|
||||
return nil, fmt.Errorf("insert creator team row: %w", err)
|
||||
}
|
||||
|
||||
// Audit rows on both parent and child for symmetric trail. Both rows
|
||||
// opt into the SmartTimeline via timeline_kind='milestone'. The
|
||||
// bubble_up=true flag (t-paliad-175 §5.3 Q5) lets these structural
|
||||
// milestones surface on Patent / Litigation / Client SmartTimelines
|
||||
// even though the level policy filters out other milestones.
|
||||
if err := insertCounterclaimEvent(ctx, tx, id, userID,
|
||||
"Widerklage (CCR) angelegt",
|
||||
map[string]any{
|
||||
"counterclaim_of": parentID.String(),
|
||||
"bubble_up": true,
|
||||
},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := insertCounterclaimEvent(ctx, tx, parentID, userID,
|
||||
"Widerklage (CCR) angelegt",
|
||||
map[string]any{
|
||||
"counterclaim_id": id.String(),
|
||||
"bubble_up": true,
|
||||
},
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit create counterclaim: %w", err)
|
||||
}
|
||||
return s.GetByID(ctx, userID, id)
|
||||
}
|
||||
|
||||
// insertCounterclaimEvent writes a paliad.project_events row with
|
||||
// event_type='counterclaim_created' AND timeline_kind='milestone' so
|
||||
// the audit row surfaces on the SmartTimeline by default. Matches the
|
||||
// pattern Slice 1 established for opt-in milestones (§2.2).
|
||||
func insertCounterclaimEvent(ctx context.Context, tx *sqlx.Tx, projectID, userID uuid.UUID, title string, meta map[string]any) error {
|
||||
now := time.Now().UTC()
|
||||
metaJSON := json.RawMessage(`{}`)
|
||||
if len(meta) > 0 {
|
||||
b, err := json.Marshal(meta)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal counterclaim_created metadata: %w", err)
|
||||
}
|
||||
metaJSON = b
|
||||
}
|
||||
_, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_events
|
||||
(id, project_id, event_type, title, description, event_date,
|
||||
created_by, metadata, created_at, updated_at, timeline_kind)
|
||||
VALUES ($1, $2, 'counterclaim_created', $3, NULL, $4, $5, $6, $4, $4, 'milestone')`,
|
||||
uuid.New(), projectID, title, now, userID, metaJSON)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert counterclaim_created event: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// derivedCounterclaimOurSide computes the child's our_side from the
|
||||
// parent's our_side and the opts.FlipOurSide override.
|
||||
//
|
||||
// Default (override nil OR override=true): claimant ↔ defendant, court
|
||||
// and both pass through unchanged. NULL parent yields NULL child — the
|
||||
// flip is meaningless without a known starting side.
|
||||
//
|
||||
// Override=false: keep parent's side as-is. R.49.2.b CCI is the named
|
||||
// edge case where the CCR sub-project shares the parent's perspective.
|
||||
func derivedCounterclaimOurSide(parentSide *string, override *bool) string {
|
||||
if parentSide == nil {
|
||||
return ""
|
||||
}
|
||||
side := strings.TrimSpace(*parentSide)
|
||||
flip := true
|
||||
if override != nil {
|
||||
flip = *override
|
||||
}
|
||||
if !flip {
|
||||
return side
|
||||
}
|
||||
switch side {
|
||||
case "claimant":
|
||||
return "defendant"
|
||||
case "defendant":
|
||||
return "claimant"
|
||||
default:
|
||||
return side
|
||||
}
|
||||
}
|
||||
|
||||
// resolvePatentReferenceForTitle returns the closest patent_number /
|
||||
// reference to use as the CCR title prefix. Parent is usually a case
|
||||
// row (no patent_number on it) — walks up ancestors to find the patent
|
||||
// hub. Best-effort: returns empty when no patent ancestor is visible.
|
||||
func (s *ProjectService) resolvePatentReferenceForTitle(ctx context.Context, userID uuid.UUID, parent *models.Project) string {
|
||||
if parent.PatentNumber != nil && strings.TrimSpace(*parent.PatentNumber) != "" {
|
||||
return strings.TrimSpace(*parent.PatentNumber)
|
||||
}
|
||||
ancestors, err := s.ListAncestors(ctx, userID, parent.ID)
|
||||
if err != nil || len(ancestors) == 0 {
|
||||
return ""
|
||||
}
|
||||
for i := len(ancestors) - 1; i >= 0; i-- {
|
||||
a := ancestors[i]
|
||||
if a.PatentNumber != nil && strings.TrimSpace(*a.PatentNumber) != "" {
|
||||
return strings.TrimSpace(*a.PatentNumber)
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// derivedCounterclaimTitle picks the auto-suggested title for the CCR
|
||||
// child. Override wins when supplied; otherwise prefers the patent
|
||||
// reference, then parent.reference, then parent.title — each yields
|
||||
// "<ref> — Widerklage (CCR)".
|
||||
func derivedCounterclaimTitle(parent *models.Project, patentRef string, override *string) string {
|
||||
if override != nil {
|
||||
v := strings.TrimSpace(*override)
|
||||
if v != "" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
suffix := " — Widerklage (CCR)"
|
||||
if patentRef != "" {
|
||||
return patentRef + suffix
|
||||
}
|
||||
if parent.Reference != nil && strings.TrimSpace(*parent.Reference) != "" {
|
||||
return strings.TrimSpace(*parent.Reference) + suffix
|
||||
}
|
||||
return strings.TrimSpace(parent.Title) + suffix
|
||||
}
|
||||
|
||||
// MaxEventsPageLimit caps ListEvents page size.
|
||||
const MaxEventsPageLimit = 200
|
||||
|
||||
|
||||
294
internal/services/projection_anchor_test.go
Normal file
294
internal/services/projection_anchor_test.go
Normal file
@@ -0,0 +1,294 @@
|
||||
package services
|
||||
|
||||
// Pure-function tests for ProjectionService Slice 2 (t-paliad-173) —
|
||||
// no DB required. Validates lookahead cap behaviour, anchor-kind
|
||||
// dispatch, and extractMetadataString. The live integration test in
|
||||
// projection_service_test.go covers SQL paths; this file covers the
|
||||
// pure helpers a future refactor most likely to break.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
func TestApplyLookaheadDefault(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want int
|
||||
}{
|
||||
{0, DefaultLookaheadCap},
|
||||
{-5, DefaultLookaheadCap},
|
||||
{1, 1},
|
||||
{7, 7},
|
||||
{50, 50},
|
||||
{51, MaxLookaheadCap},
|
||||
{1000, MaxLookaheadCap},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := applyLookaheadDefault(c.in); got != c.want {
|
||||
t.Errorf("applyLookaheadDefault(%d) = %d, want %d", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLookaheadCap_DropsBeyondCap_ExemptsOverdueAndCourtSet(t *testing.T) {
|
||||
mar1 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||
apr1 := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
||||
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
jun1 := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
jul1 := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
rows := []TimelineEvent{
|
||||
// Two predicted_overdue (past) — must survive uncapped.
|
||||
{Kind: "projected", Status: "predicted_overdue", Date: &mar1, RuleCode: "rule.past1", Title: "Past 1"},
|
||||
{Kind: "projected", Status: "predicted_overdue", Date: &apr1, RuleCode: "rule.past2", Title: "Past 2"},
|
||||
// One court_set future — exempt from cap.
|
||||
{Kind: "projected", Status: "court_set", Date: &jul1, RuleCode: "rule.hearing", Title: "Hearing"},
|
||||
// Three predicted future — cap=2 means the third drops.
|
||||
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "rule.fut1", Title: "Fut 1"},
|
||||
{Kind: "projected", Status: "predicted", Date: &jun1, RuleCode: "rule.fut2", Title: "Fut 2"},
|
||||
{Kind: "projected", Status: "predicted", Date: &jul1, RuleCode: "rule.fut3", Title: "Fut 3"},
|
||||
}
|
||||
|
||||
kept, total, shown, overdue := applyLookaheadCap(rows, 2)
|
||||
if total != 3 {
|
||||
t.Errorf("ProjectedTotal = %d, want 3", total)
|
||||
}
|
||||
if shown != 2 {
|
||||
t.Errorf("ProjectedShown = %d, want 2", shown)
|
||||
}
|
||||
if overdue != 2 {
|
||||
t.Errorf("PredictedOverdue = %d, want 2", overdue)
|
||||
}
|
||||
// kept must include both overdue + court_set + first 2 predicted = 5 rows.
|
||||
if len(kept) != 5 {
|
||||
t.Errorf("kept rows = %d, want 5", len(kept))
|
||||
}
|
||||
// Past + court_set must remain.
|
||||
pastTitles := map[string]bool{}
|
||||
for _, r := range kept {
|
||||
pastTitles[r.Title] = true
|
||||
}
|
||||
for _, want := range []string{"Past 1", "Past 2", "Hearing", "Fut 1", "Fut 2"} {
|
||||
if !pastTitles[want] {
|
||||
t.Errorf("expected kept row %q missing", want)
|
||||
}
|
||||
}
|
||||
if pastTitles["Fut 3"] {
|
||||
t.Errorf("Fut 3 should have been dropped (cap=2)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLookaheadCap_NoCapWhenUnderLimit(t *testing.T) {
|
||||
may1 := time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC)
|
||||
rows := []TimelineEvent{
|
||||
{Kind: "projected", Status: "predicted", Date: &may1, RuleCode: "r1", Title: "1"},
|
||||
}
|
||||
kept, total, shown, _ := applyLookaheadCap(rows, 7)
|
||||
if total != 1 || shown != 1 {
|
||||
t.Errorf("counts = (%d, %d), want (1, 1)", total, shown)
|
||||
}
|
||||
if len(kept) != 1 {
|
||||
t.Errorf("kept = %d, want 1", len(kept))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleAnchorKind(t *testing.T) {
|
||||
hearing := "hearing"
|
||||
decision := "decision"
|
||||
order := "order"
|
||||
filing := "filing"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rule *models.DeadlineRule
|
||||
want string
|
||||
}{
|
||||
{"hearing → appointment", &models.DeadlineRule{EventType: &hearing}, "appointment"},
|
||||
{"decision → appointment", &models.DeadlineRule{EventType: &decision}, "appointment"},
|
||||
{"order → appointment", &models.DeadlineRule{EventType: &order}, "appointment"},
|
||||
{"filing → deadline", &models.DeadlineRule{EventType: &filing}, "deadline"},
|
||||
{"nil event_type → deadline", &models.DeadlineRule{}, "deadline"},
|
||||
{"nil rule → deadline", nil, "deadline"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := ruleAnchorKind(c.rule); got != c.want {
|
||||
t.Errorf("ruleAnchorKind = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractMetadataString(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
key string
|
||||
want string
|
||||
}{
|
||||
{"present", `{"rule_code":"inf.sod","reason":"foo"}`, "rule_code", "inf.sod"},
|
||||
{"missing key", `{"foo":"bar"}`, "rule_code", ""},
|
||||
{"empty json", `{}`, "rule_code", ""},
|
||||
{"empty raw", ``, "rule_code", ""},
|
||||
{"non-string value", `{"rule_code":123}`, "rule_code", ""},
|
||||
{"malformed", `{`, "rule_code", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := extractMetadataString(json.RawMessage(c.raw), c.key)
|
||||
if got != c.want {
|
||||
t.Errorf("extractMetadataString = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLang(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"", "de"},
|
||||
{"de", "de"},
|
||||
{"DE", "de"},
|
||||
{"en", "en"},
|
||||
{"EN", "en"},
|
||||
{" en ", "en"},
|
||||
{"fr", "de"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := lang(c.in); got != c.want {
|
||||
t.Errorf("lang(%q) = %q, want %q", c.in, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuleNameInLang(t *testing.T) {
|
||||
r := models.DeadlineRule{Name: "Klageerwiderung", NameEN: "Statement of Defence"}
|
||||
if got := ruleNameInLang(r, "de"); got != "Klageerwiderung" {
|
||||
t.Errorf("de = %q", got)
|
||||
}
|
||||
if got := ruleNameInLang(r, "en"); got != "Statement of Defence" {
|
||||
t.Errorf("en = %q", got)
|
||||
}
|
||||
rNoEN := models.DeadlineRule{Name: "Klageerwiderung"}
|
||||
if got := ruleNameInLang(rNoEN, "en"); got != "Klageerwiderung" {
|
||||
t.Errorf("missing EN should fall back to DE, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPredecessorMissingError(t *testing.T) {
|
||||
pme := &PredecessorMissingError{
|
||||
MissingRuleCode: "inf.soc",
|
||||
MissingRuleNameDE: "Klageschrift",
|
||||
MissingRuleNameEN: "Statement of Claim",
|
||||
RequestedRuleCode: "inf.sod",
|
||||
RequestedRuleNameDE: "Klageerwiderung",
|
||||
RequestedRuleNameEN: "Statement of Defence",
|
||||
}
|
||||
got, ok := IsPredecessorMissing(pme)
|
||||
if !ok {
|
||||
t.Fatal("IsPredecessorMissing on direct error should return ok")
|
||||
}
|
||||
if got != pme {
|
||||
t.Errorf("unwrapped pointer mismatch")
|
||||
}
|
||||
// Wrapping with errors.Errorf-style fmt should still unwrap.
|
||||
wrapped := wrap(pme, "context")
|
||||
got2, ok2 := IsPredecessorMissing(wrapped)
|
||||
if !ok2 {
|
||||
t.Fatal("IsPredecessorMissing on wrapped error should return ok")
|
||||
}
|
||||
if got2 != pme {
|
||||
t.Errorf("unwrapped wrapped pointer mismatch")
|
||||
}
|
||||
// Random other error must not unwrap.
|
||||
if _, ok := IsPredecessorMissing(errOther{}); ok {
|
||||
t.Error("non-PME should not unwrap as PME")
|
||||
}
|
||||
}
|
||||
|
||||
// wrap is a tiny test helper that mimics fmt.Errorf("%w") wrapping.
|
||||
func wrap(err error, msg string) error {
|
||||
return wrappedErr{msg: msg, inner: err}
|
||||
}
|
||||
|
||||
type wrappedErr struct {
|
||||
msg string
|
||||
inner error
|
||||
}
|
||||
|
||||
func (w wrappedErr) Error() string { return w.msg + ": " + w.inner.Error() }
|
||||
func (w wrappedErr) Unwrap() error { return w.inner }
|
||||
|
||||
type errOther struct{}
|
||||
|
||||
func (errOther) Error() string { return "other" }
|
||||
|
||||
func TestAnnotateDependsOn(t *testing.T) {
|
||||
socID := uuid.New()
|
||||
sodID := uuid.New()
|
||||
replyID := uuid.New()
|
||||
socCode := "inf.soc"
|
||||
sodCode := "inf.sod"
|
||||
replyCode := "inf.reply"
|
||||
|
||||
rules := []models.DeadlineRule{
|
||||
{ID: socID, Code: &socCode, Name: "Klageschrift", NameEN: "Statement of Claim"},
|
||||
{ID: sodID, ParentID: &socID, Code: &sodCode, Name: "Klageerwiderung", NameEN: "Statement of Defence"},
|
||||
{ID: replyID, ParentID: &sodID, Code: &replyCode, Name: "Replik", NameEN: "Reply"},
|
||||
}
|
||||
|
||||
socDate := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC)
|
||||
sodDate := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
rows := []TimelineEvent{
|
||||
// SoC actual.
|
||||
{Kind: "deadline", Status: "done", Date: &socDate, RuleCode: socCode,
|
||||
DeadlineRuleID: ptrUUID(socID)},
|
||||
// SoD projected.
|
||||
{Kind: "projected", Status: "predicted", Date: &sodDate, RuleCode: sodCode,
|
||||
DeadlineRuleID: ptrUUID(sodID)},
|
||||
// Reply projected — depends on SoD.
|
||||
{Kind: "projected", Status: "predicted", RuleCode: replyCode,
|
||||
DeadlineRuleID: ptrUUID(replyID)},
|
||||
}
|
||||
|
||||
svc := &ProjectionService{}
|
||||
svc.annotateDependsOn(rows, rules, "de")
|
||||
|
||||
// SoC has no parent — depends_on stays empty.
|
||||
if rows[0].DependsOnRuleCode != "" {
|
||||
t.Errorf("SoC should have no depends_on, got %q", rows[0].DependsOnRuleCode)
|
||||
}
|
||||
// SoD's depends_on is SoC, dated.
|
||||
if rows[1].DependsOnRuleCode != socCode {
|
||||
t.Errorf("SoD depends_on = %q, want %q", rows[1].DependsOnRuleCode, socCode)
|
||||
}
|
||||
if rows[1].DependsOnRuleName != "Klageschrift" {
|
||||
t.Errorf("SoD depends_on name = %q (de)", rows[1].DependsOnRuleName)
|
||||
}
|
||||
if rows[1].DependsOnDate == nil || !rows[1].DependsOnDate.Equal(socDate) {
|
||||
t.Errorf("SoD depends_on_date = %v, want %v", rows[1].DependsOnDate, socDate)
|
||||
}
|
||||
// Reply's depends_on is SoD, dated (from SoD's projected date).
|
||||
if rows[2].DependsOnRuleCode != sodCode {
|
||||
t.Errorf("Reply depends_on = %q", rows[2].DependsOnRuleCode)
|
||||
}
|
||||
if rows[2].DependsOnDate == nil || !rows[2].DependsOnDate.Equal(sodDate) {
|
||||
t.Errorf("Reply depends_on_date = %v, want %v (SoD's projected date)",
|
||||
rows[2].DependsOnDate, sodDate)
|
||||
}
|
||||
|
||||
// English mode flips the name.
|
||||
svc.annotateDependsOn(rows, rules, "en")
|
||||
if rows[1].DependsOnRuleName != "Statement of Claim" {
|
||||
t.Errorf("SoD depends_on name (en) = %q", rows[1].DependsOnRuleName)
|
||||
}
|
||||
}
|
||||
|
||||
func ptrUUID(u uuid.UUID) *uuid.UUID { return &u }
|
||||
302
internal/services/projection_counterclaim_test.go
Normal file
302
internal/services/projection_counterclaim_test.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package services
|
||||
|
||||
// Live-DB integration test for the counterclaim sub-project shape
|
||||
// (t-paliad-174 SmartTimeline Slice 3). Skipped without TEST_DATABASE_URL,
|
||||
// matching the convention of the other live tests in this package.
|
||||
//
|
||||
// The test exercises the end-to-end shape:
|
||||
// 1. CreateCounterclaim atomically creates child + flips our_side +
|
||||
// writes audit rows on parent AND child + sets counterclaim_of.
|
||||
// 2. parent_id of the child equals parent's parent_id (sibling-under-
|
||||
// patent placement).
|
||||
// 3. ProjectionService.For on the parent surfaces a parallel-track
|
||||
// counterclaim event; AvailableTracks lists the new track.
|
||||
// 4. ProjectionService.For on the child surfaces the parent's events
|
||||
// with track="parent_context:<parent_id>".
|
||||
// 5. Two-level CCR chains are rejected at the schema level.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
func TestCreateCounterclaim_Live(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
userID := uuid.New()
|
||||
patentID := uuid.New() // sibling parent: the patent hub
|
||||
caseID := uuid.New() // the parent case (UPC_INF)
|
||||
|
||||
// Resolve UPC_INF + UPC_REV ids once. We need real ids from the
|
||||
// proceeding_types seed because they're NOT NULL on the test row.
|
||||
var upcInf, upcRev int
|
||||
if err := pool.GetContext(ctx, &upcInf,
|
||||
`SELECT id FROM paliad.proceeding_types WHERE code = 'UPC_INF'`); err != nil {
|
||||
t.Fatalf("resolve UPC_INF: %v", err)
|
||||
}
|
||||
if err := pool.GetContext(ctx, &upcRev,
|
||||
`SELECT id FROM paliad.proceeding_types WHERE code = 'UPC_REV'`); err != nil {
|
||||
t.Fatalf("resolve UPC_REV: %v", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
// Delete CCR children first (FK to caseID via counterclaim_of is
|
||||
// ON DELETE SET NULL but the child rows still hold parent_id =
|
||||
// patentID — clear them via a parent_id sweep).
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN (
|
||||
SELECT id FROM paliad.projects WHERE counterclaim_of = $1)`, caseID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN (
|
||||
SELECT id FROM paliad.projects WHERE counterclaim_of = $1)`, caseID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE counterclaim_of = $1`, caseID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, caseID, patentID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN ($1, $2)`, caseID, patentID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id IN ($1, $2)`, caseID, patentID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, 'ccr-test@hlc.com')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
||||
VALUES ($1, 'ccr-test@hlc.com', 'CCR Test', 'munich', 'global_admin', 'de')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
// Parent patent hub.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, path, title, patent_number, status, created_by)
|
||||
VALUES ($1, 'patent', $1::text, 'EP3456789 — Test Patent', 'EP3456789', 'active', $2)`,
|
||||
patentID, userID); err != nil {
|
||||
t.Fatalf("seed patent: %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)`,
|
||||
patentID, userID); err != nil {
|
||||
t.Fatalf("seed patent team: %v", err)
|
||||
}
|
||||
// Child case (UPC_INF) under the patent.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects
|
||||
(id, type, parent_id, path, title, status, created_by,
|
||||
proceeding_type_id, our_side)
|
||||
VALUES ($1, 'case', $2, $2::text || '.' || $1::text,
|
||||
'UPC-CFI München — Klage', 'active', $3, $4, 'claimant')`,
|
||||
caseID, patentID, userID, upcInf); err != nil {
|
||||
t.Fatalf("seed case: %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)`,
|
||||
caseID, userID); err != nil {
|
||||
t.Fatalf("seed case team: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
eventTypes := NewEventTypeService(pool, users)
|
||||
deadlines := NewDeadlineService(pool, projects, eventTypes)
|
||||
appointments := NewAppointmentService(pool, projects)
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
holidays := NewHolidayService(pool)
|
||||
courts := NewCourtService(pool)
|
||||
fristen := NewFristenrechnerService(rules, holidays, courts)
|
||||
projection := NewProjectionService(pool, projects, deadlines, appointments, fristen, rules)
|
||||
|
||||
t.Run("CreateCounterclaim flips our_side, places sibling, audits both", func(t *testing.T) {
|
||||
child, err := projects.CreateCounterclaim(ctx, userID, caseID, CounterclaimOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCounterclaim: %v", err)
|
||||
}
|
||||
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, child.ID, caseID)
|
||||
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, child.ID)
|
||||
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, child.ID)
|
||||
|
||||
// 1. counterclaim_of points at the parent.
|
||||
if child.CounterclaimOf == nil || *child.CounterclaimOf != caseID {
|
||||
t.Errorf("child.CounterclaimOf = %v, want %v", child.CounterclaimOf, caseID)
|
||||
}
|
||||
// 2. parent_id = parent's parent_id = patent hub (sibling-under-patent).
|
||||
if child.ParentID == nil || *child.ParentID != patentID {
|
||||
t.Errorf("child.ParentID = %v, want %v (sibling under patent)", child.ParentID, patentID)
|
||||
}
|
||||
// 3. our_side flipped: parent claimant → child defendant.
|
||||
if child.OurSide == nil || *child.OurSide != "defendant" {
|
||||
t.Errorf("child.OurSide = %v, want defendant", child.OurSide)
|
||||
}
|
||||
// 4. Default proceeding_type_id resolved to UPC_REV.
|
||||
if child.ProceedingTypeID == nil || *child.ProceedingTypeID != upcRev {
|
||||
t.Errorf("child.ProceedingTypeID = %v, want UPC_REV (%d)", child.ProceedingTypeID, upcRev)
|
||||
}
|
||||
// 5. Auto-suggested title carries the patent reference + suffix.
|
||||
if !strings.Contains(child.Title, "EP3456789") || !strings.Contains(child.Title, "Widerklage") {
|
||||
t.Errorf("child.Title = %q, want it to contain EP3456789 and Widerklage", child.Title)
|
||||
}
|
||||
|
||||
// 6. Audit rows on BOTH parent and child with timeline_kind='milestone'.
|
||||
var parentAudit, childAudit int
|
||||
if err := pool.GetContext(ctx, &parentAudit,
|
||||
`SELECT count(*) FROM paliad.project_events
|
||||
WHERE project_id = $1 AND event_type = 'counterclaim_created'
|
||||
AND timeline_kind = 'milestone'`, caseID); err != nil {
|
||||
t.Fatalf("count parent audit: %v", err)
|
||||
}
|
||||
if parentAudit != 1 {
|
||||
t.Errorf("parent counterclaim_created rows = %d, want 1", parentAudit)
|
||||
}
|
||||
if err := pool.GetContext(ctx, &childAudit,
|
||||
`SELECT count(*) FROM paliad.project_events
|
||||
WHERE project_id = $1 AND event_type = 'counterclaim_created'
|
||||
AND timeline_kind = 'milestone'`, child.ID); err != nil {
|
||||
t.Fatalf("count child audit: %v", err)
|
||||
}
|
||||
if childAudit != 1 {
|
||||
t.Errorf("child counterclaim_created rows = %d, want 1", childAudit)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ProjectionService.For on parent surfaces counterclaim track", func(t *testing.T) {
|
||||
child, err := projects.CreateCounterclaim(ctx, userID, caseID, CounterclaimOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCounterclaim: %v", err)
|
||||
}
|
||||
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, child.ID, caseID)
|
||||
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, child.ID)
|
||||
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, child.ID)
|
||||
|
||||
rows, meta, err := projection.For(ctx, userID, caseID, ProjectionOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("projection.For parent: %v", err)
|
||||
}
|
||||
// AvailableTracks contains parent + the new counterclaim track.
|
||||
expectTrack := "counterclaim:" + child.ID.String()
|
||||
var sawCounterclaimTrack bool
|
||||
for _, t := range meta.AvailableTracks {
|
||||
if t == expectTrack {
|
||||
sawCounterclaimTrack = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !sawCounterclaimTrack {
|
||||
t.Errorf("AvailableTracks = %v, want to contain %q", meta.AvailableTracks, expectTrack)
|
||||
}
|
||||
|
||||
// At least one row carries the counterclaim track + the
|
||||
// SubProjectID = child.ID.
|
||||
var countCCR int
|
||||
for _, r := range rows {
|
||||
if r.Track == expectTrack {
|
||||
countCCR++
|
||||
if r.SubProjectID == nil || *r.SubProjectID != child.ID {
|
||||
t.Errorf("ccr-track row missing SubProjectID = child.ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
if countCCR == 0 {
|
||||
t.Errorf("expected at least one row on counterclaim track, saw 0 (rows=%d)", len(rows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ProjectionService.For on child surfaces parent_context track", func(t *testing.T) {
|
||||
child, err := projects.CreateCounterclaim(ctx, userID, caseID, CounterclaimOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCounterclaim: %v", err)
|
||||
}
|
||||
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, child.ID, caseID)
|
||||
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, child.ID)
|
||||
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, child.ID)
|
||||
|
||||
rows, meta, err := projection.For(ctx, userID, child.ID, ProjectionOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("projection.For child: %v", err)
|
||||
}
|
||||
expectTrack := "parent_context:" + caseID.String()
|
||||
var sawParentContext bool
|
||||
for _, t := range meta.AvailableTracks {
|
||||
if t == expectTrack {
|
||||
sawParentContext = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !sawParentContext {
|
||||
t.Errorf("AvailableTracks = %v, want to contain %q", meta.AvailableTracks, expectTrack)
|
||||
}
|
||||
var countParentCtx int
|
||||
for _, r := range rows {
|
||||
if r.Track == expectTrack {
|
||||
countParentCtx++
|
||||
if r.SubProjectID == nil || *r.SubProjectID != caseID {
|
||||
t.Errorf("parent_context row missing SubProjectID = parent.ID")
|
||||
}
|
||||
}
|
||||
}
|
||||
if countParentCtx == 0 {
|
||||
t.Errorf("expected at least one parent_context row, saw 0 (rows=%d)", len(rows))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Two-level CCR chains are rejected at the schema level", func(t *testing.T) {
|
||||
child, err := projects.CreateCounterclaim(ctx, userID, caseID, CounterclaimOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateCounterclaim: %v", err)
|
||||
}
|
||||
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2)`, child.ID, caseID)
|
||||
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, child.ID)
|
||||
defer pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, child.ID)
|
||||
|
||||
// Trying to create a CCR against the CCR child = two-level chain.
|
||||
// CreateCounterclaim guards with an early ErrInvalidInput before
|
||||
// hitting the trigger; verify the early guard fires.
|
||||
_, err = projects.CreateCounterclaim(ctx, userID, child.ID, CounterclaimOpts{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for two-level CCR chain, got nil")
|
||||
}
|
||||
if !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("expected ErrInvalidInput, got %v", err)
|
||||
}
|
||||
|
||||
// Also pin the schema-level trigger guard: a direct INSERT
|
||||
// pointing at a row that already has counterclaim_of NOT NULL
|
||||
// must be rejected by paliad.projects_no_two_level_ccr.
|
||||
grandchild := uuid.New()
|
||||
_, err = pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects
|
||||
(id, type, parent_id, path, title, status, created_by, counterclaim_of)
|
||||
VALUES ($1, 'case', $2, $1::text, 'Grandchild CCR', 'active', $3, $4)`,
|
||||
grandchild, patentID, userID, child.ID)
|
||||
if err == nil {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, grandchild)
|
||||
t.Fatal("expected schema trigger to reject grandchild CCR insert, got success")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "two-level counterclaim") {
|
||||
t.Errorf("trigger error message: %v (want two-level counterclaim)", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
271
internal/services/projection_levels_test.go
Normal file
271
internal/services/projection_levels_test.go
Normal file
@@ -0,0 +1,271 @@
|
||||
package services
|
||||
|
||||
// Live-DB integration test for parent-node lane aggregation
|
||||
// (t-paliad-175 SmartTimeline Slice 4 §5). Skipped without TEST_DATABASE_URL.
|
||||
//
|
||||
// Builds a 3-level fixture (Patent → Case-A + Case-B → CCR-A) and walks
|
||||
// the level policy at each viewpoint:
|
||||
//
|
||||
// - Case-A view: full detail + CCR sub-project track (single project,
|
||||
// own actuals + projection, "self" lane + "counterclaim:<id>" lane).
|
||||
// - Patent view: lanes per child case; events from each case subtree;
|
||||
// deadlines + milestones surface, statuses done/open/overdue.
|
||||
// - Bubble-up: a counterclaim_created milestone (default-on bubble_up)
|
||||
// surfaces at Patent level under Case-A's lane.
|
||||
// - Custom milestone with bubble_up=true surfaces too; without, it's
|
||||
// filtered out.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
func TestProjectionService_LevelAggregation_Live(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
userID := uuid.New()
|
||||
patentID := uuid.New()
|
||||
caseAID := uuid.New()
|
||||
caseBID := uuid.New()
|
||||
|
||||
cleanup := func() {
|
||||
// CCR children (counterclaim_of points at one of the cases)
|
||||
// must go first so the FK doesn't block the case delete.
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN (
|
||||
SELECT id FROM paliad.projects WHERE counterclaim_of IN ($1, $2))`, caseAID, caseBID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN (
|
||||
SELECT id FROM paliad.projects WHERE counterclaim_of IN ($1, $2))`, caseAID, caseBID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE counterclaim_of IN ($1, $2)`, caseAID, caseBID)
|
||||
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id IN ($1, $2, $3)`, patentID, caseAID, caseBID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, 'level-agg-test@hlc.com')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
||||
VALUES ($1, 'level-agg-test@hlc.com', 'Level Agg Test', 'munich', 'global_admin', 'de')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
// Patent hub.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, path, title, patent_number, status, created_by)
|
||||
VALUES ($1, 'patent', $1::text, 'EP9999999 — Test Patent', 'EP9999999', 'active', $2)`,
|
||||
patentID, userID); err != nil {
|
||||
t.Fatalf("seed patent: %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)`,
|
||||
patentID, userID); err != nil {
|
||||
t.Fatalf("seed patent team: %v", err)
|
||||
}
|
||||
// Case-A under the patent.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, parent_id, path, title, status, created_by)
|
||||
VALUES ($1, 'case', $2, $2::text || '.' || $1::text, 'Case A', 'active', $3)`,
|
||||
caseAID, patentID, userID); err != nil {
|
||||
t.Fatalf("seed case A: %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)`,
|
||||
caseAID, userID); err != nil {
|
||||
t.Fatalf("seed case A team: %v", err)
|
||||
}
|
||||
// Case-B under the patent.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, parent_id, path, title, status, created_by)
|
||||
VALUES ($1, 'case', $2, $2::text || '.' || $1::text, 'Case B', 'active', $3)`,
|
||||
caseBID, patentID, userID); err != nil {
|
||||
t.Fatalf("seed case B: %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)`,
|
||||
caseBID, userID); err != nil {
|
||||
t.Fatalf("seed case B team: %v", err)
|
||||
}
|
||||
|
||||
// Case-A: one open deadline + one done milestone (bubble_up=true via
|
||||
// counterclaim_created event_type) + one custom_milestone (bubble_up=false).
|
||||
now := time.Now().UTC()
|
||||
deadlineA := uuid.New()
|
||||
bubbledMilestoneA := uuid.New()
|
||||
regularMilestoneA := uuid.New()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadlines
|
||||
(id, project_id, title, due_date, source, status, created_by)
|
||||
VALUES ($1, $2, 'Case-A open deadline', $3::date, 'manual', 'pending', $4)`,
|
||||
deadlineA, caseAID, now.AddDate(0, 0, 14).Format("2006-01-02"), userID); err != nil {
|
||||
t.Fatalf("seed deadline A: %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, 'counterclaim_created', 'Widerklage angelegt', $3, $4,
|
||||
'{"bubble_up":true}'::jsonb, $5, $5, 'milestone')`,
|
||||
bubbledMilestoneA, caseAID, now.AddDate(0, 0, -7), userID, now); err != nil {
|
||||
t.Fatalf("seed bubbled milestone A: %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', 'Random Note (no bubble)', $3, $4,
|
||||
'{}'::jsonb, $5, $5, 'custom_milestone')`,
|
||||
regularMilestoneA, caseAID, now.AddDate(0, 0, -3), userID, now); err != nil {
|
||||
t.Fatalf("seed regular milestone A: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
eventTypes := NewEventTypeService(pool, users)
|
||||
deadlines := NewDeadlineService(pool, projects, eventTypes)
|
||||
appointments := NewAppointmentService(pool, projects)
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
holidays := NewHolidayService(pool)
|
||||
courts := NewCourtService(pool)
|
||||
fristen := NewFristenrechnerService(rules, holidays, courts)
|
||||
projection := NewProjectionService(pool, projects, deadlines, appointments, fristen, rules)
|
||||
|
||||
t.Run("Case-level: lanes mirror tracks (self + CCR)", func(t *testing.T) {
|
||||
_, meta, err := projection.For(ctx, userID, caseAID, ProjectionOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("For caseA: %v", err)
|
||||
}
|
||||
// At least the "self" lane is present.
|
||||
var sawSelf bool
|
||||
for _, l := range meta.Lanes {
|
||||
if l.ID == "self" {
|
||||
sawSelf = true
|
||||
if l.Label != "Case A" {
|
||||
t.Errorf("self lane label = %q, want Case A", l.Label)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawSelf {
|
||||
t.Errorf("Lanes = %v, want a 'self' entry", meta.Lanes)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Patent-level: lanes per child case + milestones bubble", func(t *testing.T) {
|
||||
rows, meta, err := projection.For(ctx, userID, patentID, ProjectionOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("For patent: %v", err)
|
||||
}
|
||||
|
||||
// Lanes: one per child case.
|
||||
laneIDs := map[string]LaneInfo{}
|
||||
for _, l := range meta.Lanes {
|
||||
laneIDs[l.ID] = l
|
||||
}
|
||||
if _, ok := laneIDs[caseAID.String()]; !ok {
|
||||
t.Errorf("Lanes missing Case-A entry: %v", meta.Lanes)
|
||||
}
|
||||
if _, ok := laneIDs[caseBID.String()]; !ok {
|
||||
t.Errorf("Lanes missing Case-B entry: %v", meta.Lanes)
|
||||
}
|
||||
|
||||
// Bubbled-up milestone (counterclaim_created) surfaces under
|
||||
// Case-A's lane.
|
||||
var sawBubbled, sawRegular, sawDeadline bool
|
||||
for _, r := range rows {
|
||||
if r.ProjectEventID != nil && *r.ProjectEventID == bubbledMilestoneA {
|
||||
sawBubbled = true
|
||||
if r.LaneID != caseAID.String() {
|
||||
t.Errorf("bubbled milestone LaneID = %q, want %s", r.LaneID, caseAID.String())
|
||||
}
|
||||
if !r.BubbleUp {
|
||||
t.Errorf("bubbled milestone BubbleUp should be true")
|
||||
}
|
||||
}
|
||||
if r.ProjectEventID != nil && *r.ProjectEventID == regularMilestoneA {
|
||||
sawRegular = true
|
||||
}
|
||||
if r.DeadlineID != nil && *r.DeadlineID == deadlineA {
|
||||
sawDeadline = true
|
||||
if r.LaneID != caseAID.String() {
|
||||
t.Errorf("deadline LaneID = %q, want %s", r.LaneID, caseAID.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawBubbled {
|
||||
t.Errorf("bubbled milestone (counterclaim_created) should surface at Patent level")
|
||||
}
|
||||
// Patent policy = milestones + deadlines, statuses done/open/overdue.
|
||||
// The pending deadline (status=open) survives; the regular custom
|
||||
// milestone (off_script status, no bubble_up) is filtered out.
|
||||
if !sawDeadline {
|
||||
t.Errorf("Case-A's open deadline should surface at Patent level (kinds=deadline allowed)")
|
||||
}
|
||||
if sawRegular {
|
||||
t.Errorf("regular custom_milestone (no bubble_up, off_script status) should be filtered at Patent level")
|
||||
}
|
||||
})
|
||||
|
||||
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.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.project_events
|
||||
SET metadata = '{"bubble_up":true}'::jsonb
|
||||
WHERE id = $1`, regularMilestoneA); err != nil {
|
||||
t.Fatalf("flip bubble_up: %v", err)
|
||||
}
|
||||
defer pool.ExecContext(ctx,
|
||||
`UPDATE paliad.project_events SET metadata = '{}'::jsonb WHERE id = $1`,
|
||||
regularMilestoneA)
|
||||
|
||||
rows, _, err := projection.For(ctx, userID, patentID, ProjectionOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("For patent (after flip): %v", err)
|
||||
}
|
||||
var saw bool
|
||||
for _, r := range rows {
|
||||
if r.ProjectEventID != nil && *r.ProjectEventID == regularMilestoneA {
|
||||
saw = true
|
||||
if !r.BubbleUp {
|
||||
t.Errorf("flipped milestone BubbleUp should be true")
|
||||
}
|
||||
}
|
||||
}
|
||||
if !saw {
|
||||
t.Errorf("custom_milestone with bubble_up=true should surface at Patent level")
|
||||
}
|
||||
})
|
||||
}
|
||||
1866
internal/services/projection_service.go
Normal file
1866
internal/services/projection_service.go
Normal file
File diff suppressed because it is too large
Load Diff
257
internal/services/projection_service_test.go
Normal file
257
internal/services/projection_service_test.go
Normal file
@@ -0,0 +1,257 @@
|
||||
package services
|
||||
|
||||
// Live-DB integration test for ProjectionService — applies migrations,
|
||||
// seeds one project + one deadline + one appointment + one
|
||||
// timeline_kind-tagged project_event, and asserts the merge returns
|
||||
// three rows in the right order. Skipped when TEST_DATABASE_URL is
|
||||
// unset, mirroring the convention of the other live tests in this
|
||||
// package.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
func TestProjectionService_For_MergesActuals_Live(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
ctx := context.Background()
|
||||
userID := uuid.New()
|
||||
projectID := uuid.New()
|
||||
deadlineID := uuid.New()
|
||||
apptID := uuid.New()
|
||||
milestoneID := uuid.New()
|
||||
auditOnlyID := uuid.New() // timeline_kind=NULL — must NOT surface in default read
|
||||
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.appointments WHERE id = $1`, apptID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.deadlines WHERE id = $1`, deadlineID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE id IN ($1, $2)`, milestoneID, auditOnlyID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE project_id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, projectID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, 'projection-test@hlc.com')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
||||
VALUES ($1, 'projection-test@hlc.com', 'Projection Test', 'munich', 'global_admin', 'de')`,
|
||||
userID); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects (id, type, path, title, reference, status, created_by)
|
||||
VALUES ($1, 'case', $1::text, 'Projection Test Project', '2026/9993', 'active', $2)`,
|
||||
projectID, userID); err != nil {
|
||||
t.Fatalf("seed paliad.projects: %v", err)
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
deadlineDate := now.AddDate(0, 0, 7) // a week from now
|
||||
apptDate := now.AddDate(0, 0, 14) // two weeks from now
|
||||
milestoneDate := now.AddDate(0, 0, -3) // three days ago
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadlines
|
||||
(id, project_id, title, due_date, source, status, created_by)
|
||||
VALUES ($1, $2, 'Test Deadline', $3::date, 'manual', 'pending', $4)`,
|
||||
deadlineID, projectID, deadlineDate.Format("2006-01-02"), userID); err != nil {
|
||||
t.Fatalf("seed deadline: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.appointments
|
||||
(id, project_id, title, start_at, appointment_type, created_by)
|
||||
VALUES ($1, $2, 'Test Appointment', $3, 'meeting', $4)`,
|
||||
apptID, projectID, apptDate, userID); err != nil {
|
||||
t.Fatalf("seed appointment: %v", err)
|
||||
}
|
||||
// Two project_events: one with timeline_kind set (must surface), one
|
||||
// without (must be filtered out unless include_audit_full).
|
||||
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', 'Test Milestone', $3, $4,
|
||||
'{}'::jsonb, $5, $5, 'custom_milestone')`,
|
||||
milestoneID, projectID, milestoneDate, userID, now); err != nil {
|
||||
t.Fatalf("seed milestone project_event: %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)
|
||||
VALUES ($1, $2, 'project_created', 'Audit-Only Event', $3, $4,
|
||||
'{}'::jsonb, $5, $5)`,
|
||||
auditOnlyID, projectID, milestoneDate.Add(-1*time.Hour), userID, now); err != nil {
|
||||
t.Fatalf("seed audit-only project_event: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
eventTypes := NewEventTypeService(pool, users)
|
||||
deadlines := NewDeadlineService(pool, projects, eventTypes)
|
||||
appointments := NewAppointmentService(pool, projects)
|
||||
rules := NewDeadlineRuleService(pool)
|
||||
holidays := NewHolidayService(pool)
|
||||
courts := NewCourtService(pool)
|
||||
fristen := NewFristenrechnerService(rules, holidays, courts)
|
||||
projection := NewProjectionService(pool, projects, deadlines, appointments, fristen, rules)
|
||||
|
||||
t.Run("default — only timeline_kind milestones surface", func(t *testing.T) {
|
||||
rows, _, err := projection.For(ctx, userID, projectID, ProjectionOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("For: %v", err)
|
||||
}
|
||||
// Filter to seed rows so unrelated rows in the live DB don't
|
||||
// confuse the assertions. We reference rows by provenance ID.
|
||||
seen := map[string]TimelineEvent{}
|
||||
for _, r := range rows {
|
||||
switch {
|
||||
case r.DeadlineID != nil && *r.DeadlineID == deadlineID:
|
||||
seen["deadline"] = r
|
||||
case r.AppointmentID != nil && *r.AppointmentID == apptID:
|
||||
seen["appointment"] = r
|
||||
case r.ProjectEventID != nil && *r.ProjectEventID == milestoneID:
|
||||
seen["milestone"] = r
|
||||
case r.ProjectEventID != nil && *r.ProjectEventID == auditOnlyID:
|
||||
t.Errorf("audit-only project_event leaked into default read")
|
||||
}
|
||||
}
|
||||
if len(seen) != 3 {
|
||||
t.Fatalf("expected 3 seed rows, saw %d: %v", len(seen), seen)
|
||||
}
|
||||
// Sort order: milestone (3 days ago) → deadline (+7d) → appointment (+14d).
|
||||
// Find the indices of our seeded rows in the result and check the
|
||||
// relative ordering.
|
||||
idx := func(id uuid.UUID) int {
|
||||
for i, r := range rows {
|
||||
switch {
|
||||
case r.DeadlineID != nil && *r.DeadlineID == id:
|
||||
return i
|
||||
case r.AppointmentID != nil && *r.AppointmentID == id:
|
||||
return i
|
||||
case r.ProjectEventID != nil && *r.ProjectEventID == id:
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
if !(idx(milestoneID) < idx(deadlineID) && idx(deadlineID) < idx(apptID)) {
|
||||
t.Errorf("wrong sort: milestone=%d deadline=%d appt=%d (want asc)",
|
||||
idx(milestoneID), idx(deadlineID), idx(apptID))
|
||||
}
|
||||
|
||||
// Field shape — kind, status, deep-link IDs.
|
||||
dl := seen["deadline"]
|
||||
if dl.Kind != "deadline" {
|
||||
t.Errorf("deadline.Kind = %q, want deadline", dl.Kind)
|
||||
}
|
||||
if dl.Status != "open" {
|
||||
t.Errorf("deadline.Status = %q, want open (future date)", dl.Status)
|
||||
}
|
||||
if dl.Title != "Test Deadline" {
|
||||
t.Errorf("deadline.Title = %q", dl.Title)
|
||||
}
|
||||
ap := seen["appointment"]
|
||||
if ap.Kind != "appointment" || ap.Status != "open" {
|
||||
t.Errorf("appointment kind/status = %q/%q", ap.Kind, ap.Status)
|
||||
}
|
||||
ms := seen["milestone"]
|
||||
if ms.Kind != "milestone" || ms.Status != "off_script" {
|
||||
t.Errorf("milestone kind/status = %q/%q (want milestone/off_script)",
|
||||
ms.Kind, ms.Status)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("IncludeAuditFull — both project_events surface", func(t *testing.T) {
|
||||
rows, _, err := projection.For(ctx, userID, projectID, ProjectionOpts{IncludeAuditFull: true})
|
||||
if err != nil {
|
||||
t.Fatalf("For audit_full: %v", err)
|
||||
}
|
||||
var sawAudit bool
|
||||
for _, r := range rows {
|
||||
if r.ProjectEventID != nil && *r.ProjectEventID == auditOnlyID {
|
||||
sawAudit = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !sawAudit {
|
||||
t.Errorf("audit-only project_event should surface with IncludeAuditFull=true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("RecordCustomMilestone writes a row with timeline_kind set", func(t *testing.T) {
|
||||
title := "Live-Test Custom Milestone"
|
||||
desc := "from RecordCustomMilestone test"
|
||||
when := time.Date(2026, 6, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
ev, err := projection.RecordCustomMilestone(ctx, userID, projectID, title, &desc, &when, false)
|
||||
if err != nil {
|
||||
t.Fatalf("RecordCustomMilestone: %v", err)
|
||||
}
|
||||
if ev == nil || ev.ProjectEventID == nil {
|
||||
t.Fatalf("RecordCustomMilestone returned nil id")
|
||||
}
|
||||
// Defer cleanup so the row doesn't leak into other tests.
|
||||
defer pool.ExecContext(ctx, `DELETE FROM paliad.project_events WHERE id = $1`, *ev.ProjectEventID)
|
||||
|
||||
// Verify the row landed with the expected discriminators.
|
||||
var (
|
||||
eventType string
|
||||
timelineKind *string
|
||||
)
|
||||
if err := pool.QueryRowContext(ctx,
|
||||
`SELECT event_type, timeline_kind FROM paliad.project_events WHERE id = $1`,
|
||||
*ev.ProjectEventID).Scan(&eventType, &timelineKind); err != nil {
|
||||
t.Fatalf("read back: %v", err)
|
||||
}
|
||||
if eventType != "custom_milestone" {
|
||||
t.Errorf("event_type = %q, want custom_milestone", eventType)
|
||||
}
|
||||
if timelineKind == nil || *timelineKind != "custom_milestone" {
|
||||
t.Errorf("timeline_kind = %v, want custom_milestone", timelineKind)
|
||||
}
|
||||
|
||||
// And it must surface in the next read.
|
||||
rows, _, err := projection.For(ctx, userID, projectID, ProjectionOpts{})
|
||||
if err != nil {
|
||||
t.Fatalf("For after milestone: %v", err)
|
||||
}
|
||||
var found bool
|
||||
for _, r := range rows {
|
||||
if r.ProjectEventID != nil && *r.ProjectEventID == *ev.ProjectEventID {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("newly recorded milestone did not surface in For()")
|
||||
}
|
||||
})
|
||||
}
|
||||
355
internal/services/projection_service_unit_test.go
Normal file
355
internal/services/projection_service_unit_test.go
Normal file
@@ -0,0 +1,355 @@
|
||||
package services
|
||||
|
||||
// Pure-function tests for ProjectionService — no DB required, runs by
|
||||
// default. Validates the deterministic sort order and status-mapping
|
||||
// behaviour; the live integration test in projection_service_test.go
|
||||
// covers the SQL paths.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
func TestSortTimeline_DateAscUndatedLast(t *testing.T) {
|
||||
d1 := uuid.New()
|
||||
d2 := uuid.New()
|
||||
a1 := uuid.New()
|
||||
pe1 := uuid.New()
|
||||
|
||||
mar1 := time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC)
|
||||
mar5 := time.Date(2026, 3, 5, 12, 0, 0, 0, time.UTC)
|
||||
mar10 := time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
rows := []TimelineEvent{
|
||||
{Kind: "milestone", Title: "Undated milestone", ProjectEventID: &pe1}, // Date nil
|
||||
{Kind: "deadline", Date: &mar10, Title: "Mar10 deadline", DeadlineID: &d2},
|
||||
{Kind: "deadline", Date: &mar1, Title: "Mar1 deadline", DeadlineID: &d1},
|
||||
{Kind: "appointment", Date: &mar5, Title: "Mar5 appointment", AppointmentID: &a1},
|
||||
}
|
||||
|
||||
sortTimeline(rows)
|
||||
|
||||
// Date ASC (Mar1, Mar5, Mar10), undated last.
|
||||
if rows[0].Title != "Mar1 deadline" {
|
||||
t.Errorf("rows[0] = %q, want Mar1 deadline", rows[0].Title)
|
||||
}
|
||||
if rows[1].Title != "Mar5 appointment" {
|
||||
t.Errorf("rows[1] = %q, want Mar5 appointment", rows[1].Title)
|
||||
}
|
||||
if rows[2].Title != "Mar10 deadline" {
|
||||
t.Errorf("rows[2] = %q, want Mar10 deadline", rows[2].Title)
|
||||
}
|
||||
if rows[3].Title != "Undated milestone" {
|
||||
t.Errorf("rows[3] = %q, want Undated milestone", rows[3].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSortTimeline_SameDateTiebreak(t *testing.T) {
|
||||
mar5 := time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC)
|
||||
d1 := uuid.New()
|
||||
a1 := uuid.New()
|
||||
pe1 := uuid.New()
|
||||
|
||||
rows := []TimelineEvent{
|
||||
{Kind: "milestone", Date: &mar5, Title: "C", ProjectEventID: &pe1},
|
||||
{Kind: "appointment", Date: &mar5, Title: "B", AppointmentID: &a1},
|
||||
{Kind: "deadline", Date: &mar5, Title: "A", DeadlineID: &d1},
|
||||
}
|
||||
|
||||
sortTimeline(rows)
|
||||
|
||||
// Tiebreak: deadline > appointment > milestone (kindOrder).
|
||||
if rows[0].Kind != "deadline" {
|
||||
t.Errorf("rows[0].Kind = %q, want deadline", rows[0].Kind)
|
||||
}
|
||||
if rows[1].Kind != "appointment" {
|
||||
t.Errorf("rows[1].Kind = %q, want appointment", rows[1].Kind)
|
||||
}
|
||||
if rows[2].Kind != "milestone" {
|
||||
t.Errorf("rows[2].Kind = %q, want milestone", rows[2].Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeadlineStatus(t *testing.T) {
|
||||
today := time.Now().UTC()
|
||||
yesterday := today.AddDate(0, 0, -1)
|
||||
tomorrow := today.AddDate(0, 0, 1)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
status string
|
||||
due time.Time
|
||||
want string
|
||||
}{
|
||||
{"completed regardless of date", "completed", yesterday, "done"},
|
||||
{"completed even if future", "completed", tomorrow, "done"},
|
||||
{"pending past = overdue", "pending", yesterday, "overdue"},
|
||||
{"pending today = open", "pending", today, "open"},
|
||||
{"pending future = open", "pending", tomorrow, "open"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := deadlineStatus(c.status, c.due)
|
||||
if got != c.want {
|
||||
t.Errorf("deadlineStatus(%q, %v) = %q, want %q",
|
||||
c.status, c.due, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppointmentStatus(t *testing.T) {
|
||||
now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC)
|
||||
past := now.Add(-1 * time.Hour)
|
||||
future := now.Add(1 * time.Hour)
|
||||
|
||||
if got := appointmentStatus(past, now); got != "done" {
|
||||
t.Errorf("past appointment status = %q, want done", got)
|
||||
}
|
||||
if got := appointmentStatus(future, now); got != "open" {
|
||||
t.Errorf("future appointment status = %q, want open", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMilestoneStatus(t *testing.T) {
|
||||
custom := "custom_milestone"
|
||||
other := "counterclaim_filed"
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
timelineKind *string
|
||||
eventType *string
|
||||
want string
|
||||
}{
|
||||
{"custom_milestone via timeline_kind", &custom, nil, "off_script"},
|
||||
{"custom_milestone via event_type fallback", nil, &custom, "off_script"},
|
||||
{"structural milestone = done", nil, &other, "done"},
|
||||
{"both nil = done", nil, nil, "done"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := milestoneStatus(c.timelineKind, c.eventType)
|
||||
if got != c.want {
|
||||
t.Errorf("milestoneStatus = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestKindOrder(t *testing.T) {
|
||||
// Lock the exact ordering — frontend assumes deadline before
|
||||
// appointment before milestone before projected on same-date ties.
|
||||
if kindOrder("deadline") >= kindOrder("appointment") {
|
||||
t.Error("deadline should sort before appointment")
|
||||
}
|
||||
if kindOrder("appointment") >= kindOrder("milestone") {
|
||||
t.Error("appointment should sort before milestone")
|
||||
}
|
||||
if kindOrder("milestone") >= kindOrder("projected") {
|
||||
t.Error("milestone should sort before projected")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLevelPolicy pins the (kinds, statuses, lane_axis) triple per
|
||||
// project type per design §5.1 (t-paliad-175 SmartTimeline Slice 4).
|
||||
// These are user-visible policy decisions — locked here to catch
|
||||
// accidental shifts during refactors.
|
||||
func TestLevelPolicy(t *testing.T) {
|
||||
cases := []struct {
|
||||
projectType string
|
||||
kinds []string
|
||||
statuses []string
|
||||
laneAxis string
|
||||
}{
|
||||
{"case", nil, nil, "self_plus_ccr"},
|
||||
{"", nil, nil, "self_plus_ccr"}, // unknown falls back to case behaviour
|
||||
{"unknown", nil, nil, "self_plus_ccr"},
|
||||
{
|
||||
"patent",
|
||||
[]string{"deadline", "milestone"},
|
||||
[]string{"done", "open", "overdue"},
|
||||
"child_case",
|
||||
},
|
||||
{
|
||||
"litigation",
|
||||
[]string{"milestone"},
|
||||
[]string{"done"},
|
||||
"child_patent",
|
||||
},
|
||||
{
|
||||
"client",
|
||||
[]string{"milestone"},
|
||||
[]string{"done"},
|
||||
"child_litigation",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.projectType, func(t *testing.T) {
|
||||
got := levelPolicy(c.projectType)
|
||||
if got.LaneAxis != c.laneAxis {
|
||||
t.Errorf("LaneAxis = %q, want %q", got.LaneAxis, c.laneAxis)
|
||||
}
|
||||
if !sliceEqual(got.Kinds, c.kinds) {
|
||||
t.Errorf("Kinds = %v, want %v", got.Kinds, c.kinds)
|
||||
}
|
||||
if !sliceEqual(got.Statuses, c.statuses) {
|
||||
t.Errorf("Statuses = %v, want %v", got.Statuses, c.statuses)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func sliceEqual(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// TestRowSurvivesPolicy_BubbleUpOverridesFilter pins the contract that
|
||||
// a project_event milestone with bubble_up=true survives the level
|
||||
// policy's kind/status filter at higher levels (design §5.3 + Q5).
|
||||
func TestRowSurvivesPolicy_BubbleUpOverridesFilter(t *testing.T) {
|
||||
allowKind := stringSet([]string{"deadline"}) // milestones excluded
|
||||
allowStatus := stringSet([]string{"done"}) // off_script excluded
|
||||
bubbledMilestone := TimelineEvent{
|
||||
Kind: "milestone",
|
||||
Status: "off_script",
|
||||
BubbleUp: true,
|
||||
}
|
||||
if !rowSurvivesPolicy(bubbledMilestone, allowKind, allowStatus) {
|
||||
t.Error("bubble_up=true row should survive both kind and status filters")
|
||||
}
|
||||
|
||||
regularMilestone := TimelineEvent{
|
||||
Kind: "milestone",
|
||||
Status: "off_script",
|
||||
}
|
||||
if rowSurvivesPolicy(regularMilestone, allowKind, allowStatus) {
|
||||
t.Error("regular milestone should be filtered when kind/status both excluded")
|
||||
}
|
||||
|
||||
// kind allowed, status excluded → drop.
|
||||
allowedKindBadStatus := TimelineEvent{
|
||||
Kind: "deadline",
|
||||
Status: "open",
|
||||
}
|
||||
if rowSurvivesPolicy(allowedKindBadStatus, allowKind, allowStatus) {
|
||||
t.Error("excluded status should drop a row even when kind allowed")
|
||||
}
|
||||
|
||||
// kind excluded, status allowed → drop.
|
||||
badKindGoodStatus := TimelineEvent{
|
||||
Kind: "appointment",
|
||||
Status: "done",
|
||||
}
|
||||
if rowSurvivesPolicy(badKindGoodStatus, allowKind, allowStatus) {
|
||||
t.Error("excluded kind should drop a row even when status allowed")
|
||||
}
|
||||
|
||||
// Empty filters = pass-through.
|
||||
if !rowSurvivesPolicy(badKindGoodStatus, nil, nil) {
|
||||
t.Error("empty filters should pass everything")
|
||||
}
|
||||
}
|
||||
|
||||
// TestExtractBubbleUp pins the per-event-type defaults (Q5):
|
||||
// - counterclaim_created / third_party_intervention / scope_change
|
||||
// default to true.
|
||||
// - custom_milestone defaults to false.
|
||||
// - Explicit metadata.bubble_up always wins.
|
||||
func TestExtractBubbleUp(t *testing.T) {
|
||||
str := func(s string) *string { return &s }
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
raw string
|
||||
eventType *string
|
||||
timelineKind *string
|
||||
want bool
|
||||
}{
|
||||
{"counterclaim_created defaults true", "{}", str("counterclaim_created"), str("milestone"), true},
|
||||
{"third_party_intervention defaults true", "", str("third_party_intervention"), nil, true},
|
||||
{"scope_change defaults true", "", str("scope_change"), nil, true},
|
||||
{"custom_milestone defaults false", "{}", str("custom_milestone"), str("custom_milestone"), false},
|
||||
{"unknown defaults false", "{}", str("note_created"), nil, false},
|
||||
{"explicit true overrides", `{"bubble_up":true}`, str("custom_milestone"), nil, true},
|
||||
{"explicit false overrides", `{"bubble_up":false}`, str("counterclaim_created"), nil, false},
|
||||
{"string \"true\" parses", `{"bubble_up":"true"}`, str("custom_milestone"), nil, true},
|
||||
{"string \"1\" parses", `{"bubble_up":"1"}`, str("custom_milestone"), nil, true},
|
||||
{"non-bool ignored", `{"bubble_up":42}`, str("custom_milestone"), nil, false},
|
||||
{"malformed metadata falls back to default", `{`, str("counterclaim_created"), nil, true},
|
||||
{"empty metadata + nil event_type = false", "", nil, nil, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := extractBubbleUp(json.RawMessage(c.raw), c.eventType, c.timelineKind)
|
||||
if got != c.want {
|
||||
t.Errorf("extractBubbleUp = %v, want %v", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestChildTypeForAxis pins the axis → project type map.
|
||||
func TestChildTypeForAxis(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"child_case": "case",
|
||||
"child_patent": "patent",
|
||||
"child_litigation": "litigation",
|
||||
"self_plus_ccr": "",
|
||||
"": "",
|
||||
"bogus": "",
|
||||
}
|
||||
for axis, want := range cases {
|
||||
if got := childTypeForAxis(axis); got != want {
|
||||
t.Errorf("childTypeForAxis(%q) = %q, want %q", axis, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDerivedCounterclaimOurSide pins the our_side flip semantics
|
||||
// (t-paliad-174 §11 Q2):
|
||||
// - Default (override nil): claimant ↔ defendant; court / both pass through.
|
||||
// - Override true: same default-flip semantics.
|
||||
// - Override false (R.49.2.b CCI edge case): keep parent's side.
|
||||
// - NULL parent_side yields empty string (no flip without a starting side).
|
||||
func TestDerivedCounterclaimOurSide(t *testing.T) {
|
||||
tru := true
|
||||
fal := false
|
||||
str := func(s string) *string { return &s }
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
parent *string
|
||||
override *bool
|
||||
want string
|
||||
}{
|
||||
{"nil parent → empty", nil, nil, ""},
|
||||
{"nil parent + override → empty", nil, &tru, ""},
|
||||
{"claimant → defendant (default)", str("claimant"), nil, "defendant"},
|
||||
{"defendant → claimant (default)", str("defendant"), nil, "claimant"},
|
||||
{"court passes through", str("court"), nil, "court"},
|
||||
{"both passes through", str("both"), nil, "both"},
|
||||
{"explicit flip=true", str("claimant"), &tru, "defendant"},
|
||||
{"explicit flip=false keeps parent's side", str("claimant"), &fal, "claimant"},
|
||||
{"flip=false on defendant keeps defendant", str("defendant"), &fal, "defendant"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := derivedCounterclaimOurSide(c.parent, c.override)
|
||||
if got != c.want {
|
||||
t.Errorf("derivedCounterclaimOurSide(%v, %v) = %q, want %q",
|
||||
c.parent, c.override, got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -169,6 +169,10 @@ func computeViewSpecBounds(now time.Time, ts TimeSpec) viewSpecBounds {
|
||||
from := day
|
||||
to := day.AddDate(0, 0, 90)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
case HorizonPast7d:
|
||||
from := day.AddDate(0, 0, -7)
|
||||
to := day.AddDate(0, 0, 1)
|
||||
return viewSpecBounds{from: &from, to: &to}
|
||||
case HorizonPast30d:
|
||||
from := day.AddDate(0, 0, -30)
|
||||
to := day.AddDate(0, 0, 1)
|
||||
|
||||
Reference in New Issue
Block a user