Compare commits
42 Commits
mai/dirac/
...
mai/schroe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e57507a92 | ||
|
|
7da8802f9b | ||
|
|
91d3811276 | ||
|
|
483649d9d2 | ||
|
|
82888dea78 | ||
|
|
306bb11618 | ||
|
|
196f3f74a6 | ||
|
|
331efc8603 | ||
|
|
85d7dd497c | ||
|
|
335be29b23 | ||
|
|
0835be4a7f | ||
|
|
3e1bbd3c77 | ||
|
|
7057fe5d25 | ||
|
|
4a5d56d9e6 | ||
|
|
afd3aab2b2 | ||
|
|
49c260b888 | ||
|
|
12b35fc9fe | ||
|
|
ebcda13f88 | ||
|
|
487fec2672 | ||
|
|
f8cc86cd02 | ||
|
|
69544bf3fb | ||
|
|
7fef64159b | ||
|
|
7238b12b05 | ||
|
|
54cf7ac2f6 | ||
|
|
f4815a9f9a | ||
|
|
ce180123c3 | ||
|
|
7a35cad09f | ||
|
|
6058d21ce6 | ||
|
|
52caba51ec | ||
|
|
1faffb682e | ||
|
|
4b681792ab | ||
|
|
236bb3270e | ||
|
|
4670cd660a | ||
|
|
1e97eccaed | ||
|
|
3a41acee07 | ||
|
|
de4e133f03 | ||
|
|
0c12644563 | ||
|
|
5d9c62d858 | ||
|
|
188d8ec9ba | ||
|
|
d5a01e6682 | ||
|
|
02d4ac2f4e | ||
|
|
1e23745792 |
@@ -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):
|
||||
|
||||
739
docs/design-smart-timeline-2026-05-08.md
Normal file
739
docs/design-smart-timeline-2026-05-08.md
Normal file
@@ -0,0 +1,739 @@
|
||||
# Design — SmartTimeline (Verlauf-tab redesign)
|
||||
|
||||
**Author:** lagrange (inventor)
|
||||
**Date:** 2026-05-08
|
||||
**Task:** t-paliad-169
|
||||
**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.
|
||||
|
||||
- **Verlauf today** is `frontend/src/projects-detail.tsx:74-101` — `<ul className="entity-events" id="project-events-list">` rendered from `paliad.project_events` via `loadEvents(id)` at `client/projects-detail.ts:305`. Pure audit log: `event_type` distribution in prod is 100 % administrative — `deadline_completed/updated/created/...`, `note_created`, `appointment_*`, `checklist_*`, `project_type_changed`, `our_side_changed`, `deadlines_imported`. No "future-tense" or "off-script" events surface anywhere on the project page today.
|
||||
- **Projection logic** lives in `internal/services/fristenrechner.go:Calculate(ctx, proceedingCode, triggerDateStr, opts CalcOptions)` returning a `UIResponse{Deadlines []UIDeadline}` keyed by `rule_code`. `CalcOptions.AnchorOverrides map[string]string` lets callers replace any rule's date and downstream rules re-anchor — already the load-bearing primitive for "actual dates anchor downstream projections" (t-paliad-131 Phase A).
|
||||
- **`paliad.deadline_rules`** carries 172 active rules across 19 fristenrechner proceeding types (UPC×8, DE×5, EPA×2, EP×1, DPMA×3). `condition_flag text[]` already drives counterclaim cross-flows: `with_ccr` enables 7 UPC_INF cross-flow rules (Defence-to-CCR R.29.a, Application to amend R.30.1, Defence to App-to-amend R.32.1, Reply to Defence-to-CCR R.29.d, Rejoinder R.29.e, +2). `with_amend` / `with_cci` work on UPC_REV.
|
||||
- **`paliad.projects.our_side`** column exists (added in t-paliad-164) but is **null on every live row today**. The CCR perspective-flip the cascade implements via Determinator B1 (t-paliad-167) is not yet exercised by real data.
|
||||
- **CCR is not a separate project today.** It's a flag (`with_ccr=true`) on a parent UPC_INF project. m's vision asks us to revisit that.
|
||||
- **FilterBar** (`frontend/src/client/filter-bar/`, riemann's t-paliad-163 Phase 1) ships with axis stubs `deadline_event_type` + `project_event_kind` already wired into `BarState` and `AxisKey` — Phase 2 is supposed to fill them in. The SmartTimeline's facet set is exactly the kind of thing those stubs were left pending for.
|
||||
- **Project hierarchy in prod** is the canonical 4-level shape: Client (`Siemens AG`) → Litigation (`Siemens ./. Huawei`) → Patent (`EP3456789`) → Case (`UPC-CFI München — Klage Siemens ./. Huawei`). 11 projects total.
|
||||
- **t-paliad-168 deliverable 3 is dropped** per task brief — there will be no separate Verfahrensablauf-as-its-own-tab on the project page. The wizard's projection logic is the SmartTimeline's future-skeleton feeder.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vision + scope
|
||||
|
||||
m's vision (verbatim 2026-05-08 23:02):
|
||||
|
||||
> The Verlauf tab inside the case should hold past + future events. If we know the proceeding type, there is a timeline. We adapt the Verfahrensablauf logic and fix dates for things when they happened. A smart timeline. If a counterclaim is filed, that is also included. Hold it flexible — add events regardless of whether they fit the normal course.
|
||||
|
||||
The **SmartTimeline** is one composed view that answers *"what has happened in this matter, what is happening now, and what is on the standard road from here"*. Three time-zones in one widget:
|
||||
|
||||
| Zone | What it shows | Data source |
|
||||
|---|---|---|
|
||||
| **Past** | Filings, decisions, appointments, audit milestones — all dated, anchored to reality | `paliad.deadlines` (status=`done`) ∪ `paliad.appointments` (start_at < today) ∪ `paliad.project_events` (selected `timeline_kind`) |
|
||||
| **Now** | Open deadlines + appointments today | same tables, today-bracket |
|
||||
| **Future (predicted)** | Standard-course rules from `deadline_rules` projected forward, faded — only those without an actual `paliad.deadlines` row yet | `fristenrechner.Calculate` against project's proceeding type + trigger anchor |
|
||||
| **Future (off-script)** | User-added events that don't fit the standard tree (counterclaim filed, ad-hoc Anhörung, party amendment) | `paliad.deadlines` with `source='off_script'` ∪ child counterclaim sub-project's actuals ∪ `project_events` with `timeline_kind` |
|
||||
|
||||
### What changes
|
||||
|
||||
- The `tab=history` panel on `/projects/{id}` becomes a SmartTimeline component that renders all four zones in one column.
|
||||
- The audit-only Verlauf view does not disappear — it survives as a "Audit-Log" sub-toggle inside the SmartTimeline ("Alle Audit-Events anzeigen") and on the existing `/admin/audit-log` page (t-paliad-071).
|
||||
- The existing FilterBar primitive grows two facets (`timeline_track`, `timeline_status`) and re-uses three (`time`, `personal_only`, `deadline_event_type`).
|
||||
|
||||
### What stays
|
||||
|
||||
- Step 2 third-card + sidebar entry from t-paliad-168 are unaffected — the standalone Verfahrensablauf wizard at `/tools/fristenrechner` remains a knowledge-platform tool.
|
||||
- `paliad.project_events` keeps its full audit-log role for `/admin/audit-log`.
|
||||
- `paliad.deadlines` + `paliad.appointments` schemas don't migrate (only one optional column added; details in §2).
|
||||
- The existing "Inkl. Unterprojekte" toggle on the project page stays — the SmartTimeline reads child events through it.
|
||||
|
||||
### Out of scope (v1)
|
||||
|
||||
- Horizontal-Gantt rendering. We pick a vertical timeline; Gantt is a future shape (t-paliad-144 substrate already supports `shape` switching, so adding a Gantt shape is later, not now).
|
||||
- Outlook/Exchange sync. CalDAV stays the only sync path.
|
||||
- Cross-matter timelines (e.g. "everything happening on EP3456789 across Siemens ./. Huawei AND any related opposition"). The patent-level aggregation in §5 is a step in that direction but cross-matter view is a separate task.
|
||||
- Rendering documents (Schriftsätze) on the timeline. That's the t-paliad-17 Incoming-Submission workflow, separate.
|
||||
|
||||
---
|
||||
|
||||
## 2. Data model
|
||||
|
||||
**Recommendation: virtual view, ONE optional column.** No new top-level table for v1. The four zones above are computed at read time from the existing tables. The single schema change is a nullable `timeline_kind text` column on `paliad.project_events` so a subset of audit rows can opt into surfacing as timeline content.
|
||||
|
||||
### 2.1 Why no new `timeline_events` table
|
||||
|
||||
A first-instinct design would materialise a new `paliad.timeline_events` table with columns `(project_id, kind, date, title, status, source_track, rule_code?, actual_deadline_id?, …)`. I recommend against it for v1:
|
||||
|
||||
1. **Three of the four zones already have authoritative tables.** `paliad.deadlines` is the source-of-truth for legal deadlines (with completion + approval state); `paliad.appointments` for hearings + court dates; `paliad.project_events` for audit. Forcing a copy into `timeline_events` creates a sync problem on every mutation.
|
||||
2. **The future-projected zone is a function of proceeding-type + trigger date + actual anchors** — not stored data. Materialising it would require invalidation on every `paliad.deadlines` change. Cheaper to recompute per request: 19 proceeding types × at most ~15 rules = ~285 ms with cold pg cache, well under the page-render budget. Re-uses the cached `FristenrechnerService` (already memoised per request via service instantiation).
|
||||
3. **t-paliad-144 set the precedent** that ViewService composes per request without materialising. The SmartTimeline is a project-scoped instance of the same pattern.
|
||||
|
||||
If load testing later shows the projection cost matters, we materialise into a `paliad.projected_timeline_cache` table indexed by (project_id, rule_code) — but design that when load shows it, not now.
|
||||
|
||||
### 2.2 The one column added
|
||||
|
||||
```sql
|
||||
-- migration NNN_project_events_timeline_kind.up.sql
|
||||
ALTER TABLE paliad.project_events
|
||||
ADD COLUMN timeline_kind text NULL;
|
||||
|
||||
-- nullable + no CHECK — enum lives in code (services/projection_service.go).
|
||||
-- Value space (v1):
|
||||
-- 'milestone' — a 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
|
||||
-- NULL — audit only (default, all existing rows)
|
||||
|
||||
CREATE INDEX project_events_timeline_kind_idx
|
||||
ON paliad.project_events (project_id, timeline_kind)
|
||||
WHERE timeline_kind IS NOT NULL;
|
||||
```
|
||||
|
||||
Existing event types stay `NULL` — they remain audit-only and don't clutter the timeline. New write paths (counterclaim-link, off-script milestone) set the column on insert.
|
||||
|
||||
### 2.3 The discriminated `TimelineEvent` shape
|
||||
|
||||
Composed in `internal/services/projection_service.go` (new). One Go struct, one TS mirror. Frontend renders without knowing where each row came from:
|
||||
|
||||
```go
|
||||
type TimelineEvent struct {
|
||||
Kind string // "deadline" | "appointment" | "milestone" | "projected"
|
||||
Status string // "done" | "open" | "overdue" | "court_set" | "predicted" | "off_script"
|
||||
Track string // "parent" | "counterclaim" | "child:<project_id>" | "off_script"
|
||||
Date *time.Time // nil = undated (court-set + counterclaim-pending)
|
||||
|
||||
Title string
|
||||
Description string
|
||||
RuleCode string // empty when not deadline-rule-derived
|
||||
|
||||
// Provenance — exactly one is non-nil for actual rows; both nil for projected.
|
||||
DeadlineID *uuid.UUID
|
||||
AppointmentID *uuid.UUID
|
||||
ProjectEventID *uuid.UUID
|
||||
|
||||
// For projected rows (Kind=="projected") — the rule it came from, for
|
||||
// the click-to-anchor affordance (§6).
|
||||
DeadlineRuleID *uuid.UUID
|
||||
DeadlineRuleParty string // 'claimant' | 'defendant' | 'court' | 'both'
|
||||
|
||||
// For child-track rows — the sub-project this event belongs to.
|
||||
SubProjectID *uuid.UUID
|
||||
SubProjectTitle string
|
||||
}
|
||||
```
|
||||
|
||||
### 2.4 Read path
|
||||
|
||||
```
|
||||
GET /api/projects/{id}/timeline?
|
||||
from=...&to=...&direct_only=true|false&
|
||||
tracks=parent,counterclaim,...&kinds=deadline,appointment,projected,...
|
||||
```
|
||||
|
||||
The handler:
|
||||
1. Calls `ProjectionService.For(ctx, projectID, opts)` which:
|
||||
- Loads the project (proceeding_type_id, our_side, parent chain).
|
||||
- Loads child counterclaim sub-projects (if any — see §4).
|
||||
- Loads `paliad.deadlines` (project_id IN [self, child counterclaims]) → emits Kind=deadline rows.
|
||||
- Loads `paliad.appointments` (same) → emits Kind=appointment rows.
|
||||
- Loads `paliad.project_events WHERE timeline_kind IS NOT NULL` → emits Kind=milestone rows.
|
||||
- For each (project, child) with a proceeding_type_id, calls `FristenrechnerService.Calculate` with `AnchorOverrides` derived from completed actuals → emits Kind=projected rows for any rule that does **not** have a matching `paliad.deadlines.rule_id` row.
|
||||
- Sorts by Date ASC, undated rows last (with secondary sort on rule sequence_order so undated court-set rows preserve the standard course's order).
|
||||
|
||||
Visibility is inherited via existing `visibilityPredicate` on each underlying service — no new RLS surface to design.
|
||||
|
||||
### 2.5 What does NOT need to change
|
||||
|
||||
- `paliad.deadlines` schema — unchanged. (The existing `original_due_date`, `source`, and the AnchorOverrides plumbing already cover "actual date anchors downstream", §6.)
|
||||
- `paliad.appointments` — unchanged.
|
||||
- `paliad.deadline_rules` — unchanged. The existing `condition_flag text[]` keeps doing its job.
|
||||
- `paliad.projects` — unchanged. (See §4 for the counterclaim sub-project shape: it uses existing columns.)
|
||||
|
||||
---
|
||||
|
||||
## 3. UI mockup — three states
|
||||
|
||||
The SmartTimeline replaces the current `<ul className="entity-events">` block (~30 lines of TSX) with a vertically-flowing two-column timeline:
|
||||
|
||||
- Left column: date (or "Datum offen" placeholder).
|
||||
- Right column: stacked card per event with a status icon, title, kind chip, and (for actuals) a deep-link to `/deadlines/{id}` etc. Same `.entity-event` row contract as today (cf. CLAUDE.md whole-card click rule), no `::before` overlay.
|
||||
|
||||
A horizontal "**Heute →**" rule separates past from future. Past goes below (most-recent first), future above (chronological). Today's events sit on the rule.
|
||||
|
||||
### 3.1 State A — empty / no proceeding type set
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ SmartTimeline [Filter ▼] [+ Eintrag] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Noch keine Ereignisse erfasst. │
|
||||
│ │
|
||||
│ Setze einen Verfahrenstyp im Projekt-Header, um den │
|
||||
│ Standardverlauf als Vorhersage zu sehen, oder lege │
|
||||
│ einen Eintrag manuell an. │
|
||||
│ │
|
||||
│ [+ Frist anlegen] [+ Termin anlegen] [+ Meilenstein] │
|
||||
│ │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The empty state actively guides toward the two unlocks: setting a proceeding type (enables future-projection) or adding manual events (works without one).
|
||||
|
||||
### 3.2 State B — UPC_INF, infringement-only
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ SmartTimeline Verfahrenstyp: UPC-Verletzung [Filter ▼] │
|
||||
├──────────────────────────────────────────────────────────────┤
|
||||
│ Zukunft (vorhergesagt) │
|
||||
│ ───────────────────────────── │
|
||||
│ 2027-02-20 ░ Hauptverhandlung │
|
||||
│ ░ wird vom Gericht bestimmt [Datum setzen] │
|
||||
│ ─ │
|
||||
│ 2026-12-02 ░ Duplik (RoP.029.c) [voraussichtlich]│
|
||||
│ 2026-11-02 ░ Replik (RoP.029.b) [voraussichtlich]│
|
||||
│ 2026-08-31 ░ Klageerwiderung (RoP.023) [voraussichtlich]│
|
||||
│ │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━ Heute (2026-05-08) ━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ │
|
||||
│ Vergangenheit │
|
||||
│ ───────────────────────────── │
|
||||
│ 2026-04-29 ✓ Klageschrift zugestellt (Anker) │
|
||||
│ 2026-04-25 ✓ Akte angelegt (Audit) │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- `░` (faded) = projected, `✓` = done, `!` = overdue (red), `…` = open (amber), `▢` = court-set (dashed border).
|
||||
- "Datum setzen" on the Hauptverhandlung row is the click-to-anchor affordance (§6).
|
||||
- "voraussichtlich" pill is the projected-status visual; tooltip explains "Anhand des Standardverlaufs aus dem Fristenrechner berechnet".
|
||||
- Filter chip selector reveals the FilterBar primitive directly above the list (collapsed by default to reduce noise on first load — same affordance riemann shipped on /inbox).
|
||||
|
||||
### 3.3 State C — UPC_INF + Counterclaim (CCR-Subprojekt)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ SmartTimeline Verfahrenstyp: UPC-Verletzung [Track ▼ Beide] [Filter ▼] │
|
||||
├──────────────────────────────────────────────────────────────────────────────┤
|
||||
│ Verletzung (Klägerseite) ┊ Widerklage (Beklagtenseite, CCR) │
|
||||
│ ──────────────────────────────────────┊──────────────────────────────────────│
|
||||
│ Zukunft (vorhergesagt) │
|
||||
│ 2027-02-20 ░ Hauptverhandlung ┊ │
|
||||
│ [Datum setzen] ┊ │
|
||||
│ 2027-01-29 ░ Rejoinder R.29.e ┊ 2026-12-29 ░ Rejoinder R.32.3 │
|
||||
│ 2026-12-29 ░ Reply to Defence-CCR ┊ │
|
||||
│ 2026-11-29 ░ Defence to App-amend ┊ 2026-11-29 ░ Reply to Defence-amend│
|
||||
│ 2026-10-31 ░ Defence to CCR (R.29a)┊ 2026-09-30 ░ Defence to amend │
|
||||
│ 2026-08-31 ░ Klageerwiderung mit CCR┊ │
|
||||
│ ┊ │
|
||||
│ ━━━━━━━━━━━━━━━━━━━━ Heute (2026-05-08) ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ │
|
||||
│ ┊ │
|
||||
│ Vergangenheit ┊ │
|
||||
│ 2026-04-29 ✓ Klageschrift zugestellt┊ ⊕ Widerklage angekündigt │
|
||||
│ ┊ (off-script, 2026-05-02) │
|
||||
│ 2026-04-25 ✓ Akte angelegt ┊ │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- Two parallel tracks — left is the parent infringement, right is the linked counterclaim sub-project (see §4).
|
||||
- `[Track ▼]` chip toggles between "Beide" (default when a CCR sub-project exists), "Nur Verletzung", "Nur Widerklage".
|
||||
- "⊕" marks an off-script milestone (the counterclaim was *announced* before being formally filed — a `project_events` row with `timeline_kind='custom_milestone'`).
|
||||
- Mobile: stacks vertically with collapsible per-track headers.
|
||||
|
||||
---
|
||||
|
||||
## 4. Counterclaim shape — sub-project, defended
|
||||
|
||||
m's framing offered two shapes. Inventor recommendation: **sub-project**. Trade-off explicit.
|
||||
|
||||
### 4.1 The choice
|
||||
|
||||
| | **Sub-project (recommended)** | **Same-project, parallel proceeding-overlay** |
|
||||
|---|---|---|
|
||||
| Project rows | One per proceeding (parent INF + child CCR) | One project, two proceeding-types attached |
|
||||
| `our_side` flip | Independent on the child (parent: claimant; child: defendant in CCR-on-validity, claimant on CCR-of-infringement) | Needs a "perspective per proceeding" sub-table |
|
||||
| Determinator routing (t-paliad-167) | Existing — child gets its own cascade | Needs proceeding-aware routing inside one project |
|
||||
| Project tree (t-paliad-149) | Naturally appears as a nested node | Same-row, no tree change |
|
||||
| Dashboard per-project counts | Each gets its own count | Mixing — needs new "by-proceeding" aggregator |
|
||||
| Visibility / RLS | Inherits `can_see_project` cascade | Same |
|
||||
| CCR Number from CMS | Stored on child's `case_number` | Stored on parent in a new `case_numbers jsonb` |
|
||||
| New schema | None (uses existing project + parent_id) | New `project_proceedings` join table |
|
||||
|
||||
### 4.2 Why sub-project
|
||||
|
||||
- **Cheap.** Zero schema migration. The hierarchy already supports arbitrary nesting (4 types: client / litigation / patent / case — but `parent_id` is type-agnostic).
|
||||
- **Consistent with the data we just built.** t-paliad-164 our_side, t-paliad-149 project tree, t-paliad-167 Determinator cascade, t-paliad-168 deadline-rule jurisdiction defaults all assume "one project = one proceeding perspective". Counterclaim being a sub-project just means we keep that assumption.
|
||||
- **CCR Number.** The counterclaim has its own CCR number in the UPC CMS — which means it is in fact a separate proceeding artifact, not just a phase of the parent. Modeling it as a separate project row with its own `case_number` reflects reality. The "case-complex-wise" closeness m asks about is the parent_id link, not collapsing them into one row.
|
||||
- **Independent timeline math.** UPC R.49(2) puts CCI / app-to-amend "as part of" Defence to revocation — but that just means zero-duration filed-with-parent. The downstream re-anchoring is independent in each tree.
|
||||
|
||||
### 4.3 The link
|
||||
|
||||
A new optional FK on `paliad.projects`:
|
||||
|
||||
```sql
|
||||
-- migration NNN_projects_counterclaim_of.up.sql
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN counterclaim_of uuid NULL
|
||||
REFERENCES paliad.projects(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX projects_counterclaim_of_idx
|
||||
ON paliad.projects (counterclaim_of)
|
||||
WHERE counterclaim_of IS NOT NULL;
|
||||
|
||||
-- A project can be EITHER a parent (counterclaim_of IS NULL) OR a
|
||||
-- counterclaim against another project (counterclaim_of points at it),
|
||||
-- but not both. Enforced by a CHECK on the union of FKs (see §10).
|
||||
```
|
||||
|
||||
`parent_id` keeps the standard hierarchy (the counterclaim child still lives under the same patent / litigation tree). `counterclaim_of` is an *additional* relation expressing "this project is the CCR against project X". The two are both set on a counterclaim sub-project.
|
||||
|
||||
### 4.4 Creating a counterclaim from the timeline
|
||||
|
||||
The "+ Eintrag" button on the parent's SmartTimeline opens a typed-add modal (§7). Picking type=`Counterclaim` (UPC) creates a child project with:
|
||||
|
||||
- `parent_id` = parent's parent (so CCR appears as a sibling under the patent, not a grandchild — debatable; see §11 Q4).
|
||||
- `counterclaim_of` = parent project id.
|
||||
- `proceeding_type_id` = `UPC_REV` (CCR-on-validity is the standard case; UPC_CCI is the rarer R.49.2.b path).
|
||||
- `our_side` = inverted from parent (parent claimant → child defendant, parent defendant → child claimant).
|
||||
- `title` = `<patent> — Widerklage (CCR)` auto-suggested.
|
||||
|
||||
The same flow applies to `case_amend` (UPC R.30 application to amend) — a separate child sub-project. *Whether to model R.30 as a child project or as a flag on the parent is open: amendments are usually just a flag in our existing model. Default v1 = stay as flag, do **not** create a sub-project for application-to-amend; only formal counterclaims (CCR / CCI) get sub-projects.*
|
||||
|
||||
### 4.5 What the parent's SmartTimeline shows for the child
|
||||
|
||||
When `counterclaim_of` exists pointing at this project, the SmartTimeline renders a parallel right-track with the child's events (limited to `kind IN ('deadline','appointment','milestone')` — child's projected rows are also included). User can collapse/hide the child track via the `[Track ▼]` chip.
|
||||
|
||||
The child's own SmartTimeline shows its own events as the primary track plus the parent as a left-side faded-context track (so the lawyer working on the CCR can see what's happening on the main proceeding without leaving the page).
|
||||
|
||||
---
|
||||
|
||||
## 5. Parent-node aggregation rule
|
||||
|
||||
What does the SmartTimeline render at higher levels of the project hierarchy? The four levels we have today:
|
||||
|
||||
### 5.1 Per-level rendering
|
||||
|
||||
| Level | Default render | Why |
|
||||
|---|---|---|
|
||||
| **Case** (UPC-CFI X) | Full SmartTimeline of self + parallel-track for any linked CCR sub-project. All zones, all kinds. | The lawyer working a single proceeding sees everything in one view. |
|
||||
| **Patent** (EP3456789) | Lanes — one per child case. Each lane shows only `kind IN ('deadline','milestone')` + status `IN ('done','open','overdue')`. Projected rows hidden by default (unfold-per-lane on click). | A patent typically has 1-3 active cases (CFI + CoA + opposition). Showing all projected rows from every case = overwhelming. Showing actuals + structural milestones gives the matter-level view. |
|
||||
| **Litigation** (Siemens ./. Huawei) | Lanes — one per child patent's primary case (most-recently-active case). Show only `kind='milestone'` + status=`done` + per-case "next due" pill. | Litigation level is portfolio-of-patents-against-this-defendant. Useful to see when each patent's current proceeding is, not the granular deadlines. |
|
||||
| **Client** (Siemens AG) | Default = matter list (existing project tree). Behind a "Timeline-Ansicht" toggle, lanes = one per litigation. Shows only `kind='milestone'` + status=`done`. | Client level can have 100+ matters. A timeline across all is meaningless. The toggle makes it discoverable for the partner who wants the bird's-eye view. |
|
||||
|
||||
### 5.2 The single rule
|
||||
|
||||
> Each level removes one tier of detail and adds one tier of grouping. Going up: fewer kinds rendered, fewer statuses surfaced, more lanes.
|
||||
|
||||
| Level | Kinds | Statuses | Lanes |
|
||||
|---|---|---|---|
|
||||
| Case | all | all | self + CCR child |
|
||||
| Patent | deadline + milestone | done + open + overdue | one per child case |
|
||||
| Litigation | milestone | done | one per child patent |
|
||||
| Client | milestone (toggle) | done | one per child litigation |
|
||||
|
||||
This rule is implementable as a single `levelPolicy(projectType)` function in `ProjectionService` returning a `(kinds, statuses, lane_grouping)` triple. All four cases share the same render component; only the input filter varies.
|
||||
|
||||
### 5.3 Off-script events at higher levels
|
||||
|
||||
Off-script milestones (counterclaim filed, party amendment, scope change) are first-class at every level — they're the events m most cares about seeing at the litigation/patent overview. The "milestone" kind survives the level filter at all levels.
|
||||
|
||||
### 5.4 Not in v1
|
||||
|
||||
Cross-matter aggregation (e.g. "all my UPC matters, one timeline") is a Custom-View concern (t-paliad-144 substrate). The SmartTimeline is project-scoped; cross-project goes through `/views/{slug}` with a sources=`timeline` ViewSpec. Phase 5+, after t-paliad-163 Phase B lands.
|
||||
|
||||
---
|
||||
|
||||
## 6. Date-anchoring + reflow semantics
|
||||
|
||||
### 6.1 The rule (explicit)
|
||||
|
||||
> An actual date — recorded as a `paliad.deadlines.due_date` (status `done`) or `paliad.appointments.start_at` (in the past) or a milestone date — anchors every downstream projected event whose parent rule is the corresponding deadline_rule. The reflow propagates one parent-step at a time, until the next actual takes over or the chain bottoms out.
|
||||
|
||||
In other words: the existing `AnchorOverrides` mechanism in `FristenrechnerService.Calculate` is exactly the load-bearing primitive. The SmartTimeline's `ProjectionService` builds the override map at request time:
|
||||
|
||||
```go
|
||||
overrides := map[string]string{}
|
||||
for _, d := range completedDeadlines {
|
||||
if d.RuleCode == "" || d.CompletedAt == nil { continue }
|
||||
overrides[d.RuleCode] = d.CompletedAt.Format("2006-01-02")
|
||||
}
|
||||
// Court-set rules pick up the actual date too — set when the user enters
|
||||
// "Hauptverhandlung fand statt am ..." via the inline anchor affordance.
|
||||
opts := CalcOptions{AnchorOverrides: overrides, Flags: flagsForProject(p)}
|
||||
result := frist.Calculate(ctx, p.ProceedingCode, p.TriggerDate, opts)
|
||||
```
|
||||
|
||||
### 6.2 The UI affordance
|
||||
|
||||
Each projected row carries a `[Datum setzen]` link (or full-row click on tap-targets). Click → inline date input expands inline. On submit:
|
||||
|
||||
- If the row corresponds to a `deadline_rules` entry that has a *real* deadline (not court-set), the action creates a `paliad.deadlines` row with `rule_id` set, `due_date=entered`, `original_due_date=projected`, `source='anchor'`, `status='done'`, `completed_at=entered`. (The "anchor" source is new; existing values are `manual`, `rule`, `import`. v1 adds `'anchor'` to the existing CHECK list.) This is the "we just learned the parent fact" path.
|
||||
- If the row is court-set (decision / hearing / order), the action creates a `paliad.appointments` row with `start_at=entered`, `appointment_type='hearing'|'decision'|'order'` derived from the rule's `event_type`. The appointment links back to `rule_code` via a new optional FK column `paliad.appointments.deadline_rule_id` (nullable; existing rows stay null).
|
||||
- Either way, the next read recomputes the projection with the new override and downstream rows reflow.
|
||||
|
||||
### 6.3 Editing an actual date later
|
||||
|
||||
If the user clicks an existing actual row's date, the inline editor PATCHes the underlying record (`/api/deadlines/{id}` or `/api/appointments/{id}`), and the next read re-projects.
|
||||
|
||||
### 6.4 What happens to overdue projected rows
|
||||
|
||||
A projected row whose date is in the past but no actual exists yet renders as "vorhergesagt — überfällig" (faded amber). Clicking it lets the user either (a) anchor it as actual on a different date, or (b) explicitly mark "ist nicht eingetreten / wurde verschoben" — which writes a `project_events` row with `event_type='rule_skipped'` + `timeline_kind='milestone'` so the audit trail records the decision.
|
||||
|
||||
---
|
||||
|
||||
## 7. Off-script event UX
|
||||
|
||||
The cardinal constraint: "We must hold it flexible — add events regardless of whether they fit the normal course." Off-script events are first-class.
|
||||
|
||||
### 7.1 The "+ Eintrag" CTA
|
||||
|
||||
Persistent button in the SmartTimeline header. Click → typed-add modal:
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ Neuer Eintrag im SmartTimeline │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ Was ist passiert? (oder wird passieren?) │
|
||||
│ │
|
||||
│ ◯ Frist → /deadlines/new │
|
||||
│ ◯ Termin → /appointments/new │
|
||||
│ ◯ Widerklage (CCR) → Anlegen Sub-Akte │
|
||||
│ ◯ Anwendung auf Änderung (R.30) → Flag setzen │
|
||||
│ ◯ Schriftsatz / Order → Off-script │
|
||||
│ ◯ Eigener Meilenstein → Off-script (frei) │
|
||||
│ │
|
||||
│ [ Abbrechen ] [ Weiter ▶ ] │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
The visible options depend on the project's `proceeding_type_id`. UPC_INF gets the CCR + R.30 routes; UPC_REV gets CCI; DE_INF gets none of these. The "Schriftsatz / Order" + "Eigener Meilenstein" routes are universal.
|
||||
|
||||
### 7.2 The off-script branch
|
||||
|
||||
For "Schriftsatz / Order" and "Eigener Meilenstein" — a small form:
|
||||
|
||||
```
|
||||
Off-script Meilenstein
|
||||
|
||||
Titel: [Widerklage angekündigt durch Beklagten ]
|
||||
Datum: [2026-05-02]
|
||||
Beschreibung: [Schreiben des Beklagtenanwalts vom 02.05., … ]
|
||||
Verknüpfung: ☐ Frist daraus erzeugen ☐ Termin daraus erzeugen
|
||||
Sichtbar in: ◉ Diese Akte ◯ Diese Akte + Eltern
|
||||
↑ Will it bubble up to higher levels?
|
||||
|
||||
[ Abbrechen ] [ Speichern ]
|
||||
```
|
||||
|
||||
On submit, writes a `paliad.project_events` row with:
|
||||
|
||||
- `event_type='off_script_milestone'` (new value in the event_type enum-ish CHECK; today's CHECK is open-ended text — confirm during impl).
|
||||
- `timeline_kind='custom_milestone'`.
|
||||
- `event_date=entered`.
|
||||
- `description=...`.
|
||||
- `metadata={"track": "parent" | "off_script", "links": [...]}`.
|
||||
|
||||
The optional checkboxes "Frist daraus erzeugen / Termin daraus erzeugen" open the standard deadline/appointment-create flow with the milestone's data prefilled and the milestone's id linked via metadata for audit trail.
|
||||
|
||||
### 7.3 Curated catalogue per proceeding type (NICE TO HAVE)
|
||||
|
||||
A small lookup table `paliad.timeline_event_catalogue (proceeding_type_id, kind, slug, name_de, name_en, primary_party)` could surface in the modal as a "Häufige Ereignisse" section above the universal "Eigener Meilenstein" route. Examples:
|
||||
|
||||
- UPC_INF: Counterclaim Filed, Third Party Intervention, Hearing Postponement, Cost Decision Issued
|
||||
- UPC_REV: Application to Amend Filed, Substantive Decision, Costs Order
|
||||
- DE_INF: Hinweisbeschluss Issued, Verteidigungsanzeige, Termin Hauptverhandlung, Versäumnisurteil
|
||||
|
||||
The catalogue is a v2 nice-to-have. v1 ships with "Eigener Meilenstein" as the universal escape hatch and the few proceeding-specific routes named above (CCR, CCI, R.30) hardcoded on the modal.
|
||||
|
||||
---
|
||||
|
||||
## 8. Filter facets — first-pass refinement
|
||||
|
||||
Refining the task brief's first-pass list against the FilterBar API (riemann's `BarState` / `AxisKey`). Each axis maps to either a universal axis (already shipped), an existing per-source stub (riemann left ready), or a new one.
|
||||
|
||||
### 8.1 Reused universal axes (already in BarState)
|
||||
|
||||
- **`time`** (universal, chip cluster + custom range) — past 30/90d, next 30/90/any/custom. Default = `any`. Re-used verbatim; no work.
|
||||
- **`personal_only`** (universal, chip) — re-used. "Nur meine Einträge" — `created_by=me`. Behavior same as on `/events` (t-paliad-128).
|
||||
|
||||
### 8.2 New per-source axes (extend `AxisKey`)
|
||||
|
||||
```ts
|
||||
// frontend/src/client/filter-bar/types.ts — additions
|
||||
export type AxisKey =
|
||||
| …existing…
|
||||
| "timeline_kind" // multi-select chip cluster
|
||||
| "timeline_status" // multi-select chip cluster
|
||||
| "timeline_track" // multi-select chip cluster
|
||||
;
|
||||
|
||||
export interface BarState {
|
||||
…existing…
|
||||
timeline_kind?: ("deadline" | "appointment" | "milestone" | "projected")[];
|
||||
timeline_status?: ("done" | "open" | "overdue" | "court_set" | "predicted" | "off_script")[];
|
||||
timeline_track?: ("parent" | "counterclaim" | string /* child:<projectid> */)[];
|
||||
}
|
||||
```
|
||||
|
||||
### 8.3 The facet set on the SmartTimeline surface
|
||||
|
||||
The surface declares this `axes` array when it mounts the bar:
|
||||
|
||||
```ts
|
||||
mountFilterBar(host, {
|
||||
axes: [
|
||||
"time", // universal — past/future filter
|
||||
"timeline_kind", // deadline | appointment | milestone | projected
|
||||
"timeline_status", // done | open | overdue | court_set | predicted | off_script
|
||||
"timeline_track", // parent | counterclaim | child:<id>
|
||||
"personal_only", // optional — toggle "nur meine Einträge"
|
||||
"deadline_event_type", // existing stub — wired in t-paliad-117 multi-select
|
||||
"shape", // timeline (default) | list | cards
|
||||
"sort", // chronological asc/desc
|
||||
"density", // comfortable | compact
|
||||
],
|
||||
surfaceKey: "project-smart-timeline",
|
||||
systemViewSlug: "project-timeline",
|
||||
…
|
||||
});
|
||||
```
|
||||
|
||||
### 8.4 Defaults
|
||||
|
||||
- `time = any`
|
||||
- `timeline_kind = [deadline, appointment, milestone]` (projected hidden by default — the user opts in via chip; reduces noise on first load when most projects don't have a proceeding type set)
|
||||
- `timeline_status = [done, open, overdue, off_script]` (predicted + court_set hidden by default if `projected` kind is hidden — the chip group is one logical "show future" toggle)
|
||||
- `timeline_track = all available`
|
||||
- `shape = timeline`
|
||||
- `sort = date_desc` (most recent first; matches today's Verlauf default)
|
||||
- `density = comfortable`
|
||||
|
||||
### 8.5 The "show future" macro
|
||||
|
||||
Most users will only want one toggle: "Zukunft anzeigen". We render that as a primary chip pair next to `time`:
|
||||
|
||||
```
|
||||
[ Vergangenheit | Heute | Zukunft ] ← primary toggle
|
||||
```
|
||||
|
||||
Internally this maps to `time + timeline_kind` (Vergangenheit hides projected, Zukunft shows projected, Heute is just today). Power users can drill into the granular axes via the bar.
|
||||
|
||||
### 8.6 What riemann's port (t-paliad-170) needs to know
|
||||
|
||||
Riemann is porting FilterBar onto the Verlauf surface in parallel. Three things they need:
|
||||
|
||||
1. **Three new axis keys** (`timeline_kind`, `timeline_status`, `timeline_track`). They render as chip clusters — the same primitive `chipRow + chipBtn` riemann already factored.
|
||||
2. **`shape: "timeline"`** is a new render shape. Existing shapes are `list | cards | calendar` (t-paliad-144). We pick "timeline" as a 4th shape so the FilterBar's shape switcher lets the user collapse to `list` (compact audit log) or `cards` (chronological card grid) without losing the data. Implementation = new `frontend/src/client/views/shape-timeline.ts` mirroring the other shape files. Out of scope for t-paliad-170 (riemann ports the bar, not the new shape).
|
||||
3. **The `timeline_track` axis options are dynamic** — they depend on whether the project has a counterclaim child. The bar already supports lazy axes (the `project` axis pattern in `axes.ts:30` — `"populated lazily"`). `timeline_track` follows the same shape: surface fetches available tracks at mount, passes them to the bar.
|
||||
|
||||
---
|
||||
|
||||
## 9. Verfahrensablauf-logic sharing — extract, don't import
|
||||
|
||||
**Recommendation: extract into a shared module first.**
|
||||
|
||||
### 9.1 The decision
|
||||
|
||||
The wizard's projection logic is currently in two places:
|
||||
|
||||
1. `internal/services/fristenrechner.go:Calculate(...)` — the canonical Go implementation. Already returns a `UIResponse{Deadlines []UIDeadline}` keyed by rule_code, supports `AnchorOverrides`. ~1000 lines, tested.
|
||||
2. `frontend/src/client/fristenrechner.ts:calculate()` — the frontend wrapper that POSTs `/api/tools/fristenrechner` and handles flags + overrides. ~3500 lines including the wizard UI, but the projection-relevant slice is small (call + render).
|
||||
|
||||
The SmartTimeline's `ProjectionService.For(projectID)` needs the *Go calculator*, not the frontend code path. So the question is really: *do we add a new Go service that wraps `FristenrechnerService.Calculate` for projects?*
|
||||
|
||||
Yes — a thin adapter, not a parallel implementation.
|
||||
|
||||
### 9.2 The adapter
|
||||
|
||||
```go
|
||||
// internal/services/projection_service.go (new, ~200 LoC)
|
||||
|
||||
type ProjectionService struct {
|
||||
db *sqlx.DB
|
||||
fristen *FristenrechnerService
|
||||
deadlines *DeadlineService
|
||||
appointments *AppointmentService
|
||||
projects *ProjectService
|
||||
courts *CourtService
|
||||
}
|
||||
|
||||
// For builds a SmartTimeline for one project (and its CCR child if any).
|
||||
// Composes the four zones described in §1; returns sorted TimelineEvent[].
|
||||
func (s *ProjectionService) For(ctx context.Context, projectID uuid.UUID, opts ProjectionOpts) ([]TimelineEvent, error) {
|
||||
p, err := s.projects.GetVisible(ctx, projectID, opts.ViewerID)
|
||||
// ...
|
||||
children := s.projects.LoadCounterclaimChildrenVisible(ctx, projectID, opts.ViewerID)
|
||||
|
||||
actuals := s.collectActuals(ctx, []uuid.UUID{p.ID, children...}) // dl + appt + milestones
|
||||
overrides := buildAnchorOverrides(actuals)
|
||||
|
||||
var projected []TimelineEvent
|
||||
if p.ProceedingTypeCode != "" && p.TriggerDate != nil {
|
||||
proj := s.fristen.Calculate(ctx, p.ProceedingTypeCode, p.TriggerDate.Format("2006-01-02"),
|
||||
CalcOptions{AnchorOverrides: overrides, Flags: flagsFor(p), CourtID: p.CourtID})
|
||||
projected = projectionToTimeline(proj, p, actuals)
|
||||
}
|
||||
// (same for each child counterclaim)
|
||||
|
||||
return mergeAndSort(actuals, projected, opts.LevelPolicy), nil
|
||||
}
|
||||
```
|
||||
|
||||
The adapter does not duplicate the calculator — it calls `FristenrechnerService.Calculate` exactly once per (project, child). Same code path as `/api/tools/fristenrechner` uses today; same tests cover both.
|
||||
|
||||
### 9.3 What the standalone wizard keeps
|
||||
|
||||
`/tools/fristenrechner` continues to use `FristenrechnerService.Calculate` directly — it's a knowledge-platform tool, not a project-scoped view. It does not gain anchoring affordances or off-script events. The projection there is hypothetical ("if you start a UPC_INF on date X, here's the timeline"), not project-actual.
|
||||
|
||||
`ProjectionService` is a project-scoped composition layer; it lives one level above `FristenrechnerService` in the dependency graph.
|
||||
|
||||
### 9.4 The test split
|
||||
|
||||
- `fristenrechner_test.go` keeps testing the calculator (duration math, AnchorOverrides, CourtID resolution).
|
||||
- `projection_service_test.go` (new) tests the composition: mixing actuals + projected, level policy, counterclaim child merging, sort order.
|
||||
|
||||
---
|
||||
|
||||
## 10. Phasing — 4 sequential slices
|
||||
|
||||
Each slice is independently shippable and reviewable. m's go/no-go gate after each.
|
||||
|
||||
### Slice 1 — SmartTimeline skeleton (no projection yet)
|
||||
|
||||
What lands:
|
||||
|
||||
- New `internal/services/projection_service.go` with `For()` returning only actuals (deadlines + appointments + opted-in `project_events`). No `fristenrechner` call yet.
|
||||
- Migration `NNN_project_events_timeline_kind.up.sql` adds the optional column + partial index (§2.2).
|
||||
- New endpoint `GET /api/projects/{id}/timeline?…` returning `[]TimelineEvent`.
|
||||
- `frontend/src/client/projects-detail.ts:loadEvents` rewritten to call `/timeline` instead of `/events`. The current Verlauf list is replaced by the new vertical timeline component (`client/views/shape-timeline.ts` — new file, ~300 LoC).
|
||||
- "+ Eintrag" CTA in the timeline header (modal partially implemented — only "Eigener Meilenstein" route lit; CCR / R.30 / Frist / Termin routes are link buttons to existing flows).
|
||||
- "Audit-Log anzeigen" toggle that switches to the legacy chronological list rendering (`paliad.project_events` ALL — not just `timeline_kind IS NOT NULL`).
|
||||
|
||||
What it gives m: a working SmartTimeline showing past actuals + open/upcoming deadlines + appointments + off-script milestones, with the audit log surviving as a toggle. No future-projection yet.
|
||||
|
||||
### Slice 2 — Future-projection + click-to-anchor
|
||||
|
||||
What lands:
|
||||
|
||||
- `ProjectionService.For` calls `FristenrechnerService.Calculate` and emits projected rows.
|
||||
- Click-to-anchor inline date editor (§6.2). New endpoint `POST /api/projects/{id}/timeline/anchor` taking `{rule_code, actual_date, kind?}` and writing the appropriate `paliad.deadlines` (`source='anchor'`) or `paliad.appointments` (`deadline_rule_id` FK new) row.
|
||||
- Migration `NNN_appointments_deadline_rule_id.up.sql` adds the optional FK on appointments + extends `paliad.deadlines.source` CHECK to include `'anchor'`.
|
||||
- "voraussichtlich" / "Datum vom Gericht" status pills + projected-row CSS (faded + dashed border for court-set).
|
||||
- New "Zukunft anzeigen" macro chip pair (§8.5).
|
||||
- `event_type='rule_skipped'` write path for the "ist nicht eingetreten" decision (§6.4).
|
||||
|
||||
What it gives m: predicted future course based on standard timeline; click to fix any date when something happens; downstream reflows automatically.
|
||||
|
||||
### Slice 3 — Counterclaim sub-project
|
||||
|
||||
What lands:
|
||||
|
||||
- Migration `NNN_projects_counterclaim_of.up.sql` — the new `counterclaim_of` FK + index + the CHECK (a project either has counterclaim_of OR is parent — not both — to keep the invariant clean).
|
||||
- "+ Eintrag → Widerklage (CCR)" route in the modal (§7.1) — creates child project with auto-suggested `our_side` flip, `proceeding_type_id`, and title, then navigates to it for the user to fill in `case_number`.
|
||||
- `ProjectionService` loads CCR children + emits parallel-track rows.
|
||||
- `[Track ▼]` chip in the header — reads `available_tracks` from the timeline response.
|
||||
- The two-column rendering on State C (§3.3).
|
||||
- `paliad.project_events` audit row written on counterclaim creation (`event_type='counterclaim_created'`, `timeline_kind='milestone'`).
|
||||
|
||||
What it gives m: counterclaims as proper sub-projects, parallel timelines, CCR perspective-flip works end-to-end.
|
||||
|
||||
### Slice 4 — Parent-node aggregation
|
||||
|
||||
What lands:
|
||||
|
||||
- `levelPolicy(projectType)` in `ProjectionService` — kinds/statuses/lane filter per level (§5.1).
|
||||
- Lane-grouped rendering at Patent / Litigation / Client levels.
|
||||
- "Timeline-Ansicht" toggle on Client-level project page (default off; lanes-of-litigations when on).
|
||||
- Off-script milestones bubble up to higher levels via the `metadata.bubble_up: true` flag (§7.2 form's "Sichtbar in: Diese Akte + Eltern" checkbox).
|
||||
|
||||
What it gives m: portfolio-level timelines without overload — the bird's-eye view he asked about.
|
||||
|
||||
### What's NOT in any slice
|
||||
|
||||
- Curated per-proceeding event catalogue (§7.3) — v2 nice-to-have.
|
||||
- Gantt rendering — separate `shape: "gantt"` follow-up.
|
||||
- Cross-matter timeline — Custom Views path.
|
||||
- Outlook integration — out of scope.
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions for m
|
||||
|
||||
Listed with my (inventor) pick where I have one — m decides.
|
||||
|
||||
**Q1 — Counterclaim sub-project vs proceeding-overlay (§4).** I recommend sub-project. Confirm before Slice 3 design lock.
|
||||
|
||||
**Q2 — Should `our_side` flip automatically on counterclaim sub-project creation?** My pick: yes, default-flip with a "Stimmt nicht?" toggle on the create modal. The R.49.2.b CCI is the edge case (parent claimant → child claimant in CCI of the *separate* infringement claim), but the standard CCR-on-validity always inverts. Default-flip + toggle handles both.
|
||||
|
||||
**Q3 — Should `paliad.deadlines.source` gain `'anchor'` or should we re-use `'manual'`?** My pick: new `'anchor'` value — separates "user-typed-it-in" from "user-recorded-an-actual-after-projection-fired" for analytics + future automated import (Outlook event → anchor).
|
||||
|
||||
**Q4 — Counterclaim sub-project's `parent_id` — under the patent (sibling to parent case) or under the parent case (grandchild)?** My pick: under the patent (sibling). The CCR is its own proceeding with its own case_number; modeling it as a sibling to the parent infringement, both under the patent, mirrors how UPC CMS sees them. Grandchild placement would imply CCR is "part of" the parent case which it structurally isn't.
|
||||
|
||||
**Q5 — Off-script milestone bubble-up default.** My pick: default-on for `event_type IN ('counterclaim_created', 'third_party_intervention', 'scope_change')`; default-off for `event_type='custom_milestone'`. Form has the override checkbox in either case.
|
||||
|
||||
**Q6 — Should `/tools/fristenrechner` keep its standalone existence?** Brief says yes — knowledge tool, separate from project context. My pick: yes, agree. It stays.
|
||||
|
||||
**Q7 — Application-to-amend (UPC R.30) as sub-project or flag?** My pick: stay as flag (`with_amend`). Amendments are not a separate proceeding artifact in the CMS — they ride on the parent's record. The cross-flow rules already activate via `condition_flag`.
|
||||
|
||||
**Q8 — On the parent's SmartTimeline, do CCR rows mix into one column or stay in a parallel right-track?** My pick: parallel right-track when both are populated; collapses into one column on mobile (vertical stacking with sub-headers per track). The `[Track ▼]` chip lets desktop users opt into single-column mode.
|
||||
|
||||
**Q9 — Court-set anchor (Hauptverhandlung) creates a `paliad.appointments` row or a `paliad.deadlines` row?** My pick: `paliad.appointments` — it's an appointment, not a deadline. The new `appointments.deadline_rule_id` FK preserves the link back to the rule for downstream re-anchoring.
|
||||
|
||||
**Q10 — Is `timeline_kind` the right column name?** Alternatives: `is_timeline_milestone bool`, `surface_on_timeline bool`. My pick: keep `timeline_kind text NULL` because it lets us distinguish `milestone` (structural) from `custom_milestone` (free-form) without a second column.
|
||||
|
||||
**Q11 — Should the SmartTimeline be the only view of the project's events?** Or do we keep a "klassisch (chronologisch)" sidebar tab? My pick: SmartTimeline as the only Verlauf tab; "Audit-Log anzeigen" toggle inside the timeline reveals the chronological rendering. m uses `/admin/audit-log` (t-paliad-071) for the cross-project audit query.
|
||||
|
||||
**Q12 — Patent-level "matter list vs lane timeline" default.** My pick: lanes by default at Patent + Litigation; matter list by default at Client. The Litigation level has 1-3 child patents typically → 1-3 lanes is fine. Client can have 100+ → lanes are a toggle.
|
||||
|
||||
---
|
||||
|
||||
## 12. Files implementer will touch (Slice 1 only)
|
||||
|
||||
Aggregated for the coder shift kickoff:
|
||||
|
||||
**Backend (Go):**
|
||||
- `internal/services/projection_service.go` — new, ~250 LoC.
|
||||
- `internal/handlers/projection.go` — new, GET /api/projects/{id}/timeline, ~80 LoC.
|
||||
- `internal/handlers/handlers.go` — register the new route.
|
||||
- `internal/db/migrations/NNN_project_events_timeline_kind.{up,down}.sql` — new.
|
||||
|
||||
**Frontend (TS / TSX):**
|
||||
- `frontend/src/client/views/shape-timeline.ts` — new render shape, ~300 LoC.
|
||||
- `frontend/src/client/projects-detail.ts:loadEvents` — replace with timeline fetch.
|
||||
- `frontend/src/projects-detail.tsx:74-101` — replace Verlauf markup with `<div id="project-smart-timeline">`.
|
||||
- `frontend/src/styles/global.css` — `.smart-timeline-*` styles, ~150 LoC.
|
||||
- `frontend/src/client/i18n.ts` — ~30 keys under `projects.detail.smarttimeline.*`.
|
||||
|
||||
**Tests:**
|
||||
- `internal/services/projection_service_test.go` — new (live-DB integration test, skipped without `TEST_DATABASE_URL`).
|
||||
- `internal/services/projection_service_unit_test.go` — pure-function tests (sort, level policy, override-build).
|
||||
|
||||
Slices 2-4 are scoped in §10; coder picks them up after m's gate.
|
||||
|
||||
---
|
||||
|
||||
## 13. Trade-offs flagged
|
||||
|
||||
- **Per-request projection cost.** Recomputing on every Verlauf load is fine for a single project. If m navigates to a Client-level lane view with 50 child litigations × 3 cases each, that's 150 calculator invocations. Mitigation: lane-rendering at Litigation+Client levels excludes `kind='projected'` by default (§5), so the calculator is only called on the leaf rendering. Watch in production; add per-(project, hash(overrides)) cache if needed.
|
||||
- **Migration order across active workers.** riemann is on t-paliad-170 (FilterBar Verlauf port) in parallel. Slice 1 must merge **after** their port because Slice 1 mounts the bar with new axis keys. Coordinate via head before Slice 1 PR opens.
|
||||
- **Sub-project counterclaim adds a tier.** The project tree gets deeper (Patent → Case + Patent → CCR-Sub-Case as siblings). Existing tree visualisation in t-paliad-149 handles arbitrary depth, but the per-card "in 3 children" badge needs to count the CCR child correctly — verify in Slice 3.
|
||||
- **`appointments.deadline_rule_id`** is a backward-pointing FK that doesn't exist yet. Adding it in Slice 2 is clean (nullable, no backfill needed). Just flagging that this ties appointments to deadline_rules where they previously had no link.
|
||||
- **Anchor write path can race.** Two users clicking "Datum setzen" on the same row simultaneously could both write `paliad.deadlines` rows. Mitigation: server-side check `WHERE NOT EXISTS (SELECT 1 FROM paliad.deadlines WHERE project_id=... AND rule_id=...)` before insert, otherwise PATCH the existing row. Standard pattern.
|
||||
- **What if the proceeding type changes mid-flight?** The user changes `paliad.projects.proceeding_type_id` after deadlines have been calculated. Existing actuals stay (they have `rule_id` FK pointing to the OLD rule tree). Projected rows recompute against the NEW rule tree; rule_codes that don't exist in the new tree drop out. This is the same behaviour today — flagging because the SmartTimeline makes it more visible.
|
||||
|
||||
---
|
||||
|
||||
## 14. Recommendation for implementer
|
||||
|
||||
Pattern-fluent Sonnet coder. Slice 1 is largely boilerplate (new service + handler + render shape). Slice 2 needs the calculator integration which is well-trodden (t-paliad-131 Phase A shipped overrides). Slice 3 needs the sub-project FK design (one careful migration) and the parallel-track CSS. Slice 4 is render-policy logic, low-risk.
|
||||
|
||||
Lagrange (this worktree) parks. NOT pre-emptively flipping to coder — m gates.
|
||||
|
||||
---
|
||||
|
||||
**DESIGN READY FOR REVIEW**
|
||||
469
docs/design-universal-filter-2026-05-08.md
Normal file
469
docs/design-universal-filter-2026-05-08.md
Normal file
@@ -0,0 +1,469 @@
|
||||
# Universal filter + view-mode primitive across all entity-views
|
||||
|
||||
**Issue:** m/paliad#23 (t-paliad-163)
|
||||
**Inventor:** riemann (mai/riemann/inventor-universal)
|
||||
**Date:** 2026-05-08
|
||||
**Status:** READY FOR REVIEW — no code yet, design only.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — the central position
|
||||
|
||||
m's framing is exactly right: "halfway there without custom views". The Custom Views substrate (t-paliad-144) is the missing primitive — it just hasn't been lifted from "a saved-view feature on /views/{slug}" up to "the bar that every list-shaped page reads from".
|
||||
|
||||
Concrete take:
|
||||
|
||||
- **Don't invent a new schema or a new query layer.** `internal/services/filter_spec.go` + `render_spec.go` + `view_service.go` already cover every axis the issue lists, and `POST /api/views/run` and `POST /api/views/{slug}/run` already accept ad-hoc spec overrides. The substrate's own comment says it: *"Phase B will route them here; Phase A1 leaves the wiring as a no-op for those pages."* (`internal/handlers/views.go:247`). t-paliad-163 is Phase B with a UX-shaped artifact at the front.
|
||||
- **Build one frontend `<FilterBar>` component** that consumes a `FilterSpec` + `RenderSpec` + a per-surface `axes[]` declaration, owns URL/local-state, and emits diffs. Drop it on every list-shaped surface. Each system page declares a base spec (= one of the existing `SystemView` definitions) and the supported axes.
|
||||
- **"Save current filter as named view" is one button** on the bar. It POSTs the effective spec to `/api/user-views`. The custom-view editor (`/views/new`, `/views/{slug}/edit`) becomes a power-user form for the same data the bar produces; the bar is the everyday entry point.
|
||||
- **/projects stays bespoke** (locked in t-paliad-149). Source⊥Shape orthogonality breaks for projects — they don't render as cards/calendar in the events sense, and `paliad.user_card_layouts` is a different primitive (per-card facts, not filters). The bar coexists with the `<details>`-chip cluster on /projects without subsuming it.
|
||||
|
||||
The migration is one surface at a time. /inbox first (no filter today, lowest blast radius), /events last (richest filter today, the proof point that the primitive can absorb it).
|
||||
|
||||
---
|
||||
|
||||
## 0. Premises verified live
|
||||
|
||||
Before designing on top of CLAUDE.md / memory / the issue body, I checked the live tree:
|
||||
|
||||
- **`paliad.user_views` (056) exists.** `paliad.user_card_layouts` (061) exists. **`paliad.user_view_layouts` does NOT exist** — the issue body's reference is a typo. Real names: `paliad.user_views` is the FilterSpec/RenderSpec store; `paliad.user_card_layouts` is the per-card-facts store for /projects only. `grep -rn user_view_layouts` returns nothing.
|
||||
- **`POST /api/views/run`** takes an inline `FilterSpec` and returns `ViewRunResult{rows, inaccessible_project_ids}` without touching the DB. (`internal/handlers/views.go:248`)
|
||||
- **`POST /api/views/{slug}/run`** accepts an optional `{filter: <override>}` body that overrides the saved/system spec for one run — does not mutate storage. (`internal/handlers/views.go:282`, `runRequest` at `:238`)
|
||||
- **5 SystemViews are already code-resident** (`dashboard`, `agenda`, `events`, `inbox`, `inbox-mine`) at `internal/services/system_views.go:35`-`156`. Their slugs are reserved against user-view collisions. Each carries a canonical `FilterSpec` + `RenderSpec`.
|
||||
- **3 render-shape components exist** in `frontend/src/client/views/`: `shape-list.ts`, `shape-cards.ts`, `shape-calendar.ts`. They take `(host, rows, render)` — pure config-driven dispatch.
|
||||
- **List shape supports density (compact|comfortable), 13 known columns, and sort.** Column registry at `internal/services/render_spec.go:99`: `["date","time","title","project","actor","status","rule","event_type","location","appointment_type","approval_status","decided_by","kind"]`. Sort: `date_asc | date_desc`.
|
||||
- **`attachEventTypeMultiSelectFilter`** in `frontend/src/client/event-types.ts` is a mature listbox-panel component (search + grouped checkboxes + URL round-trip + internal `onLangChange` subscription per t-paliad-117). The pattern to copy for project + appointment-type + status panels.
|
||||
- **`renderAgendaTimeline`** in `frontend/src/client/agenda-render.ts` is the day-grouped timeline used both by `/agenda` and dashboard inline; reusable.
|
||||
- **`.entity-table` row-click contract** is the project-wide rule (CLAUDE.md "Frontend conventions"). Any list-shape table must wire row-handlers that skip clicks on inner `<a>`/`<button>` and add `entity-table--readonly` when rows don't navigate. The bar must not regress this — it doesn't, because `shape-list.ts` already emits `entity-table--readonly` on its tables.
|
||||
|
||||
---
|
||||
|
||||
## 1. The 7 list-shaped surfaces today — what they each have
|
||||
|
||||
A factual map of who has what. The underlinings are the axes the issue calls out.
|
||||
|
||||
| Surface | Filter axes today | View modes | State store |
|
||||
|---|---|---|---|
|
||||
| **/agenda** (`client/agenda.ts`, 226 LoC) | type chip (deadlines/appointments/both), range chip (7/14/30/90d), event-type multi-select | timeline only | URL `?range=&types=&event_type=` |
|
||||
| **/events** (`client/events.ts`, 1083 LoC) — also `/deadlines`, `/appointments` via 302 redirect | type chip (deadline/appointment/all), status select (8 buckets), project select (single, with `__personal__` sentinel), event-type multi (deadline-only), appointment-type select (appointment-only) | cards / list / calendar | URL `?type=&view=&status=&project_id=&personal_only=&event_type=&type_filter=` |
|
||||
| **/inbox** (`client/inbox.ts`, 329 LoC) — both tabs | tab (pending-mine / mine), nothing else | list only | URL `?tab=` |
|
||||
| **/projects** (`client/projects.ts` + `client/projects-cards.ts`) | search input, 6 chips (scope/status/type/has-open-deadlines), `<details>` multi-select for status + type | tree / cards / flat | sessionStorage `paliad.projects.lastView` + URL overlay |
|
||||
| **/views/{slug}** (`client/views.ts`) | none in the viewer (only saved-spec); shape switcher (list/cards/calendar) | list / cards / calendar | URL path |
|
||||
| **dashboard** (`client/dashboard.ts`, inline Agenda + Letzte Aktivität) | none | inline timeline / inline list | none |
|
||||
| **/views/new \| /views/{slug}/edit** (`client/views-editor.ts`) | full FilterSpec form (sources / scope / time / shape / list density) | n/a — author surface | n/a |
|
||||
|
||||
The pattern m sees on `/inbox?tab=mine` is the natural endpoint of seven surfaces all building filters their own way: the surface that didn't have a filter author yet is also the surface with no filter chrome at all.
|
||||
|
||||
The good news: every axis on every surface is **already nameable in the FilterSpec / RenderSpec grammar** that `internal/services/filter_spec.go` ships. There's a one-to-one mapping; nothing has to be invented at the data layer.
|
||||
|
||||
---
|
||||
|
||||
## 2. What the universal primitive is — `<FilterBar>`
|
||||
|
||||
A single TypeScript component, mounted on a host `<div>`, parameterised by:
|
||||
|
||||
```ts
|
||||
interface FilterBarOpts {
|
||||
// Base spec — usually a SystemView's FilterSpec, fetched from /api/views/system.
|
||||
// For /views/{slug}, this is the user-view's saved filter_spec.
|
||||
baseFilter: FilterSpec;
|
||||
baseRender: RenderSpec;
|
||||
|
||||
// Which axes the surface supports. Universal axes always render;
|
||||
// per-surface axes render iff present in this list.
|
||||
axes: AxisKey[];
|
||||
|
||||
// Optional fixed predicates the surface refuses to let users tweak.
|
||||
// E.g. /inbox forces sources=[approval_request], not relaxable.
|
||||
pinned?: PartialFilterSpec;
|
||||
|
||||
// Where to write rows when filter changes. The bar runs the spec via
|
||||
// /api/views/run and hands the result back here for shape rendering.
|
||||
onResult: (res: ViewRunResult, effective: { filter: FilterSpec; render: RenderSpec }) => void;
|
||||
|
||||
// Optional URL-param namespace (defaults to the empty namespace).
|
||||
// Useful for embedding the bar twice on one page (dashboard inline)
|
||||
// without colliding ?time= / ?time2=. Phase 4 ramps this up if needed.
|
||||
urlNamespace?: string;
|
||||
|
||||
// Optional surface key — used as the localStorage key for view-mode
|
||||
// and density preferences ("paliad.bar.<surfaceKey>.prefs").
|
||||
surfaceKey: string;
|
||||
|
||||
// Optional sidebar slot — when present, "Save as view" + "Reset" are
|
||||
// rendered. Defaults to true on every surface except dashboard inline.
|
||||
showSaveAsView?: boolean;
|
||||
}
|
||||
|
||||
type AxisKey =
|
||||
| "project" // ← universal (always rendered if axes contains it; otherwise the chip is hidden)
|
||||
| "time" // ← universal
|
||||
| "personal_only" // ← universal
|
||||
| "deadline_status" // ← per-surface (deadline source only)
|
||||
| "deadline_event_type"
|
||||
| "appointment_type"
|
||||
| "approval_viewer_role"
|
||||
| "approval_status"
|
||||
| "approval_entity_type"
|
||||
| "project_event_kind"
|
||||
| "shape" // ← view-mode (list|cards|calendar)
|
||||
| "sort" // ← per-shape
|
||||
| "density" // ← list-shape only
|
||||
| "columns"; // ← list-shape only (advanced; popover with checkboxes)
|
||||
```
|
||||
|
||||
The bar's job:
|
||||
1. On mount, parse URL params (within `urlNamespace`) and `localStorage["paliad.bar.<surfaceKey>.prefs"]`, overlay them on `baseFilter` + `baseRender`, validate, and POST `/api/views/run` with the effective spec.
|
||||
2. Render chrome — chips for booleans / single-selects, popovers for multi-selects, segmented control for view-mode. Each control is a thin wrapper over an existing pattern (chip-row, multi-anchor + multi-panel, segment-control).
|
||||
3. On any change, re-validate, sync URL, sync localStorage (for prefs only — see §3), POST the spec again, hand the result + effective spec to `onResult`. The shape host renders.
|
||||
4. Expose two trailing actions (when `showSaveAsView`): **Speichern als Sicht** and **Zurücksetzen**.
|
||||
|
||||
What the bar is NOT:
|
||||
- Not a router. Pages still own their URL.
|
||||
- Not a layout system. Cards on /projects keep the `paliad.user_card_layouts` primitive (per-card facts) — that's orthogonal to filtering.
|
||||
- Not the renderer. The bar just hands `(rows, effectiveRender)` to one of `shape-list / shape-cards / shape-calendar`.
|
||||
- Not a substitute for the dedicated views editor. That stays for power-users who want full control (predicates, custom horizons, columns).
|
||||
|
||||
---
|
||||
|
||||
## 3. The 7 brief items — taking positions
|
||||
|
||||
### 3.1 Filter axes: which are universal, which are per-surface, how does the bar declare its supported axes?
|
||||
|
||||
**Universal** — render always when `axes` contains them (and the surface's pinned spec doesn't rule them out):
|
||||
- `project` — single-select with the existing `<select>` (Alle / Nur persönliche / each project, ltree-indented). On surfaces where multi-project would help later (system-wide views), the same control upgrades to a multi-select listbox-panel by adding a `multi: true` flag — postpone to phase C, single-select covers every surface today.
|
||||
- `time` — segmented chip group (`Heute · 7T · 30T · 90T · Alles · Anpassen`). Maps to `time.horizon`. "Anpassen" pops a date-range pair (`time.horizon = "custom"` + from/to). On /inbox the chip group reads "Heute · 7T · 30T · Alles" since approval queues are usually now-shaped — but the same control.
|
||||
- `personal_only` — boolean chip ("Nur eigene"). Active when `scope.personal_only=true`. Hidden when source set excludes deadline AND appointment (others don't honour personal_only).
|
||||
|
||||
**Per-surface** — declared in `axes`, controlled by which sources the spec uses:
|
||||
- `deadline_status` (chip cluster: "Offen · Überfällig · Erledigt · Alle") — only when `sources` includes deadline.
|
||||
- `deadline_event_type` (multi-select listbox-panel, reuses `attachEventTypeMultiSelectFilter`) — only when sources includes deadline.
|
||||
- `appointment_type` (single-select for now: hearing/meeting/consultation/deadline_hearing/Alle) — only when sources includes appointment.
|
||||
- `approval_viewer_role` (segmented chips: "Zur Genehmigung · Eigene Anfragen · Alle sichtbaren") — only when sources includes approval_request. This subsumes the /inbox tab.
|
||||
- `approval_status` (chip cluster: "Wartend · Entschieden · Alle") — only when sources includes approval_request.
|
||||
- `approval_entity_type` (chip pair: "Fristen · Termine") — only when sources includes approval_request.
|
||||
- `project_event_kind` (multi-select listbox-panel; the 13 `KnownProjectEventKinds`) — only when sources includes project_event. Powers the dashboard "Letzte Aktivität" filter.
|
||||
|
||||
**View-mode + per-shape** — declared in `axes`, but special:
|
||||
- `shape` — segmented chips (list/cards/calendar). Always rendered when `axes` contains `shape`; available shapes derived from `baseRender` + the surface's whitelist. The bar emits a transient render override (mirrors how `client/views.ts:171` does shape-switching today: it doesn't rerun, just re-renders).
|
||||
- `sort` — single-select (`date_asc | date_desc`).
|
||||
- `density` — segmented chip pair (Komfortabel / Kompakt) — list shape only, hidden otherwise.
|
||||
- `columns` — popover with checkbox list of `KnownListColumns` — list shape only, advanced opt-in.
|
||||
|
||||
**How the surface declares its axes:** an array. No higher-order component, no slot composition. Plain config. The bar's render is a switch over each axis key:
|
||||
|
||||
```ts
|
||||
mountFilterBar(host, {
|
||||
baseFilter: agendaSystemView.filter,
|
||||
baseRender: agendaSystemView.render,
|
||||
axes: ["time", "project", "personal_only", "deadline_status", "deadline_event_type", "appointment_type", "shape", "sort"],
|
||||
surfaceKey: "agenda",
|
||||
onResult: ({rows, inaccessible_project_ids}, effective) => { ... },
|
||||
});
|
||||
```
|
||||
|
||||
Slot composition was considered. It's overkill — every existing chrome pattern paliad uses (chip cluster, multi-anchor popover, segmented control, `<select>`) is already in `frontend/src/styles/global.css`; there's nothing to plug or override. A flat axis-config keeps the bar a 600-LoC component, not a framework.
|
||||
|
||||
### 3.2 State model: URL vs in-memory vs hybrid
|
||||
|
||||
**Hybrid**, with a sharp split:
|
||||
|
||||
- **URL is canonical** for everything that affects which rows you see. That means: project (`?project=`), sources (`?sources=`), time (`?time=` for horizon, `?from=&to=` for custom), personal-only, every per-source predicate (`?deadline_status=`, `?event_type=`, `?appointment_type=`, `?approval_role=`, `?approval_status=`, `?approval_entity_type=`, `?project_event_kind=`), shape (`?shape=`), sort (`?sort=`). Bookmarkable, shareable, refresh-survives, deep-linkable from the dashboard or /inbox bell.
|
||||
- **localStorage holds preferences** that don't change rows: density (`?density=` is also a URL param when explicitly chosen, but absence falls through to localStorage default), default columns per surface (advanced opt-in), default shape per surface (only when the user has overridden the SystemView's default — first visit uses base). Keyed `paliad.bar.<surfaceKey>.prefs`. Mirrors the spirit of /projects' sessionStorage `paliad.projects.lastView` (t-paliad-149 Q1 lock-in) but at the right scope: the "what I prefer" sticks per surface, the "what this URL is showing" stays in the URL.
|
||||
- **No sessionStorage.** /projects' use was justified by tab restoration; for the bar, every interesting bit is in the URL (so back/forward + refresh + share both work). Adding a third tier would create the worst-of-three: state in URL ∪ session ∪ local, three places to look when something's off.
|
||||
|
||||
URL parameter names are stable and short. The bar exports a tiny URL codec (`encodeBarParams(filter, render) → URLSearchParams` and inverse) so the same params work whether the bar is on /agenda, /inbox, /events, or /views/{slug}.
|
||||
|
||||
The migration from /events' bespoke `?type=&view=&status=&project_id=&personal_only=&event_type=&type_filter=` to the bar's params is straightforward: each old param maps to a new one (or stays, when names already match — `?project_id`, `?personal_only`, `?event_type` are unchanged; `?type` becomes `?sources`; `?view` becomes `?shape`; `?status` and `?type_filter` become per-surface predicates). Server middleware on the legacy /events handler can rewrite old → new params for one release so existing bookmarks don't 404.
|
||||
|
||||
### 3.3 View-mode switcher — universal or per-surface? Sort-state ownership? Density?
|
||||
|
||||
**Universal.** The bar always owns the segmented `shape` control. The surface declares which shapes it whitelists (e.g. /inbox might whitelist `["list"]` and hide the switcher; /agenda might whitelist `["cards", "list", "calendar"]`). When the whitelist has only one entry the bar suppresses the chip; when ≥2 it renders.
|
||||
|
||||
**Sort lives in the bar's `RenderSpec.list.sort` / `cards.sort`.** Already exists in the schema. The list-shape table renderer is currently sort-by-config-only; promoting `<th>` clicks to update `RenderSpec.list.sort` is a one-line callback in the bar (`onListHeaderSort`) → server-side re-sort isn't needed because `shape-list.ts:16` already sorts in JS. **Sortable column headers become a list-shape feature owned by the bar**, not a per-surface concern.
|
||||
|
||||
**Density** is a list-shape config (`comfortable | compact`). The bar exposes the pair as a chip; `shape-list.ts` already supports both. Density on /inbox today is implicitly comfortable; toggling it to compact gives the user the activity-feed look on the inbox surface for free, which is the kind of small win the brief calls out.
|
||||
|
||||
**Multi-column sort** is out-of-scope for v1 — `shape-list.ts:16` does single-column sort, which matches every surface today. Add when a user asks.
|
||||
|
||||
### 3.4 Composability — drop-in API without forcing existing pages to refactor
|
||||
|
||||
The bar mounts onto an empty `<div>`. The surface's TSX changes are:
|
||||
- Replace the per-page filter chrome (chip cluster, selects, popovers, view-mode segment) with `<div id="filter-bar"></div>`.
|
||||
- Replace the per-page result rendering with `<div id="filter-bar-results"></div>`.
|
||||
- The page's `client/<surface>.ts` shrinks to: read `__PALIAD_<SURFACE>__` initial payload (or skip), call `mountFilterBar(host, opts)`, write `onResult` to dispatch into the matching shape component (already exist).
|
||||
|
||||
That's it. The page surface is reduced to ~50 LoC of orchestration around the bar; the bulk of `events.ts` (1083 LoC) drops to a baseline of ≈80 LoC after Phase 3 because the per-axis filter state, the project select populator, the language-hot-swap, the URL-sync, the type-visibility logic, the appointment-type filter logic, the calendar month-paging, and the cards-vs-list-vs-calendar dispatch all migrate into shared components: the bar (filter axes, view-mode, URL, language hot-swap), `shape-list.ts` (table), `shape-cards.ts` (cards), `shape-calendar.ts` (month grid).
|
||||
|
||||
The bar **does not own row interaction**. Row click → detail page is already a per-shape concern (`shape-list.ts` emits `entity-table--readonly`; the bar doesn't override that). Lifecycle actions (complete/reopen/approve/reject) are also per-shape — `shape-list.ts` will need a small extension to emit clickable-row tables on /events (so the existing complete-checkbox + reopen flow keeps working). That extension is one new render flag in `RenderSpec.list.row_action: "navigate" | "approve" | "complete-toggle" | "none"`, defaulting to navigate. Honest scope: this is a small `RenderSpec` schema bump (new optional field), not an axis change.
|
||||
|
||||
### 3.5 Reuse with the existing /views layout-spec — does the universal bar inherit, or does the spec become a special case of saved bar state?
|
||||
|
||||
**The latter.** m's hint ("halfway there without custom views") points at exactly this.
|
||||
|
||||
A **Custom View is the persisted form of a bar state.** When the user clicks "Speichern als Sicht" on /agenda, the bar gathers the effective `FilterSpec` + `RenderSpec`, prompts for name + slug + icon + show-count (a small modal — one form, four fields, mirroring `views-editor.ts`'s collectForm), and POSTs `/api/user-views`. The user is then redirected to `/views/{slug}` (or stays in place with a confirmation toast — see §3.7).
|
||||
|
||||
Conversely, **a SystemView is a code-resident bar state.** The bar already knows how to load one (`/api/views/system` → match slug). The "system pages" become surfaces whose default state happens to live in code instead of in `paliad.user_views`.
|
||||
|
||||
Implementation consequence:
|
||||
- `views-editor.ts` keeps existing for power users who want to edit predicates that the bar doesn't expose (e.g. pinning a `time.field = "created_at"` for an "audit-trail" view). The editor and the bar produce identical `FilterSpec` + `RenderSpec` JSON; they're alternate authoring UX.
|
||||
- `views.ts` (the `/views/{slug}` viewer) gains the bar above its rows. The bar renders with the saved spec as its base; the user can tweak axes (e.g. narrow the time horizon for a quick glance) — those tweaks are URL-overlays and don't mutate the saved spec until the user clicks "Aktualisieren" (a new affordance). This satisfies the brief's "halfway there" hint: today /views/{slug} renders a saved spec **statically**; with the bar, it becomes interactive without losing the saved-state semantics.
|
||||
|
||||
### 3.6 Migration path — phase one surface at a time, identify the hardest
|
||||
|
||||
The bar is shippable on one surface in one PR. Then each subsequent surface is its own small PR.
|
||||
|
||||
**Phase 1 — /inbox (the cold start).** Lowest blast radius: today /inbox has no filter chrome, only tabs. Replace tabs with the `approval_viewer_role` axis (the bar collapses two tabs into one chip cluster). Drop the bar with `axes: ["time", "approval_status", "approval_entity_type", "approval_viewer_role", "shape", "density", "sort"]`. Pin `sources: [approval_request]`. Density toggle gives the user a stream view m's "looks really bad" was diagnosing. URL contract: keep `?tab=` redirecting to `?approval_role=` for one release.
|
||||
|
||||
**Phase 2 — /agenda.** Already filter-shaped and the most readable orchestrator (226 LoC). Bar replaces the chip cluster + range chip + event-type popover. `axes: ["time", "project", "personal_only", "deadline_status", "deadline_event_type", "appointment_type", "shape", "sort"]`. Default: shape="cards" (matching today's timeline default). The dashboard inline Agenda gets a stripped-down bar with `axes: ["time", "deadline_event_type"]` and `urlNamespace: "agenda"` (so the page-level bar on the dashboard doesn't collide with anything else if the dashboard adds another bar later for "Letzte Aktivität").
|
||||
|
||||
**Phase 3 — /events (the proof point).** Most complex filter today: type chip + status select + project select + personal-only + event-type multi + appointment-type select + cards/list/calendar. Every one of these axes is already nameable in FilterSpec/RenderSpec (verified §1). `axes: ["time", "project", "personal_only", "deadline_status", "deadline_event_type", "appointment_type", "shape", "sort", "density"]`. The 5-card summary above the table (Heute / Diese Woche / Nächste Woche / Später / Überfällig) becomes a bar-driven facet: clicking a card sets `time.horizon` (or for "Überfällig", a special `deadline_status: ["overdue"]` predicate). Identifying /events as the hardest surface up front means the primitive's axis registry has to be wide enough on day 1; the design above already names every needed axis, so Phase 1's primitive is forward-compatible.
|
||||
|
||||
**Phase 4 — dashboard inline lists (Agenda + Letzte Aktivität).** The dashboard composes two tiny bars: one for Agenda (cards/list, narrow time horizon, no save-as-view), one for Letzte Aktivität (project_event source, density=compact, no save-as-view). Both use `urlNamespace` to keep params tidy.
|
||||
|
||||
**Phase 5 — /views/{slug}.** Add the bar above the rows. Saved spec → bar's base; URL overlays are transient until "Aktualisieren" persists them. The custom-view editor (`/views/new`, `/views/{slug}/edit`) stays for power users; "Speichern als Sicht" from the bar is the everyday path.
|
||||
|
||||
**Out of phasing:** /projects stays bespoke. The bar coexists on the page only if a future task adds it — today the chip cluster + tree/cards/flat segment are doing fine, and Source⊥Shape orthogonality breaks for projects (no ProjectSource in the substrate; no TreeShape in the substrate). t-paliad-149's locked-in choice stands.
|
||||
|
||||
**Hardest surface, identified:** /events. Phase 3 is the proof point. By designing the bar's axis registry against /events on day 1 (not retrofitting), Phase 1 (/inbox) and Phase 2 (/agenda) ship without redesign churn.
|
||||
|
||||
### 3.7 "Save current filter as named view" — making it trivial
|
||||
|
||||
The bar's trailing action is a single button: **Speichern als Sicht**. Click → small modal:
|
||||
|
||||
```
|
||||
┌─ Sicht speichern ─────────────────────┐
|
||||
│ Name [_________________] │
|
||||
│ Slug [_________________] (opt) │
|
||||
│ Icon [▼ Auswählen ] │
|
||||
│ □ Anzahl in der Sidebar zeigen │
|
||||
│ │
|
||||
│ [ Abbrechen ] [ Speichern ] │
|
||||
└───────────────────────────────────────┘
|
||||
```
|
||||
|
||||
If slug is empty, derive from name (kebab-case) and validate against the regex + reserved-slug list client-side (mirrors `views-editor.ts:179`). On 409 (slug taken), show inline error and let the user adjust. On success, two affordances:
|
||||
- A toast "Als Sicht 'Heute überfällig' gespeichert. Zur Sicht wechseln?" with a link to `/views/{slug}`.
|
||||
- The new view automatically appears in the **Meine Sichten** sidebar group (t-paliad-144) on next page load (or sooner, if the bar emits a window event the sidebar listens to).
|
||||
|
||||
This means: every list-shaped surface gets "save current filter as named view" for free. No per-surface plumbing.
|
||||
|
||||
**"Aktualisieren" on /views/{slug}** is the symmetric write-back: when the user is viewing a saved view and tweaks the bar, a "Aktualisieren" button appears next to "Speichern als Sicht". Click → PATCH `/api/user-views/{id}` with the effective spec. Confirmation toast.
|
||||
|
||||
**"Zurücksetzen"** clears the URL overlay and re-renders with the base spec only.
|
||||
|
||||
---
|
||||
|
||||
## 4. Two harder questions worth surfacing now
|
||||
|
||||
### 4.1 The chip-vs-popover-vs-select tension
|
||||
|
||||
paliad has three patterns for "pick from a set" today:
|
||||
|
||||
- **Chip cluster** (e.g. /agenda type chip, /projects scope chip) — best for 2–4 mutually exclusive options. Always-visible, click-fast.
|
||||
- **`<select>`** (e.g. /events status, project, appointment-type) — best for 5–30 single-select options, especially when the option list is dynamic (project list grows).
|
||||
- **Listbox-panel popover** (e.g. event-type multi, /projects status/type `<details>`) — best for multi-select or for >30 options with search.
|
||||
|
||||
The bar must use the right pattern per axis to feel native, not regress one surface in service of another. My picks:
|
||||
|
||||
| Axis | Pattern | Why |
|
||||
|---|---|---|
|
||||
| project (single) | `<select>` | dynamic list; option count grows with the firm |
|
||||
| time | chip cluster + "Anpassen" overflow | 5 mutually exclusive presets cover 95% of usage |
|
||||
| personal_only | single chip | binary |
|
||||
| sources (when `axes` exposes it) | listbox-panel multi | 4 options but multi-select |
|
||||
| deadline_status | chip cluster | 4 options, mutually exclusive |
|
||||
| deadline_event_type | listbox-panel multi | 40+ options, search + grouped checkboxes (reuses event-types.ts pattern) |
|
||||
| appointment_type | chip cluster (4 + Alle) | small mutually-exclusive set |
|
||||
| approval_viewer_role | chip cluster | 3 mutually exclusive options |
|
||||
| approval_status | chip cluster | 4 options |
|
||||
| approval_entity_type | chip cluster | 2 options |
|
||||
| project_event_kind | listbox-panel multi | 13 options, multi-select |
|
||||
| shape | segmented control | 1-of-N, special UX (icon-only buttons) |
|
||||
| sort | `<select>` (small) | 2 options today, room for `title_asc/desc` later |
|
||||
| density | segmented control | binary, icon-shaped |
|
||||
|
||||
The point: the bar isn't one widget, it's a thin shell that delegates each axis to the right existing control. CSS reuse: `.agenda-chip` / `.events-view-btn` / `.akten-multi-trigger` / `.multi-anchor` / `.multi-panel` all stay; the bar just composes them.
|
||||
|
||||
### 4.2 Empty-state UX when an axis is invalid for the current sources
|
||||
|
||||
If the user clears all sources, every per-source axis becomes meaningless. Two options:
|
||||
- **Hide invalid axes.** Cleanest. Bar reacts to source changes by collapsing dependent chips. Risk: feels jumpy.
|
||||
- **Disable + tooltip.** Less jumpy but visually noisier.
|
||||
|
||||
Recommend **hide**, with one twist: the bar persists hidden-axis state in the URL anyway, so toggling sources back on restores the user's prior filter. This matches /events' existing behaviour (when type=appointment, event-type panel is hidden but its state persists in `?event_type=`).
|
||||
|
||||
---
|
||||
|
||||
## 5. RenderSpec extensions — one schema bump
|
||||
|
||||
The bar exposes capabilities that are already in `RenderSpec` (shape, sort, density, columns) plus one new field:
|
||||
|
||||
```go
|
||||
type ListConfig struct {
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
Sort SortOrder `json:"sort,omitempty"`
|
||||
Density ListDensity `json:"density,omitempty"`
|
||||
RowAction ListRowAction `json:"row_action,omitempty"` // NEW — "navigate" (default) | "complete_toggle" | "approve" | "none"
|
||||
}
|
||||
```
|
||||
|
||||
`RowAction` lets `shape-list.ts` know whether to wire an `entity-table--readonly` or to attach the existing checkbox / reopen / approve / reject buttons. Default `navigate` keeps the contract stable; system pages explicitly set `complete_toggle` (events list) and `approve` (inbox list).
|
||||
|
||||
This is the only schema change. Every other axis is already in the spec.
|
||||
|
||||
---
|
||||
|
||||
## 6. Hard requirements from the brief — addressed
|
||||
|
||||
- **`.entity-table` row-click contract.** The bar's list-shape table is rendered by `shape-list.ts:80` which already emits `entity-table--readonly`. When `RowAction="navigate"` the bar adds a row-handler that does `window.location.href = detailRoute(row)` and skips clicks on inner `<a>`/`<button>` (mirrors the existing `events.ts:wireRowHandlers` pattern). Whole-card / whole-row click → JS row-handler, never `::before` overlays (CLAUDE.md frontend conventions, t-paliad-102).
|
||||
- **No hour estimates.** Throughout this design.
|
||||
- **DE+EN bilingual.** Every new label gets a key under `views.bar.*` (single new namespace; ~25 keys for axes + ~10 for save modal + ~10 for empty/loading/error states). Keys are added to `frontend/src/client/i18n.ts`'s registry at the appropriate phase.
|
||||
- **Mobile.** The bar collapses to a single horizontal scroll row on `≤768px` (mirrors `.frist-summary-cards` mobile pattern). The "Speichern als Sicht" + "Zurücksetzen" actions move into a `<details>` "Mehr" affordance on mobile to keep the scrollable strip clean. Re-imagining mobile-list-mode is out of scope per the brief.
|
||||
|
||||
---
|
||||
|
||||
## 7. Trade-offs — the honest list
|
||||
|
||||
### What this design gains
|
||||
1. **One filter chrome across all list-shaped surfaces.** Users learn one bar, every surface respects it. Discoverability for "save as view" jumps from one surface (/views/new editor) to seven.
|
||||
2. **System pages become substrate clients.** `/api/views/run` (already shipped) becomes the canonical event-fetching path. Phase B from t-paliad-144 design lands.
|
||||
3. **`events.ts` shrinks ~10×.** Most of its 1083 lines are filter chrome + URL sync + view-mode dispatch — all now shared.
|
||||
4. **Save-as-view is universal.** Today only /views/new + /views/{slug}/edit can author saved views; after the migration, every page can.
|
||||
5. **/inbox gains filters and sort and density** as a free side effect of the migration — directly addressing m's "looks really bad" diagnosis.
|
||||
6. **Sortable column headers** become a substrate feature (small bar callback that updates `RenderSpec.list.sort`).
|
||||
7. **The schema barely moves** — one new optional field on `ListConfig`. Migrations not needed.
|
||||
|
||||
### What this design risks
|
||||
1. **One component holding many axes is at risk of bloat.** Mitigation: the bar is a flat axis-config (no slot composition, no HOC). 600 LoC ceiling enforced by the per-axis switch pattern. CSS reuse keeps the visual surface small.
|
||||
2. **The /events migration is the largest single PR.** 1083 LoC client → ≈100 LoC + ≈250 LoC of bar config + per-shape extensions. A regression on the 5-card summary or the deadline complete/reopen flow would be visible. Mitigation: Phase 3 is gated behind Phase 1 (/inbox) and Phase 2 (/agenda) shipping cleanly, and the design lands the `RowAction` schema bump in Phase 1 so `complete_toggle` is wired before /events arrives.
|
||||
3. **URL overlay on /views/{slug} creates two states.** Saved spec ≠ effective spec when the user has tweaked the bar. The "Aktualisieren" / "Speichern als Sicht" actions resolve which becomes canonical, but a user who navigates away with unsaved tweaks loses them. Mitigation: a `?dirty=1` URL marker + a small toast on first tweak ("Änderungen sind nicht gespeichert").
|
||||
4. **Two filter chromes coexist on /projects.** The bar doesn't subsume the chip cluster (Source⊥Shape break). Future visual unification would standardise the chip pattern between the two — out of scope here.
|
||||
5. **Hidden-axis URL state.** Persisting `?event_type=` even when sources excludes deadline can confuse a user reading their URL. Acceptable: matches /events' current behaviour and is reversible by toggling the source back. The alternative (pruning URL params on source change) loses the user's prior state on a quick re-toggle.
|
||||
6. **i18n hot-swap correctness.** Every dynamic populator must subscribe to `onLangChange` (the t-paliad-117 lesson). The bar handles this once internally for every axis; surfaces don't need to wire it per-page.
|
||||
7. **Default per-surface defaults can drift from SystemView.** The bar reads `localStorage` for prefs (e.g. preferred shape on /agenda). If a user toggles a pref then a SystemView default changes, the user's pref wins. Mitigation: `localStorage` only stores explicit overrides, not the base value, so changes to the SystemView's base flow through for users who haven't overridden.
|
||||
8. **Two storage primitives ("user_views" + "user_card_layouts") could be confusing.** Names are similar; they store different things. Mitigation: documentation. The bar only ever reads/writes `paliad.user_views`. /projects' card-layout is a separate, narrow concern that stays bespoke.
|
||||
|
||||
### Reversibility
|
||||
- The bar is purely additive. Phase 1 doesn't touch /agenda or /events. If after Phase 1 the bar feels wrong, /inbox can revert to its prior chrome by reverting one PR. Phase 2 only ships after Phase 1 holds.
|
||||
- The new `RenderSpec.list.row_action` field is optional with a `navigate` default; existing rows continue to render correctly.
|
||||
- The URL contract is preserved for /events for one release via a thin redirect middleware that maps old → new params; bookmarks don't 404.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions for m before lock-in
|
||||
|
||||
These are decisions where my recommendation might be challenged:
|
||||
|
||||
**Q1. State model: full URL-canonical, or do we accept localStorage for shape/density preferences?** I recommend hybrid: URL for filter axes, localStorage for shape + density prefs (per-surface). Keeps shareable URLs honest while letting "I always want compact density on /inbox" persist across sessions.
|
||||
|
||||
**Q2. Save-as-view modal vs slide-out vs inline.** I recommend modal — minimal surface, four fields, blocks the page. Alternatives: a slide-out (less interruption, more work) or an inline expansion of the "Speichern" button (cramped on mobile). Modal lines up with existing `<dialog>` usage on /admin.
|
||||
|
||||
**Q3. /events 5-card summary — keep, or fold into the bar?** I recommend keep (above the bar, unchanged). The cards encode urgency at a glance; collapsing them into the bar's `time` chip would lose the "9 / 3 / 2 / 5 / Überfällig 1" density. Clicking a card still updates the bar's time horizon (existing behaviour preserved).
|
||||
|
||||
**Q4. Tabs on /inbox — collapse into the `approval_viewer_role` chip cluster, or keep tabs as visual chrome above the bar?** I recommend collapse — one fewer place for state, the chip cluster is exactly the right control for 3 mutually exclusive options. Counter-argument: tabs are a strong visual hint of "two pages with the same shape". My counter-counter: the bar's chips are the same hint, less mid-air.
|
||||
|
||||
**Q5. URL parameter naming.** I recommend short, namespaced names: `?time=`, `?sources=`, `?project=`, `?personal=`, per-source predicate names (`?d_status=` for deadline.status, `?a_role=` for approval_request.viewer_role, `?pe_kind=` for project_event.event_types). Cargo-friendly to long names like `?deadline_status=` if m prefers — same axis, same wire format.
|
||||
|
||||
**Q6. "Speichern als Sicht" on the dashboard inline bars — show or hide?** I recommend hide. The dashboard composes two tiny bars; saving a sub-bar's spec as a custom view would feel disjoint from the dashboard concept. Power users can craft custom views via /views/new instead.
|
||||
|
||||
**Q7. Migration: do we keep `?type=` redirecting on /events for one release, or hard-cut?** I recommend keep for one release (small middleware in `internal/handlers/events_pages.go`) so existing bookmarks (Sidebar, internal docs, the /events sidebar links at `events.ts:838`) keep working through Phase 3.
|
||||
|
||||
**Q8. /views/{slug} — should the URL overlay tweak persist in localStorage as a "draft" until the user resets or saves?** I recommend no — URL is the only state, and a tweak that disappears on reload matches user expectation. The `?dirty=1` toast is enough. Alternative: a per-view-id `paliad.bar.view-{id}.draft` localStorage key that re-applies on re-visit — more powerful, more surprising.
|
||||
|
||||
**Q9. Sortable column headers — list shape only, or also rule for cards/calendar in a future phase?** I recommend list-shape only for v1. Cards and calendar have their own ordering semantics (group_by + within-group sort); promoting headers would over-complicate.
|
||||
|
||||
**Q10. Bar embedding twice on dashboard — `urlNamespace` worth the complexity, or single namespace and accept that dashboard's two bars share `?time=`?** I recommend `urlNamespace` for dashboard only (e.g. `?agenda_time=` and `?activity_time=`). Costs ~10 LoC, keeps two bars from colliding.
|
||||
|
||||
**Q11. Multi-project select — phase C, or fold into Phase 2?** I recommend phase C. Single-project covers every surface today; multi-project unlocks "all my Düsseldorf cases this week" type queries but no current page asks for it. Save complexity until a user does.
|
||||
|
||||
**Q12. EventTypeMultiSelect today supports `none` ("Ohne Typ") — keep or drop?** I recommend keep. The bar's deadline_event_type axis just wraps `attachEventTypeMultiSelectFilter`, so `none` works as-is. Honestly nothing to design here.
|
||||
|
||||
---
|
||||
|
||||
## 9. Scope boundaries (in + out)
|
||||
|
||||
### In scope
|
||||
- New `<FilterBar>` component + axis registry + URL codec.
|
||||
- One `RenderSpec.list.row_action` field with validator update.
|
||||
- Phase 1: /inbox surface + tests.
|
||||
- Documentation + i18n keys for the bar.
|
||||
- Phase 2..5 named in the migration path with clear gates between them — but each is its own PR and not part of "the inventor design has shipped" definition-of-done.
|
||||
|
||||
### Out of scope (per the brief + my reading)
|
||||
- New entity surfaces. Only the 7 named surfaces.
|
||||
- Backend SQL migrations beyond the one optional `RenderSpec.list.row_action` field. The bar runs through `/api/views/run` which already exists.
|
||||
- /projects redesign — t-paliad-149 stands.
|
||||
- Mobile-list-mode reimagining — separate workstream.
|
||||
- Multi-project selection — phase C, not v1.
|
||||
- Multi-column sort — when a user asks.
|
||||
- Internationalisation beyond DE + EN.
|
||||
|
||||
---
|
||||
|
||||
## 10. Files implementer will touch (Phase 1: /inbox)
|
||||
|
||||
To make the scope concrete:
|
||||
|
||||
**New:**
|
||||
- `frontend/src/components/FilterBar.tsx` — TSX wrapper with the host divs.
|
||||
- `frontend/src/client/filter-bar/index.ts` — `mountFilterBar` entry point.
|
||||
- `frontend/src/client/filter-bar/axes.ts` — per-axis render functions (one per `AxisKey`).
|
||||
- `frontend/src/client/filter-bar/url-codec.ts` — `encode/decode/diffWithBase`.
|
||||
- `frontend/src/client/filter-bar/save-modal.ts` — the "Speichern als Sicht" modal.
|
||||
- `frontend/src/client/filter-bar/types.ts` — `FilterBarOpts`, `AxisKey`.
|
||||
- `frontend/src/client/filter-bar/i18n.ts` — namespace registry helper.
|
||||
|
||||
**Modified (Phase 1):**
|
||||
- `frontend/src/inbox.tsx` — replace tab row with `<div id="filter-bar">` + `<div id="filter-bar-results">`.
|
||||
- `frontend/src/client/inbox.ts` — shrink to `mountFilterBar(host, {baseFilter: inboxSystemView, axes: [...], onResult: renderListShape})`.
|
||||
- `internal/handlers/inbox.go` — add `?approval_role=` redirect from old `?tab=` for one release. (The actual rows continue to come from `/api/views/run` via the bar.)
|
||||
- `internal/services/render_spec.go` — add `RowAction` field + validator + `KnownRowActions = ["navigate", "complete_toggle", "approve", "none"]`.
|
||||
- `frontend/src/client/views/types.ts` — TS mirror of the new `RowAction` field.
|
||||
- `frontend/src/client/views/shape-list.ts` — honour `RowAction` (navigate is the existing default; `approve` mounts approve/reject buttons; `complete_toggle` mounts the checkbox).
|
||||
- `frontend/src/client/i18n.ts` + `i18n-keys.ts` — ~30 new keys under `views.bar.*`.
|
||||
- `frontend/src/styles/global.css` — bar layout + mobile rules. Reuses existing `.agenda-chip`, `.akten-multi-*`, `.frist-summary-card`, `.multi-anchor`/`.multi-panel`, `.events-view-btn` styles.
|
||||
|
||||
**Tests (Phase 1):**
|
||||
- `internal/services/render_spec_test.go` — add cases for `RowAction` validator (8 cases: each enum value + invalid + omitted + …).
|
||||
- `frontend/src/client/filter-bar/url-codec.test.ts` — round-trip encode/decode for every `AxisKey`.
|
||||
- `internal/handlers/inbox_redirect_test.go` — old-tab → new-axis redirect.
|
||||
|
||||
**Phase 2..5 file lists** are not enumerated here — each is a separate PR with its own surface refactor and follows the same shape (replace per-page chrome + URL sync, mount the bar, hand `onResult` to the existing shape components).
|
||||
|
||||
---
|
||||
|
||||
## 11. Recommended implementer
|
||||
|
||||
**Pattern-fluent Sonnet coder** is the right fit. Substrate is well-trodden:
|
||||
- Custom Views client + render shapes already exist (t-paliad-144).
|
||||
- Multi-select listbox-panel already exists (`event-types.ts`).
|
||||
- Chip-row pattern exists on `/agenda`, `/projects`, `/events`.
|
||||
- Save modal pattern exists on `/views/new` (`views-editor.ts`).
|
||||
- URL-sync pattern exists on every system page.
|
||||
|
||||
The first PR (Phase 1: /inbox + bar scaffolding + `RowAction` schema bump) is contained and reviewable in one window. Subsequent phases are smaller — they're "swap in the bar and delete page-local code".
|
||||
|
||||
I am happy to be the coder if m wants minimum context-switch — riemann has the live model of every piece of this design. Equally happy to hand off to a fresh Sonnet coder with this doc as the brief; the doc is intended to be self-contained for that path.
|
||||
|
||||
The head decides.
|
||||
|
||||
---
|
||||
|
||||
## 12. Phasing summary (no estimates, just order)
|
||||
|
||||
1. /inbox migration + `<FilterBar>` scaffolding + `RowAction` schema bump.
|
||||
2. /agenda migration.
|
||||
3. /events migration (proof point — most complex filter today, biggest LoC delta).
|
||||
4. Dashboard inline bars (Agenda + Letzte Aktivität).
|
||||
5. /views/{slug} bar overlay + "Aktualisieren" affordance.
|
||||
|
||||
Each phase is its own PR. Phases must merge in order; m's merge gate at every step.
|
||||
|
||||
---
|
||||
|
||||
## 13. Why this is worth an inventor
|
||||
|
||||
m's last line in the brainstorm: *"worth an inventor?"*. Yes — and the reason is exactly what the design doc surfaces: the substrate already exists, the schema's right, the run endpoints are shipped, and 5 SystemViews are already declared. A coder coming in cold would either (a) not realise the substrate is there and reinvent it, or (b) realise and underestimate how much per-surface chrome can collapse into one bar. The inventor's job here was to read what's there, name the bar primitive, identify /events as the proof point, propose the one schema bump (`RowAction`) that makes /inbox shippable in Phase 1, and resist designing a layout-spec system that's already covered by `RenderSpec`.
|
||||
|
||||
Stop. DESIGN READY FOR REVIEW.
|
||||
394
docs/research-determinator-coverage-2026-05-08.md
Normal file
394
docs/research-determinator-coverage-2026-05-08.md
Normal file
@@ -0,0 +1,394 @@
|
||||
# Research — Determinator coverage audit (gaps + smart-navigation framing)
|
||||
|
||||
**Author:** curie (researcher)
|
||||
**Date:** 2026-05-08
|
||||
**Task:** t-paliad-167 (Gitea m/paliad#26)
|
||||
**Mode:** read-only research; produces a gap matrix + design framing, not migrations.
|
||||
|
||||
Builds on `docs/audit-upc-rop-deadlines-2026-05-08.md` (t-paliad-159) which drove from the UPC Rules of Procedure outward. This one drives from **paliad's own corpus** outward: every active rule, every firm-wide event_type, every cascade leaf — and asks "can a Determinator user actually reach this row?"
|
||||
|
||||
m's prompt (verbatim, 2026-05-08 22:24 Determinator dogfooding):
|
||||
|
||||
> We are still missing all kinds of orders in our decision tree. What do we need to do to cover everything? Can we maybe check what "options" we have covered in our tree and which we don't? I want to have a smart way to navigate people through the tree to determine what's next.
|
||||
|
||||
---
|
||||
|
||||
## 1. Scope and method
|
||||
|
||||
**Five surfaces, three pathways.**
|
||||
|
||||
paliad currently has three independent ways to land on a deadline:
|
||||
|
||||
- **Pathway A — Fristenrechner (proceeding tree).** User picks a proceeding type (`UPC_INF`, `DE_NULL`, `EPA_OPP`, …) and a trigger date; the engine emits the entire timeline. Source: `paliad.deadline_rules` rows where the parent proceeding has `category='fristenrechner'` (19 active proceeding types).
|
||||
- **Pathway B — Determinator cascade.** User answers "what just happened?" by drilling 1-3 levels through `paliad.event_categories` (6 roots → 27 → 49 → 43 leaves; 103 leaves total). Each leaf maps to one or more `paliad.deadline_concepts` via `paliad.event_category_concepts`. Concepts then resolve to rules (`deadline_rules.concept_id`) and event_types (`deadline_concept_event_types`, mig 072).
|
||||
- **Pathway C — Trigger-event search.** Free-text `paliad.trigger_events` lookup (102 youpc-imported rows). Used by the t-paliad-086 "Was kommt nach…" mode and by autocomplete. Out of audit scope here — no Determinator surface uses it.
|
||||
|
||||
**Reachability rule.** For this audit, "reachable from the Determinator cascade" means: there exists some leaf `L` in `event_categories` such that `event_category_concepts(L → C)` and either:
|
||||
- (rule-side) `deadline_rules.concept_id = C` for the rule under test, or
|
||||
- (event_type-side) `deadline_concept_event_types(C, E)` for the event_type under test.
|
||||
|
||||
Concepts that exist but never appear in `event_category_concepts` are **dead-end concepts** — Pathway A may use them, Pathway B can't.
|
||||
|
||||
**Inventory snapshot (live youpc Supabase, 2026-05-08 22:30):**
|
||||
|
||||
| Surface | Rows | Notes |
|
||||
|---|---|---|
|
||||
| `proceeding_types` (`category='fristenrechner'`) | 19 | UPC×8, DE×5, EPA×2, EP×1, DPMA×3 |
|
||||
| `proceeding_types` (`category='litigation'`, legacy/dormant) | 7 | INF, REV, CCR, AMD, APM, APP, ZPO_CIVIL — see §2.1 |
|
||||
| `deadline_rules` active | 172 | 95 true deadlines (`duration_value > 0`), rest are anchors / court-set |
|
||||
| `deadline_rules` true deadlines, `category='fristenrechner'` only | **76** | The audit denominator |
|
||||
| `event_categories` active | 125 | 6 roots, 103 leaves |
|
||||
| `event_category_concepts` mappings | 153 | 45 distinct concepts in cascade |
|
||||
| `deadline_concepts` active | 57 | 45 in cascade, 12 dead-end |
|
||||
| `event_types` firm-wide active | 44 | 26 reachable, 18 unreachable |
|
||||
| `deadline_concept_event_types` (mig 072) | 32 rows / 25 concepts / 30 event_types | The Regel↔Typ junction |
|
||||
|
||||
**Cascade root inventory (Pathway B entry chips):**
|
||||
|
||||
| Root | Children | Leaves | Purpose |
|
||||
|---|---|---|---|
|
||||
| `cms-eingang` | gericht / gegenseite | 50 | Inbound — paper just landed |
|
||||
| `muendl-verhandlung` | geladen / gehalten / verlegt / zwischenverfahren | 4 | Hearing-pivot |
|
||||
| `beschluss-entscheidung` | (11 leaf decisions per forum) | 11 | Decision-pivot — duplicate of `cms-eingang.gericht.endentscheidung.*` |
|
||||
| `frist-verpasst` | upc / de-patg / de-zpo / epa / dpma | 5 | Wiedereinsetzung family |
|
||||
| `ich-moechte-einreichen` | klage / berufung / widerklage / spätere-schriftsätze / einspruch | 32 | Outbound — file something |
|
||||
| `sonstiges` | — | 1 (dangling, no concept) | Escape hatch |
|
||||
|
||||
**Per-forum cascade depth:** UPC has 38 reachable leaves, DE 35, EPA 11, DPMA 7. The DE corpus is now within 8% of UPC's — the imbalance flagged in earlier audits is largely closed. EPA/DPMA remain underbuilt.
|
||||
|
||||
---
|
||||
|
||||
## 2. Inventory by jurisdiction
|
||||
|
||||
Each section answers the same three questions: (a) which rules exist, (b) are they reachable from the cascade, (c) what's missing relative to a real practitioner's everyday surface area.
|
||||
|
||||
### 2.1 Legacy / dormant proceedings (out of scope but worth flagging)
|
||||
|
||||
The 7 `category='litigation'` proceedings (INF, REV, CCR, APM, AMD, APP, ZPO_CIVIL) carry **40 active rules** between them but:
|
||||
- 0 cascade references (`event_category_concepts.proceeding_type_code` never names them),
|
||||
- 0 concept_id linkage on any of their 18 true deadlines,
|
||||
- not surfaced in the Fristenrechner UI (filtered by `category='fristenrechner'` in `deadline_rule_service.go:740`).
|
||||
|
||||
These rows are zombie taxonomy from migration 008/009 — superseded by the `UPC_*` / `DE_*` / `EPA_*` / `DPMA_*` family in mig 012/042/043/044. **Recommendation:** flag them `is_active=false` in a follow-up cleanup migration; they only confuse audits.
|
||||
|
||||
The audit denominator is therefore **76 true Fristenrechner deadlines across 19 active proceedings**.
|
||||
|
||||
### 2.2 UPC
|
||||
|
||||
Most-mature jurisdiction. 8 proceedings, 40 true deadlines, 39 reachable from cascade.
|
||||
|
||||
| Proceeding | True deadlines | Reachable | Notes |
|
||||
|---|---|---|---|
|
||||
| UPC_INF | 11 | 10 | `inf.app_to_amend` (RoP.030.1, 2mo) has no concept_id — Pathway A only |
|
||||
| UPC_REV | 9 | 9 | Plus 2 duration bugs flagged in t-paliad-159 (R.49.1 3→2mo, R.52 2→1mo) |
|
||||
| UPC_PI | 0 | n/a | All 4 rules are anchors / court-set (no calendar arithmetic) |
|
||||
| UPC_APP | 5 | 5 | 3 rule_code-drift bugs flagged in t-paliad-159 (R.224.1.a, R.224.2.a, R.235.2) |
|
||||
| UPC_DAMAGES | 3 | 3 | |
|
||||
| UPC_DISCOVERY | 3 | 3 | |
|
||||
| UPC_COST_APPEAL | 1 | 1 | Tree-end leaf still missing R.155 chain |
|
||||
| UPC_APP_ORDERS | 4 | 4 | R.224.2.b grounds-on-orders missing entirely (RoP audit gap 6) |
|
||||
|
||||
**Cascade-side gaps that t-paliad-159 surfaced and remain open:**
|
||||
- R.19 Preliminary Objection (no leaf, no rule, no event_type — but `upc_preliminary_objection` event_type exists, archived from cascade)
|
||||
- R.197.3 Saisie review request, R.198/R.213 31d-or-20wd start-of-merits
|
||||
- R.262.2 Confidentiality response (14d) — daily occurrence in HLC infringement, completely absent from both pathways
|
||||
- R.333.2 Review of CMO (15d) — trigger event #16 exists, no rule, no leaf
|
||||
- R.353 Rectification (1mo) — trigger event #41 exists, no rule, no leaf
|
||||
- R.207.6.a / R.229.2 / R.71 Mängelbeseitigung — registry-correction family entirely missing
|
||||
- R.109.1 / R.109.4 / R.109.5 oral-hearing translation prep (only `before`-mode rules in the corpus)
|
||||
|
||||
### 2.3 DE (Zivilgericht + Bundesinstanzen)
|
||||
|
||||
5 proceedings, 22 true deadlines, all 22 reachable from cascade.
|
||||
|
||||
| Proceeding | True deadlines | Reachable | Cascade entry |
|
||||
|---|---|---|---|
|
||||
| DE_INF | 6 | 6 | `cms-eingang.gegenseite.de-inf.*` + `urteil-de-inf-lg` |
|
||||
| DE_NULL | 5 | 5 | `cms-eingang.gegenseite.de-null.*` + `urteil-de-null-bpatg` |
|
||||
| DE_INF_OLG | 3 | 3 | `urteil-de-inf-lg` (Berufung-Begründung) |
|
||||
| DE_INF_BGH | 5 | 5 | `urteil-de-inf-olg` (NZB / NZB-Begründung / Revisionsfrist / Revisionsbegründung) |
|
||||
| DE_NULL_BGH | 3 | 3 | `urteil-de-null-bpatg` (Berufung BGH) |
|
||||
|
||||
**Headline DE gaps (entirely uncovered by both pathways):**
|
||||
- **Hinweisbeschluss** — `cms-eingang.gericht.hinweisbeschluss` leaf exists and links to `response-to-preliminary-opinion` concept, but **no rule row computes a deadline from it**. The concept has 1 rule (`r79-further-stellungnahme`, 2mo) wired to EPA_OPP only. The DE Hinweisbeschluss deadline (4 weeks under §139 ZPO is judge-set; under § 522 ZPO Berufung-Hinweis is judge-set with min 2 weeks) is not in the rule corpus.
|
||||
- **Beweisbeschluss / Beweissicherungsanordnung (DE)** — `cms-eingang.gericht.anordnung` leaf exists but only links to `request-for-discretionary-review` (UPC R.220.3). No DE-side reaction (e.g. Stellungnahme nach Beweisaufnahme, § 411 ZPO 2-week comment on Sachverständigengutachten).
|
||||
- **Streitwertbeschluss** — neither cascade leaf nor rule. Streitwertbeschwerde is § 68 GKG, 6 months → frequent and unrepresented.
|
||||
- **Versäumnisurteil** — leaf `versaeumnisurteil` exists with concept `versaeumnisurteil-einspruch`, but the concept has 0 rules. The 2-week Einspruch deadline (§ 339 Abs. 1 ZPO) is documented in the concept text but doesn't compute. A user lands on the leaf and gets a hint card, no calendar entry.
|
||||
- **ZPO Klage as starting point** — Pathway A has a legacy `ZPO_CIVIL` proceeding (dormant per §2.1) but no live equivalent; Pathway B's `cms-eingang.gegenseite.de-inf.klageschrift` covers the *defendant*'s perspective only. A claimant entering "I just filed a Klageschrift" has no path.
|
||||
- **Schriftsatznachfristsetzung (§ 283 ZPO)** — concept `schriftsatznachreichung` exists in cascade with 0 rules; "court grants me a 3-week response window" produces no calendar entry.
|
||||
|
||||
### 2.4 EPO
|
||||
|
||||
2 active proceedings (EPA_OPP, EPA_APP) plus 1 grant-side outlier (EP_GRANT). 12 true deadlines, 8 reachable from cascade.
|
||||
|
||||
| Proceeding | True deadlines | Reachable | Notes |
|
||||
|---|---|---|---|
|
||||
| EPA_OPP | 4 | 4 | Cascade entry via `cms-eingang.gegenseite.epa-opp.einspruchsschrift` + `entscheidung-epa-opp` |
|
||||
| EPA_APP | 4 | 4 | Cascade entry via `cms-eingang.gegenseite.epa-app` + `entscheidung-epa-boa` |
|
||||
| **EP_GRANT** | **4** | **0** | All 4 unreachable — concepts (`search-report`, `publication`, `request-for-examination`, `approval-and-translation`) have no `event_category_concepts` row |
|
||||
|
||||
**EP_GRANT is the single biggest blanket-gap in the audit.** The 4 most fundamental EPO grant-side deadlines (R.70(1) examination request 6mo, Art. 93 publication, R.71(3) approval+translation 4mo, search-report 6mo) are computable in Pathway A but the cascade has zero entry points for them. A user landing on the Determinator says "EP-Anmeldung erteilt, was nun?" and finds nothing.
|
||||
|
||||
**Headline EPO gaps (both pathways):**
|
||||
- **R.71(3) communication received** — `cms-eingang.gericht.rechtsverlust-epa` covers the *negative* outcome (Rechtsverlust → Weiterbehandlung/Wiedereinsetzung) but the *positive* outcome (Mitteilung nach R.71(3) → 4-month approval+translation) has no leaf. The concept exists (`approval-and-translation`) but no leaf binds it.
|
||||
- **R.94(3) examination-stage Bescheid** — entirely absent. Most-frequent EPO deadline in prosecution practice ("4-month period to respond to examination report"); no rule, no leaf, no event_type.
|
||||
- **EPO opposition reply** — event_type `epo_opposition_reply` exists, archived from cascade (no concept link). Pathway A's EPA_OPP has the rule but no Pathway B path.
|
||||
- **R.116 EPO oral-proceedings final-submissions** — covered (`r116-final-submissions` concept, 2 rules, leaf `muendl-verhandlung.geladen` + `ich-moechte-einreichen.spaetere-schriftsaetze.r116-eingaben`).
|
||||
- **Annual renewal fees (Art. 86 EPC)** — `epo_renewal_fee` event_type exists, archived from cascade. No concept, no rule.
|
||||
|
||||
### 2.5 DPMA
|
||||
|
||||
3 active proceedings (DPMA_OPP, DPMA_BPATG_BESCHWERDE, DPMA_BGH_RB). 6 true deadlines, all 6 reachable from cascade.
|
||||
|
||||
| Proceeding | True deadlines | Reachable | Cascade entry |
|
||||
|---|---|---|---|
|
||||
| DPMA_OPP | 2 | 2 | `cms-eingang.gegenseite.dpma-opp` + `entscheidung-dpma` |
|
||||
| DPMA_BPATG_BESCHWERDE | 2 | 2 | `entscheidung-dpma` (Beschwerde) + `beschluss-bpatg-beschwerde` |
|
||||
| DPMA_BGH_RB | 2 | 2 | `beschluss-bpatg-beschwerde` (Rechtsbeschwerde) |
|
||||
|
||||
**Headline DPMA gaps (both pathways):**
|
||||
- **Beanstandungsbescheid (Prüfungsverfahren)** — DPMA examination-stage objection notice with 4-month default response window (§ 45 PatG). No rule, no leaf, no event_type. Most-frequent DPMA deadline in real practice and entirely unrepresented.
|
||||
- **Aktenversendungsbescheid / Anhörungsbescheid (Einspruchsverfahren)** — § 59 PatG opposition oral-hearing summons; no leaf.
|
||||
- **Anmeldetag-Mitteilung / Recherchenbericht (DPMA)** — `dpma_examination_request` event_type exists with concept link to `request-for-examination`, but the concept is a Pathway-A-only dead-end (not in cascade).
|
||||
- **Patenterteilungsbeschluss** — no leaf for the positive grant decision (the negative-outcome Beschluss-BPatG path covers appeals, not the grant-stage event).
|
||||
|
||||
### 2.6 Cross-cutting (procedural orders that span jurisdictions)
|
||||
|
||||
The categories m specifically called out — "court orders that aren't entry events but procedural orders." Status:
|
||||
|
||||
| Order type | UPC | DE | EPA | DPMA | Notes |
|
||||
|---|---|---|---|---|---|
|
||||
| Hinweisbeschluss / vorläufige Würdigung | concept-only | concept-only (no rule) | n/a | n/a | Leaf `cms-eingang.gericht.hinweisbeschluss` exists; the only rule wired to `response-to-preliminary-opinion` is EPA-side R.79. Judge-set period in DE/UPC; the leaf produces no calendar entry. |
|
||||
| Beweisbeschluss / Beweissicherungsanordnung | partial (R.196/R.197) | absent | n/a | n/a | Trigger events #26 / #44 / #65 / #66 exist; only R.197.3 (saisie review 30d) is missing as a rule. § 411 ZPO 2-week Stellungnahme-Frist nowhere. |
|
||||
| Streitwertbeschluss | n/a | absent | n/a | n/a | § 68 GKG 6-month Streitwertbeschwerde — common, unrepresented. |
|
||||
| Versäumnisurteil | n/a | leaf-only (no rule) | n/a | n/a | § 339 ZPO 2-week Einspruch — concept `versaeumnisurteil-einspruch` carries 0 rules. |
|
||||
| Case-Management-Order (R.220.1.c / § 273 ZPO) | partial | absent | n/a | n/a | UPC R.333.2 review-of-CMO 15d missing; trigger event #16 exists. |
|
||||
| Berichtigungsbeschluss / Tatbestandsberichtigung | absent | absent | n/a | n/a | UPC R.353 1mo / § 320 ZPO 2-week — both unrepresented. |
|
||||
| Konfidentialitätsantrag der Gegenseite | absent | n/a | n/a | n/a | UPC R.262.2 14d — high-frequency in HLC infringement work. |
|
||||
| R.71(3) communication | n/a | n/a | absent | n/a | The most-common EPO prosecution deadline. |
|
||||
| Examination-stage Bescheid | n/a | n/a | absent (R.94(3)) | absent (§ 45 PatG) | 4-month response. Single biggest *prosecution* gap. |
|
||||
| Mängelbeseitigung notification | absent (R.71/R.207.6.a/R.229.2) | absent | absent | absent | Cross-jurisdictional gap. Trigger event #71 exists for UPC. |
|
||||
| Translation lodging order | absent (R.109.5) | n/a | n/a | n/a | `before`-mode rules — schema supports, no data. |
|
||||
| Rechtsverlust-Mitteilung | n/a | n/a | leaf-only (covered) | n/a | Only EPA branch wired (`weiterbehandlung` + `wiedereinsetzung`). |
|
||||
|
||||
---
|
||||
|
||||
## 3. Cascade reachability tables
|
||||
|
||||
### 3.1 Rule reachability per proceeding
|
||||
|
||||
| Proceeding | True deadlines | No concept | Reachable | Unreachable (concept exists, not in cascade) |
|
||||
|---|---|---|---|---|
|
||||
| UPC_INF | 11 | 1 (`inf.app_to_amend`) | 10 | 0 |
|
||||
| UPC_REV | 9 | 0 | 9 | 0 |
|
||||
| UPC_APP | 5 | 0 | 5 | 0 |
|
||||
| UPC_DAMAGES | 3 | 0 | 3 | 0 |
|
||||
| UPC_DISCOVERY | 3 | 0 | 3 | 0 |
|
||||
| UPC_COST_APPEAL | 1 | 0 | 1 | 0 |
|
||||
| UPC_APP_ORDERS | 4 | 0 | 4 | 0 |
|
||||
| EP_GRANT | 4 | 0 | 0 | **4** |
|
||||
| DE_INF | 6 | 0 | 6 | 0 |
|
||||
| DE_NULL | 5 | 0 | 5 | 0 |
|
||||
| DE_INF_OLG | 3 | 0 | 3 | 0 |
|
||||
| DE_INF_BGH | 5 | 0 | 5 | 0 |
|
||||
| DE_NULL_BGH | 3 | 0 | 3 | 0 |
|
||||
| EPA_OPP | 4 | 0 | 4 | 0 |
|
||||
| EPA_APP | 4 | 0 | 4 | 0 |
|
||||
| DPMA_OPP | 2 | 0 | 2 | 0 |
|
||||
| DPMA_BPATG_BESCHWERDE | 2 | 0 | 2 | 0 |
|
||||
| DPMA_BGH_RB | 2 | 0 | 2 | 0 |
|
||||
| **Total** | **76** | **1** | **71** | **4** |
|
||||
|
||||
**Reachability rate: 71/76 = 93.4 %.** The 5 unreachable rules concentrate in two clusters:
|
||||
- `UPC_INF.inf.app_to_amend` (RoP.030.1, 2mo) — no concept_id assigned. Recommended fix: link to `defence-to-application-to-amend` or create a new `application-to-amend` concept.
|
||||
- All 4 `EP_GRANT` rules — concepts exist (`search-report`, `publication`, `request-for-examination`, `approval-and-translation`) but none has an `event_category_concepts` row. Recommended fix: add an EP-Grant subtree under either `cms-eingang.gericht` or a new `ich-moechte-einreichen.ep-grant` branch.
|
||||
|
||||
### 3.2 Event_type reachability (firm-wide active types only, n=44)
|
||||
|
||||
**Reachable via cascade (26 of 44):**
|
||||
|
||||
| Slug | Category | Jurisdiction |
|
||||
|---|---|---|
|
||||
| de_klageerwiderung | submission | DE |
|
||||
| dpma_appeal | submission | DPMA |
|
||||
| dpma_opposition | submission | DPMA |
|
||||
| epo_appeal_grounds, epo_appeal_notice, epo_opposition_filing | submission | EPO |
|
||||
| upc_application_for_cost_decision, upc_application_for_damages | submission | UPC |
|
||||
| upc_counterclaim_for_infringement, upc_counterclaim_for_revocation | submission | UPC |
|
||||
| upc_cross_appeal_2242a (×2 concepts) | submission | UPC |
|
||||
| upc_defence_to_amend_patent, upc_defence_to_revocation | submission | UPC |
|
||||
| upc_grounds_of_appeal_2242a (×2 concepts) | submission | UPC |
|
||||
| upc_protective_letter, upc_rejoinder_to_reply, upc_reply_to_defence | submission | UPC |
|
||||
| upc_reply_to_defence_to_amend_patent, upc_reply_to_defence_to_revocation | submission | UPC |
|
||||
| upc_request_to_lay_open_books | submission | UPC |
|
||||
| upc_statement_for_revocation, upc_statement_of_appeal_2201 | submission | UPC |
|
||||
| upc_statement_of_claim, upc_statement_of_defence | submission | UPC |
|
||||
| upc_statement_of_defence_no_ccr, upc_statement_of_defence_with_ccr | submission | UPC |
|
||||
|
||||
**Unreachable (18 of 44):**
|
||||
|
||||
| Slug | Category | Why unreachable |
|
||||
|---|---|---|
|
||||
| upc_decision_of_epo | decision | Concept missing, no junction row |
|
||||
| upc_decision_on_costs | decision | Junction → `cost-decision` concept; that concept is dead-end (not in cascade) |
|
||||
| upc_decision_on_merits | decision | No junction row |
|
||||
| upc_final_decision | decision | No junction row |
|
||||
| upc_oral_hearing | hearing | Junction → `oral-hearing` concept; dead-end |
|
||||
| upc_case_management_order | order | Junction → `order` concept; dead-end |
|
||||
| upc_order_lodge_translations | order | No junction row |
|
||||
| upc_summons_oral_hearing | service | No junction row |
|
||||
| upc_application_to_amend_patent | submission | No junction row (parallel to UPC_INF gap above) |
|
||||
| upc_defence_to_statement_dni, upc_statement_dni | submission | DNI family (RoP audit gap 23) — no rule, no concept, no leaf |
|
||||
| upc_grounds_of_appeal_2242b | submission | RoP audit gap 6 — R.224.2.b orders-track grounds entirely missing |
|
||||
| upc_preliminary_objection | submission | RoP audit gap 5 — R.19 entirely missing |
|
||||
| dpma_examination_request | submission | Junction → `request-for-examination`; dead-end |
|
||||
| epo_renewal_fee, contract_renewal | fee | No junction row, no concept |
|
||||
| epo_opposition_reply | submission | No junction row |
|
||||
| stellungnahme | submission | No junction row, no concept (generic catch-all) |
|
||||
|
||||
**Pattern.** The 18 unreachable types split into three groups:
|
||||
- **Court-side trigger types (8/18)**: decisions, orders, hearings, summons. The cascade is *reaction*-oriented (clicking a leaf yields "what's next") and cannot represent these as endpoints because they are themselves the entry points of reaction trees. Adding them via the `ich-moechte-einreichen` root is structurally wrong; they're not user filings. Adding them via `cms-eingang.gericht` would require an explicit "tag this incoming court event" sub-mode that the Determinator currently doesn't have.
|
||||
- **Genuinely missing UPC content (5/18)**: DNI family, R.19 PO, R.224.2.b orders-track grounds, EP-grant `application_to_amend_patent`. These are real gaps the RoP audit already named.
|
||||
- **Prosecution-side gaps (5/18)**: EPO renewal fees, R.94(3) reply, DPMA examination request, generic Stellungnahme, contract renewal. Both pathways skip prosecution; the platform is litigation-first today.
|
||||
|
||||
### 3.3 Cascade-side dangling (leaves with no concept attached)
|
||||
|
||||
3 leaves carry no concept link:
|
||||
- `cms-eingang.gericht.bescheid-mit-frist` ("Bescheid mit explizit gesetzter Frist") — intentional escape hatch but produces no calendar entry. A user lands here when no specific Bescheid type matches; without a concept, no autofill, no "I'll do the math for you."
|
||||
- `muendl-verhandlung.verlegt` — when an oral hearing is rescheduled, no follow-on deadline (correct: judge re-issues with new date).
|
||||
- `sonstiges` — top-level "Anderes" escape hatch.
|
||||
|
||||
These three leaves are the existing "not in the tree" UX — a user already CAN bottom out, but only with zero downstream support. §4 below proposes how to make those moments useful.
|
||||
|
||||
### 3.4 Concept-side dead-ends (concepts with rules but no cascade entry)
|
||||
|
||||
12 concepts have `is_active=true` and ≥1 rule attached but never appear in `event_category_concepts`:
|
||||
|
||||
| Concept | Rules | Comment |
|
||||
|---|---|---|
|
||||
| `decision` | 14 | Generic decision-anchor — used by every proceeding's `*.decision` row. Not a reaction target. |
|
||||
| `oral-hearing` | 11 | Same as decision — anchor not reaction. |
|
||||
| `publication` | 3 | EP grant publication, A1/B1 dates. |
|
||||
| `order` | 2 | Generic order-anchor. |
|
||||
| `cost-decision` | 1 | R.157 fixation-of-costs. Should arguably be reachable since post-cost-decision reactions exist (`application-for-leave-to-appeal`); the leaf `kostenfestsetzung` already maps to `notice-of-appeal` and `application-for-leave-to-appeal`, so the *reaction* path is covered — `cost-decision` itself just doesn't need to be in the cascade. |
|
||||
| `preliminary-opinion` | 1 | EPA preliminary opinion — used by EPA_OPP. |
|
||||
| `grant` | 1 | EP grant decision. |
|
||||
| `filing` | 1 | EP filing date. |
|
||||
| `search-report` | 1 | EPO search-report 6mo period. |
|
||||
| `request-for-examination` | 1 | EPO R.70(1) 6mo. |
|
||||
| `approval-and-translation` | 1 | EPO R.71(3) 4mo. |
|
||||
| `communication-r71-3` | 1 | Same family as approval-and-translation; intermediate. |
|
||||
|
||||
**Reading.** 8 of these are court-side anchors (decision, order, hearing, publication, grant, filing, search-report, preliminary-opinion) — by design not reactions, so their absence from the cascade is structurally correct. The remaining 4 are all the EP-grant family (request-for-examination, approval-and-translation, communication-r71-3, plus the implicit `publication` for EP_GRANT) — these *should* be reachable and currently aren't. Confirms §3.1's EP_GRANT cluster as the single biggest fixable cluster.
|
||||
|
||||
---
|
||||
|
||||
## 4. Smart-navigation framing — which pattern fits the gap distribution?
|
||||
|
||||
Issue §3 names three candidate patterns:
|
||||
|
||||
- **(P1) Free-text search at every cascade depth.** "Beweisbeschluss" → suggests closest leaves with a "that's not it" fallback.
|
||||
- **(P2) Persistent "Mein Ereignis ist nicht dabei" escape button.** Visible at every level → opens a manual entry form with rule-only / no-rule paths.
|
||||
- **(P3) Breadcrumb-aware "weiter unten suchen".** Flattens deeper levels into the current row's chip set when the user can't pick at the current depth.
|
||||
|
||||
The gap distribution we just enumerated tells us which pattern earns its keep. There are four kinds of "I don't see my event" moments:
|
||||
|
||||
**Type α — Real gap, content missing.** The user wants a real event paliad genuinely doesn't model (Streitwertbeschluss, R.19 PO, DPMA Beanstandungsbescheid, R.71(3), R.94(3), § 411 ZPO Stellungnahme nach Beweisaufnahme). Count: ~18-22 events from §2.6 plus the RoP audit's 25 missing. **What helps:** an escape that captures *what* the user wanted, so we can prioritise the right migration rather than guess. P2 + telemetry.
|
||||
|
||||
**Type β — Reachable but mis-modelled cascade path.** The leaf exists, the user can't find it (different mental label, deeper than expected, wrong root). E.g. R.116 final submissions live under `muendl-verhandlung.geladen` AND `ich-moechte-einreichen.spaetere-schriftsaetze.r116-eingaben`; if the user starts at `cms-eingang` they hit a dead end. Or: Wiedereinsetzung is under `frist-verpasst.*` but a user might look under `ich-moechte-einreichen.spaetere-schriftsaetze`. **What helps:** P1 (search collapses the labelling problem) and P3 (flat-search within current branch when nothing matches).
|
||||
|
||||
**Type γ — Court-side trigger event needs to be tagged, not reacted-to.** The user has a `upc_decision_on_merits` and wants to *file it as an event in their project*, not get a reaction list. The cascade doesn't model this — it always assumes "reaction wanted." Count: ~8 of the 18 unreachable event_types. **What helps:** none of P1/P2/P3 directly — this is a separate "tag, don't react" mode. Out of scope here but worth flagging.
|
||||
|
||||
**Type δ — Dead-end leaf with no concept (the 3 dangling leaves).** User selected `bescheid-mit-frist` and lands on a content-free card. **What helps:** P2's "manual entry with rule-only path" is exactly the escape these leaves need — turn the dangle into a deliberate fall-through.
|
||||
|
||||
### 4.1 Recommendation: **P2 + P1, in that order, with P3 as a stretch.**
|
||||
|
||||
**Why P2 first.** Of the four types, only Type α (real content gaps) is genuinely closed by P2, but Type α is also the *only* type that produces actionable feedback for paliad's roadmap. A persistent "Ich finde mein Ereignis nicht" button at every cascade depth, opening a `<dialog>` with:
|
||||
- a free-text "What event are you trying to file/respond to?" input,
|
||||
- a date input,
|
||||
- "kein Regelwerk verfügbar" rule-only path that creates a deadline with `event_type=null, rule_id=null, manual_due_date=...`,
|
||||
- an opt-in checkbox "Mein Hinweis hilft, paliad zu verbessern" that posts the captured text to a (future) `paliad.coverage_gaps` table,
|
||||
|
||||
…does three things at once: (a) unblocks the user immediately, (b) gives m a backlog that's *exactly* the prioritisation signal this audit can't provide alone (which gaps are real demand vs. theoretical RoP completeness), (c) repurposes the 3 dangling leaves and `sonstiges` from "looks broken" to "deliberate fall-through."
|
||||
|
||||
Implementation cost: one `<dialog>` modal reused at every depth + one new `coverage_gap` event sink + one feedback-style admin view. The button itself can hang off the existing FilterBar primitive (t-paliad-163) or attach to the bottom of every cascade list.
|
||||
|
||||
**Why P1 second.** Type β (mis-modelled paths) is the *quietest* failure mode — the user gives up before clicking anywhere relevant. Search would catch it but the gap data alone doesn't tell us how many such users exist. Layering P1 on top of P2 turns the captured "Mein Ereignis nicht dabei" texts into the very query corpus that powers fuzzy-search ranking. A search input at the top of every cascade level (`<input type="search">` filtering the current set of children + drilling into matching deeper leaves via FTS over `label_de` / `label_en` / `aliases` / linked `concept.aliases`) closes Type β cheaply once the corpus is decent.
|
||||
|
||||
**Why P3 is a stretch.** "Flatten deeper levels into current chip-set" reads cleanly but trades depth for breadth: the cascade currently has 38 reachable UPC leaves under 2-3 levels — flattening to 38 chips at depth 1 produces analysis paralysis. The cascade's depth is a feature, not a bug. P3 is only worth building if telemetry from P2 shows a cluster of users bottoming out at level 2 with the *right* root selected. Defer.
|
||||
|
||||
### 4.2 What this means for current scope
|
||||
|
||||
- **m/paliad#25 (minkowski's row-by-row)** is orthogonal — that fixes individual rule rows. Keep that going.
|
||||
- **Type α gap fill** is a separate workstream driven by the Wave 1-5 RoP-audit sequencing in `audit-upc-rop-deadlines-2026-05-08.md` §6. The smart-navigation work doesn't replace it; it gives the work a feedback loop.
|
||||
- **Type γ (tag-don't-react)** is its own design problem — file as a separate ticket if/when it shows up in P2 telemetry.
|
||||
- **The 5 unreachable rules from §3.1** (4 EP_GRANT + 1 UPC_INF) should be fixed with a 5-row migration regardless of the navigation work. Independent. EP-grant in particular is the single change that lifts cascade reachability from 93.4 % to 100 % of the audited rule corpus.
|
||||
|
||||
### 4.3 Suggested next steps (not implementation, just ordering)
|
||||
|
||||
1. **5-row reachability migration** (no design needed): link `inf.app_to_amend` to `defence-to-application-to-amend` concept; add cascade leaves for the 4 EP_GRANT concepts under a new `ich-moechte-einreichen.ep-erteilung` subtree. Wave-0 alongside the t-paliad-159 duration bug fixes.
|
||||
2. **Inventor pass on P2 + P1** as one design ticket: persistent escape button + free-text search at each level + capture-table schema + admin view. This is where m's "smart navigation" intuition lives — keep P1 and P2 as a pair so the captured texts feed search ranking.
|
||||
3. **Type α gap fill** continues independently per RoP audit waves — capture-table data in (2) refines priorities after a few weeks of real use.
|
||||
4. **Defer P3 + Type γ** until telemetry justifies them.
|
||||
|
||||
---
|
||||
|
||||
## 5. Summary
|
||||
|
||||
**Coverage today (n=76 true Fristenrechner deadlines across 19 active proceedings):**
|
||||
|
||||
| Status | Count | Share |
|
||||
|---|---|---|
|
||||
| Reachable from cascade | 71 | 93 % |
|
||||
| No concept_id | 1 | 1 % |
|
||||
| Concept exists, dead-end | 4 | 5 % |
|
||||
|
||||
**Event_type reachability (n=44 firm-wide active types):**
|
||||
|
||||
| Status | Count | Share |
|
||||
|---|---|---|
|
||||
| Reachable | 26 | 59 % |
|
||||
| Unreachable | 18 | 41 % |
|
||||
|
||||
**Headline gap categories** (entirely uncovered by both pathways, ordered by daily-practice frequency):
|
||||
|
||||
1. EPO R.94(3) examination-stage Bescheid (4mo) — most-frequent EPO prosecution deadline, **completely absent**.
|
||||
2. EPO R.71(3) communication → approval+translation (4mo) — concept exists but no cascade entry.
|
||||
3. DPMA § 45 PatG Beanstandungsbescheid (4mo) — most-frequent DPMA prosecution deadline, completely absent.
|
||||
4. UPC R.262.2 confidentiality response (14d) — high-frequency in HLC infringement.
|
||||
5. DE Hinweisbeschluss reaction — leaf exists, no rule.
|
||||
6. DE Versäumnisurteil-Einspruch (§ 339 ZPO 2 weeks) — leaf exists, no rule.
|
||||
7. DE Streitwertbeschwerde (§ 68 GKG 6mo) — neither leaf nor rule.
|
||||
8. UPC R.19 Preliminary Objection (1mo) — neither pathway.
|
||||
9. UPC R.224.2.b grounds-on-orders-track (15d) — neither pathway.
|
||||
10. UPC R.353 Rectification (1mo) — neither pathway.
|
||||
11. UPC EP-grant family (R.70(1), Art. 93, R.71(3), search-report) — Pathway A only, no cascade entry.
|
||||
12. UPC R.109 oral-hearing translation prep (1mo / 2w / 2w `before`-mode) — schema-supported, no data.
|
||||
|
||||
**Recommended smart-navigation pattern:** P2 (persistent "Ich finde mein Ereignis nicht" escape with capture) + P1 (free-text search per cascade level), in that order. P2 alone unblocks users and produces the feedback loop the rest of the gap-fill roadmap needs; P1 layered on top closes mis-labelling. P3 is over-scoped for current data.
|
||||
|
||||
---
|
||||
|
||||
## Appendix A — files consulted
|
||||
|
||||
- `internal/services/deadline_rule_service.go` (proceeding-type filtering, `category='fristenrechner'` gate)
|
||||
- `internal/services/event_category_service.go` (cascade traversal)
|
||||
- `internal/services/fristenrechner.go` (Pathway A composer)
|
||||
- `internal/db/migrations/008_seed_proceeding_types.up.sql` (legacy 7 codes)
|
||||
- `internal/db/migrations/012_fristenrechner_rules.up.sql` (UPC/DE/EPA seed)
|
||||
- `internal/db/migrations/042_de_expansion_b3.up.sql` (DE_INF_OLG / DE_INF_BGH / DE_NULL_BGH)
|
||||
- `internal/db/migrations/043_de_instance_split_proceedings.up.sql`
|
||||
- `internal/db/migrations/044_dpma_proceedings.up.sql`
|
||||
- `internal/db/migrations/045_epa_gap_fill.up.sql`
|
||||
- `internal/db/migrations/048_event_categories.up.sql` (cascade seed)
|
||||
- `internal/db/migrations/049_event_categories_seed.up.sql`
|
||||
- `internal/db/migrations/051_proceeding_display_order.up.sql`
|
||||
- `internal/db/migrations/052_event_categories_rop_audit.up.sql` (cascade-side RoP fixes)
|
||||
- `internal/db/migrations/063_frist_verpasst_upc.up.sql` (R.320 leaf)
|
||||
- `internal/db/migrations/072_deadline_concept_event_types.up.sql` (Regel↔Typ junction)
|
||||
|
||||
## Appendix B — companion audits
|
||||
|
||||
- `docs/audit-upc-rop-deadlines-2026-05-08.md` — RoP-driven UPC audit (t-paliad-159, curie). Half the data for §2.2.
|
||||
- `docs/audit-fristenrechner-completeness-2026-04-30.md` — youpc-vs-paliad (t-paliad-084, curie).
|
||||
- `docs/design-deadline-data-model-2026-05-08.md` — current data-model design.
|
||||
@@ -1,10 +1,23 @@
|
||||
import { initI18n, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { attachEventTypePicker, type PickerHandle } from "./event-types";
|
||||
import {
|
||||
attachEventTypePicker,
|
||||
eventTypeLabel,
|
||||
fetchEventTypes,
|
||||
type EventType,
|
||||
type PickerHandle,
|
||||
} from "./event-types";
|
||||
import { projectIndent } from "./project-indent";
|
||||
|
||||
let eventTypePicker: PickerHandle | null = null;
|
||||
let currentUserAdmin = false;
|
||||
let eventTypesByID = new Map<string, EventType>();
|
||||
// expandedOverride flips to true when the user clicks "Anderen Typ
|
||||
// wählen" on the collapsed inline summary. Sticky for the rest of the
|
||||
// form session — cleared only when the user reverts the rule to "Keine
|
||||
// Regel". When true, the picker stays visible regardless of whether
|
||||
// the chip matches the rule's canonical default.
|
||||
let expandedOverride = false;
|
||||
|
||||
interface Project {
|
||||
id: string;
|
||||
@@ -19,8 +32,22 @@ interface DeadlineRule {
|
||||
name: string;
|
||||
name_en: string;
|
||||
rule_code?: string;
|
||||
// t-paliad-165 — canonical event_type for this rule's concept,
|
||||
// hydrated server-side from paliad.deadline_concept_event_types.
|
||||
// Drives auto-fill of the Typ chip when the user picks this rule.
|
||||
concept_default_event_type_id?: string | null;
|
||||
}
|
||||
|
||||
// Rules indexed by id so the Regel-change handler can look up the
|
||||
// concept's canonical event_type without re-fetching.
|
||||
let rulesByID = new Map<string, DeadlineRule>();
|
||||
|
||||
// Last event_type the rule auto-filled. Tracked so we can tell whether
|
||||
// the picker still reflects the rule's suggestion (replace silently on
|
||||
// new rule pick) or whether the user has manually edited (leave alone,
|
||||
// surface the mismatch warning instead).
|
||||
let lastAutoFilledEventTypeID: string | null = null;
|
||||
|
||||
let preselectedProjectID = "";
|
||||
|
||||
function esc(s: string): string {
|
||||
@@ -71,6 +98,7 @@ async function loadRules() {
|
||||
const resp = await fetch("/api/deadline-rules");
|
||||
if (!resp.ok) return;
|
||||
const rules: DeadlineRule[] = await resp.json();
|
||||
rulesByID = new Map(rules.map((r) => [r.id, r]));
|
||||
const opts: string[] = [
|
||||
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
|
||||
];
|
||||
@@ -85,6 +113,93 @@ async function loadRules() {
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
|
||||
// picker. The two modes are mutually exclusive:
|
||||
//
|
||||
// collapsed: rule selected + canonical event_type known + picker
|
||||
// contains exactly [default] + user hasn't clicked "Anderen Typ
|
||||
// wählen". Hides the chip cluster, surfaces a single inline
|
||||
// summary "Klageerwiderung (vorgegeben durch Regel)" + an
|
||||
// override link.
|
||||
//
|
||||
// expanded: every other case — no rule, no default for the rule,
|
||||
// picker has been edited, or expandedOverride is sticky after the
|
||||
// user clicked the override link. Picker visible; mismatch warning
|
||||
// surfaces yellow when the rule expected a different event_type.
|
||||
function refreshRuleView(): void {
|
||||
const collapsed = document.getElementById("deadline-event-type-collapsed");
|
||||
const collapsedLabel = document.getElementById("deadline-event-type-collapsed-label");
|
||||
const pickerHost = document.getElementById("deadline-event-types");
|
||||
const warn = document.getElementById("deadline-event-type-rule-mismatch");
|
||||
if (!collapsed || !collapsedLabel || !pickerHost || !warn) return;
|
||||
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const expected = rule?.concept_default_event_type_id ?? null;
|
||||
const picked = eventTypePicker?.getIDs() ?? [];
|
||||
|
||||
const pickerMatchesDefault =
|
||||
expected !== null && picked.length === 1 && picked[0] === expected;
|
||||
const wantsCollapsed =
|
||||
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault;
|
||||
|
||||
if (wantsCollapsed) {
|
||||
const et = eventTypesByID.get(expected!);
|
||||
collapsedLabel.textContent = et ? eventTypeLabel(et) : "";
|
||||
collapsed.style.display = "";
|
||||
pickerHost.style.display = "none";
|
||||
warn.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
collapsed.style.display = "none";
|
||||
pickerHost.style.display = "";
|
||||
// Mismatch warning: rule expected an event_type AND the picker
|
||||
// doesn't contain it. (When the picker is empty + no override, no
|
||||
// warning — user is free to leave it blank.)
|
||||
if (expected && picked.length > 0 && !picked.includes(expected)) {
|
||||
warn.style.display = "";
|
||||
} else {
|
||||
warn.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// applyRuleAutoFill replaces the picker silently when it still reflects
|
||||
// the previous rule's suggestion (or is empty); leaves a manually-edited
|
||||
// picker alone. Called whenever the Regel select changes.
|
||||
function applyRuleAutoFill(): void {
|
||||
if (!eventTypePicker) return;
|
||||
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
|
||||
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
|
||||
const expected = rule?.concept_default_event_type_id ?? null;
|
||||
const current = eventTypePicker.getIDs();
|
||||
|
||||
// Reset the override on transition to "Keine Regel" — fresh form
|
||||
// session. Otherwise expandedOverride stays sticky.
|
||||
if (ruleID === "") {
|
||||
expandedOverride = false;
|
||||
}
|
||||
|
||||
const pickerStillReflectsLastSuggestion =
|
||||
lastAutoFilledEventTypeID !== null &&
|
||||
current.length === 1 &&
|
||||
current[0] === lastAutoFilledEventTypeID;
|
||||
const pickerIsEmpty = current.length === 0;
|
||||
|
||||
if (expected) {
|
||||
if (pickerIsEmpty || pickerStillReflectsLastSuggestion) {
|
||||
eventTypePicker.setIDs([expected]);
|
||||
lastAutoFilledEventTypeID = expected;
|
||||
}
|
||||
} else if (pickerStillReflectsLastSuggestion) {
|
||||
// New rule has no canonical event_type — clear the stale auto-fill
|
||||
// so the picker doesn't carry a chip from the old rule.
|
||||
eventTypePicker.setIDs([]);
|
||||
lastAutoFilledEventTypeID = null;
|
||||
}
|
||||
refreshRuleView();
|
||||
}
|
||||
|
||||
function initBackLinks() {
|
||||
if (preselectedProjectID) {
|
||||
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
|
||||
@@ -233,8 +348,36 @@ document.addEventListener("DOMContentLoaded", async () => {
|
||||
if (pickerHost) {
|
||||
eventTypePicker = attachEventTypePicker(pickerHost, {
|
||||
currentUserAdmin,
|
||||
onChange: () => refreshRuleView(),
|
||||
});
|
||||
}
|
||||
// t-paliad-165 follow-up — preload event_types so the collapsed
|
||||
// summary can render the type's label inline without an extra round
|
||||
// trip when the user picks a Regel.
|
||||
fetchEventTypes()
|
||||
.then((types) => {
|
||||
eventTypesByID = new Map(types.map((et) => [et.id, et]));
|
||||
refreshRuleView();
|
||||
})
|
||||
.catch(() => {/* non-fatal — collapsed view falls back to empty label */});
|
||||
// t-paliad-165 — Regel change auto-fills the Typ chip from the rule's
|
||||
// concept's canonical event_type, when the picker hasn't been
|
||||
// manually edited away from the previous rule's suggestion.
|
||||
document.getElementById("deadline-rule")?.addEventListener("change", () => {
|
||||
applyRuleAutoFill();
|
||||
});
|
||||
// "Anderen Typ wählen" — sticky expanded mode so the picker stays
|
||||
// visible even when the chip still matches the rule's default.
|
||||
document.getElementById("deadline-event-type-override-btn")?.addEventListener("click", () => {
|
||||
expandedOverride = true;
|
||||
refreshRuleView();
|
||||
// Move focus into the picker's search box so the user can type
|
||||
// immediately without an extra click.
|
||||
const search = document.querySelector<HTMLInputElement>(
|
||||
"#deadline-event-types .event-type-search",
|
||||
);
|
||||
search?.focus();
|
||||
});
|
||||
// Wire approval-hint refresh: on first render + on project change.
|
||||
void refreshApprovalHint();
|
||||
document.getElementById("deadline-project")?.addEventListener("change", () => {
|
||||
|
||||
512
frontend/src/client/filter-bar/axes.ts
Normal file
512
frontend/src/client/filter-bar/axes.ts
Normal file
@@ -0,0 +1,512 @@
|
||||
// Per-axis renderers for the FilterBar — t-paliad-163.
|
||||
//
|
||||
// Each axis is a small, self-contained render function that takes the
|
||||
// current BarState slice and a callback. The bar's mountFilterBar
|
||||
// composes them in the order declared on the surface.
|
||||
//
|
||||
// Reuses existing CSS classes wherever possible:
|
||||
// - .agenda-chip / .agenda-chip-active (chip cluster pattern)
|
||||
// - .filter-group (label + control wrapping)
|
||||
// - .akten-multi-trigger / .multi-anchor / .multi-panel
|
||||
//
|
||||
// New classes are scoped under .filter-bar-* so they don't bleed.
|
||||
|
||||
import { t, tDyn, type I18nKey } from "../i18n";
|
||||
import type { BarState, AxisKey } from "./types";
|
||||
|
||||
export interface AxisCtx {
|
||||
// Read the current value for this axis.
|
||||
get<K extends keyof BarState>(key: K): BarState[K];
|
||||
// Patch one or more axis values + trigger re-run.
|
||||
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, opts?: RenderAxisOpts): HTMLElement | null {
|
||||
switch (axis) {
|
||||
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);
|
||||
case "approval_status": return renderApprovalStatusAxis(ctx);
|
||||
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 component.
|
||||
case "deadline_event_type":
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// time — chip cluster (presets + Anpassen)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
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, 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 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 (isUnbounded) {
|
||||
ctx.patch({ time: undefined });
|
||||
} else {
|
||||
ctx.patch({ time: { horizon: preset } });
|
||||
}
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
// Custom range — placeholder chip; opens a small popover with two
|
||||
// <input type="date"> in Phase 2. For Phase 1 we render the chip
|
||||
// disabled with a tooltip so the affordance is discoverable.
|
||||
const customChip = chipBtn(t("views.bar.time.custom"), current === "custom");
|
||||
customChip.classList.add("filter-bar-chip-pending");
|
||||
customChip.title = t("views.bar.time.custom.coming_soon");
|
||||
customChip.disabled = true;
|
||||
row.appendChild(customChip);
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// personal_only — single chip (binary)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function renderPersonalOnlyAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.personal");
|
||||
const chip = chipBtn(t("views.bar.personal.on"), !!ctx.get("personal_only"));
|
||||
chip.addEventListener("click", () => {
|
||||
ctx.patch({ personal_only: !ctx.get("personal_only") });
|
||||
});
|
||||
wrap.appendChild(chip);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// approval_viewer_role — chip cluster (3 mutually exclusive)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPROVAL_ROLES: Array<{ value: NonNullable<BarState["approval_viewer_role"]>; key: I18nKey }> = [
|
||||
{ value: "approver_eligible", key: "views.bar.approval_role.approver_eligible" },
|
||||
{ value: "self_requested", key: "views.bar.approval_role.self_requested" },
|
||||
{ value: "any_visible", key: "views.bar.approval_role.any_visible" },
|
||||
];
|
||||
|
||||
function renderApprovalRoleAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.approval_role");
|
||||
const row = chipRow();
|
||||
// Default to "any_visible" so the surface lands on a populated view
|
||||
// for every user. The InboxSystemView's base spec also defaults here;
|
||||
// these two defaults must stay in sync — otherwise the chip and the
|
||||
// server narrow disagree on the empty URL.
|
||||
const current = ctx.get("approval_viewer_role") ?? "any_visible";
|
||||
for (const role of APPROVAL_ROLES) {
|
||||
const chip = chipBtn(t(role.key), role.value === current);
|
||||
chip.addEventListener("click", () => {
|
||||
ctx.patch({ approval_viewer_role: role.value });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// approval_status — chip cluster (multi-select)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPROVAL_STATUSES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "pending", key: "views.bar.approval_status.pending" },
|
||||
{ value: "approved", key: "views.bar.approval_status.approved" },
|
||||
{ value: "rejected", key: "views.bar.approval_status.rejected" },
|
||||
{ value: "revoked", key: "views.bar.approval_status.revoked" },
|
||||
];
|
||||
|
||||
function renderApprovalStatusAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.approval_status");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("approval_status")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ approval_status: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("approval_status") ?? []);
|
||||
for (const status of APPROVAL_STATUSES) {
|
||||
const chip = chipBtn(t(status.key), current.has(status.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(status.value)) current.delete(status.value);
|
||||
else current.add(status.value);
|
||||
ctx.patch({ approval_status: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// approval_entity_type — chip pair (multi-select; deadline / appointment)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPROVAL_ENTITY_TYPES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "deadline", key: "views.bar.approval_entity.deadline" },
|
||||
{ value: "appointment", key: "views.bar.approval_entity.appointment" },
|
||||
];
|
||||
|
||||
function renderApprovalEntityTypeAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.approval_entity");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("approval_entity_type")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ approval_entity_type: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("approval_entity_type") ?? []);
|
||||
for (const ent of APPROVAL_ENTITY_TYPES) {
|
||||
const chip = chipBtn(t(ent.key), current.has(ent.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(ent.value)) current.delete(ent.value);
|
||||
else current.add(ent.value);
|
||||
ctx.patch({ approval_entity_type: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// deadline_status — chip cluster (multi-select)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const DEADLINE_STATUSES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "pending", key: "views.bar.deadline_status.pending" },
|
||||
{ value: "completed", key: "views.bar.deadline_status.completed" },
|
||||
];
|
||||
|
||||
function renderDeadlineStatusAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.deadline_status");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("deadline_status")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ deadline_status: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("deadline_status") ?? []);
|
||||
for (const s of DEADLINE_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({ deadline_status: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// appointment_type — chip cluster (multi-select)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const APPOINTMENT_TYPES: Array<{ value: string; key: I18nKey }> = [
|
||||
{ value: "hearing", key: "views.bar.appointment_type.hearing" },
|
||||
{ value: "meeting", key: "views.bar.appointment_type.meeting" },
|
||||
{ value: "consultation", key: "views.bar.appointment_type.consultation" },
|
||||
{ value: "deadline_hearing", key: "views.bar.appointment_type.deadline_hearing" },
|
||||
];
|
||||
|
||||
function renderAppointmentTypeAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.appointment_type");
|
||||
const row = chipRow();
|
||||
const all = chipBtn(t("views.bar.common.all"), !ctx.get("appointment_type")?.length);
|
||||
all.addEventListener("click", () => ctx.patch({ appointment_type: undefined }));
|
||||
row.appendChild(all);
|
||||
const current = new Set(ctx.get("appointment_type") ?? []);
|
||||
for (const ty of APPOINTMENT_TYPES) {
|
||||
const chip = chipBtn(t(ty.key), current.has(ty.value));
|
||||
chip.addEventListener("click", () => {
|
||||
if (current.has(ty.value)) current.delete(ty.value);
|
||||
else current.add(ty.value);
|
||||
ctx.patch({ appointment_type: current.size ? [...current] : undefined });
|
||||
});
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
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)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const SHAPES: Array<{ value: NonNullable<BarState["shape"]>; key: I18nKey }> = [
|
||||
{ value: "list", key: "views.bar.shape.list" },
|
||||
{ value: "cards", key: "views.bar.shape.cards" },
|
||||
{ value: "calendar", key: "views.bar.shape.calendar" },
|
||||
];
|
||||
|
||||
function renderShapeAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.shape");
|
||||
const row = chipRow();
|
||||
row.classList.add("filter-bar-segment");
|
||||
const current = ctx.get("shape");
|
||||
for (const sh of SHAPES) {
|
||||
const chip = chipBtn(t(sh.key), sh.value === current);
|
||||
chip.addEventListener("click", () => ctx.patch({ shape: sh.value }));
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// density — segmented pair (comfortable / compact)
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const DENSITIES: Array<{ value: NonNullable<BarState["density"]>; key: I18nKey }> = [
|
||||
{ value: "comfortable", key: "views.bar.density.comfortable" },
|
||||
{ value: "compact", key: "views.bar.density.compact" },
|
||||
];
|
||||
|
||||
function renderDensityAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.density");
|
||||
const row = chipRow();
|
||||
row.classList.add("filter-bar-segment");
|
||||
const current = ctx.get("density") ?? "comfortable";
|
||||
for (const d of DENSITIES) {
|
||||
const chip = chipBtn(t(d.key), d.value === current);
|
||||
chip.addEventListener("click", () => ctx.patch({ density: d.value }));
|
||||
row.appendChild(chip);
|
||||
}
|
||||
wrap.appendChild(row);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// sort — small <select>
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
const SORTS: Array<{ value: NonNullable<BarState["sort"]>; key: I18nKey }> = [
|
||||
{ value: "date_asc", key: "views.bar.sort.date_asc" },
|
||||
{ value: "date_desc", key: "views.bar.sort.date_desc" },
|
||||
];
|
||||
|
||||
function renderSortAxis(ctx: AxisCtx): HTMLElement {
|
||||
const wrap = group("views.bar.label.sort");
|
||||
const sel = document.createElement("select");
|
||||
sel.className = "entity-select filter-bar-select";
|
||||
for (const s of SORTS) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = s.value;
|
||||
opt.textContent = t(s.key);
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
sel.value = ctx.get("sort") ?? "date_asc";
|
||||
sel.addEventListener("change", () => ctx.patch({ sort: sel.value as NonNullable<BarState["sort"]> }));
|
||||
wrap.appendChild(sel);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// shared helpers — group + chip + row
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
function group(labelKey: I18nKey): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "filter-group filter-bar-group";
|
||||
const label = document.createElement("span");
|
||||
label.className = "filter-bar-label";
|
||||
label.textContent = t(labelKey);
|
||||
wrap.appendChild(label);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function chipRow(): HTMLElement {
|
||||
const row = document.createElement("div");
|
||||
row.className = "filter-bar-chip-row";
|
||||
return row;
|
||||
}
|
||||
|
||||
function chipBtn(text: string, active: boolean): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "agenda-chip filter-bar-chip" + (active ? " agenda-chip-active" : "");
|
||||
btn.textContent = text;
|
||||
return btn;
|
||||
}
|
||||
341
frontend/src/client/filter-bar/index.ts
Normal file
341
frontend/src/client/filter-bar/index.ts
Normal file
@@ -0,0 +1,341 @@
|
||||
// FilterBar — the universal filter + view-mode primitive
|
||||
// (t-paliad-163). One client component every list-shaped paliad surface
|
||||
// mounts.
|
||||
//
|
||||
// Lifecycle:
|
||||
// 1. Caller hands in baseFilter + baseRender + axes + onResult.
|
||||
// 2. We parse URL params (within urlNamespace) and localStorage prefs,
|
||||
// overlay them on the base spec to compute the effective spec.
|
||||
// 3. We render the toolbar (one chip cluster / popover / select per
|
||||
// axis, plus trailing actions).
|
||||
// 4. We POST /api/views/{slug}/run with the effective spec as override
|
||||
// and hand the result + effective spec to onResult. The surface's
|
||||
// shape host renders.
|
||||
// 5. Every axis interaction patches BarState, re-encodes the URL,
|
||||
// re-runs the spec.
|
||||
//
|
||||
// The bar is a closed loop — surfaces don't see FilterSpec/RenderSpec
|
||||
// directly, just BarState diffs and the final ViewRunResult. That keeps
|
||||
// the substrate's validation invariants in one place (the bar).
|
||||
|
||||
import { onLangChange, t } from "../i18n";
|
||||
import type { FilterSpec, RenderSpec, ViewRunResult } from "../views/types";
|
||||
import {
|
||||
parseBar,
|
||||
encodeBar,
|
||||
} from "./url-codec";
|
||||
import { renderAxis, type AxisCtx, type RenderAxisOpts } from "./axes";
|
||||
import { openSaveModal } from "./save-modal";
|
||||
import type { BarState, MountOpts, BarHandle, EffectiveSpec, AxisKey } from "./types";
|
||||
|
||||
export type { MountOpts, BarHandle, AxisKey } from "./types";
|
||||
|
||||
const PREFS_PREFIX = "paliad.bar.";
|
||||
|
||||
interface PrefsBlob {
|
||||
shape?: string;
|
||||
density?: string;
|
||||
sort?: string;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Hydrate state: URL > localStorage prefs > base.
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
state = parseBar(urlParams, ns);
|
||||
hydratePrefs(state, opts.surfaceKey);
|
||||
|
||||
// Toolbar shell.
|
||||
const toolbar = document.createElement("div");
|
||||
toolbar.className = "filter-bar";
|
||||
host.appendChild(toolbar);
|
||||
|
||||
// Trailing actions: Save as view + Reset (when not suppressed).
|
||||
const showSave = opts.showSaveAsView !== false;
|
||||
|
||||
// Run + render orchestration.
|
||||
let runVersion = 0;
|
||||
let lastEffective: EffectiveSpec | null = null;
|
||||
|
||||
const runAndRender = async () => {
|
||||
const effective = computeEffective(opts.baseFilter, opts.baseRender, state);
|
||||
lastEffective = effective;
|
||||
const myVersion = ++runVersion;
|
||||
try {
|
||||
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;
|
||||
}
|
||||
if (myVersion !== runVersion) return;
|
||||
opts.onResult(result, effective);
|
||||
} catch (_e) {
|
||||
if (myVersion !== runVersion) return;
|
||||
opts.onResult({ rows: [], inaccessible_project_ids: [] }, effective);
|
||||
}
|
||||
};
|
||||
|
||||
// Axis context — all axis renderers patch state through here.
|
||||
const ctx: AxisCtx = {
|
||||
get<K extends keyof BarState>(key: K) { return state[key]; },
|
||||
patch(delta) {
|
||||
state = { ...state, ...delta };
|
||||
// Coerce empties so URL stays clean.
|
||||
for (const k of Object.keys(delta) as (keyof BarState)[]) {
|
||||
const v = state[k];
|
||||
if (Array.isArray(v) && v.length === 0) delete state[k];
|
||||
if (v === undefined || v === null || v === false) delete state[k];
|
||||
}
|
||||
// personal_only false should also be deleted (handled above as
|
||||
// falsy, but explicit for clarity).
|
||||
if (state.personal_only === false) delete state.personal_only;
|
||||
syncURL();
|
||||
syncPrefs();
|
||||
renderToolbar();
|
||||
void runAndRender();
|
||||
},
|
||||
};
|
||||
|
||||
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, axisRenderOpts);
|
||||
if (el) toolbar.appendChild(el);
|
||||
}
|
||||
if (showSave) {
|
||||
const trailing = document.createElement("div");
|
||||
trailing.className = "filter-bar-trailing";
|
||||
|
||||
const resetBtn = document.createElement("button");
|
||||
resetBtn.type = "button";
|
||||
resetBtn.className = "btn-secondary btn-small filter-bar-reset";
|
||||
resetBtn.textContent = t("views.bar.action.reset");
|
||||
resetBtn.disabled = !isDirty(state);
|
||||
resetBtn.addEventListener("click", () => handle.reset());
|
||||
trailing.appendChild(resetBtn);
|
||||
|
||||
const saveBtn = document.createElement("button");
|
||||
saveBtn.type = "button";
|
||||
saveBtn.className = "btn-primary btn-small filter-bar-save";
|
||||
saveBtn.textContent = t("views.bar.action.save_as_view");
|
||||
saveBtn.addEventListener("click", async () => {
|
||||
if (!lastEffective) return;
|
||||
const result = await openSaveModal(lastEffective.filter, lastEffective.render);
|
||||
if (result) {
|
||||
window.location.href = `/views/${encodeURIComponent(result.view.slug)}`;
|
||||
}
|
||||
});
|
||||
trailing.appendChild(saveBtn);
|
||||
|
||||
toolbar.appendChild(trailing);
|
||||
}
|
||||
};
|
||||
|
||||
const syncURL = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
encodeBar(state, params, ns);
|
||||
const qs = params.toString();
|
||||
const url = qs ? `${window.location.pathname}?${qs}` : window.location.pathname;
|
||||
history.replaceState(null, "", url);
|
||||
};
|
||||
|
||||
const syncPrefs = () => {
|
||||
const blob: PrefsBlob = {};
|
||||
if (state.shape) blob.shape = state.shape;
|
||||
if (state.density) blob.density = state.density;
|
||||
if (state.sort) blob.sort = state.sort;
|
||||
try {
|
||||
if (Object.keys(blob).length === 0) {
|
||||
localStorage.removeItem(PREFS_PREFIX + opts.surfaceKey);
|
||||
} else {
|
||||
localStorage.setItem(PREFS_PREFIX + opts.surfaceKey, JSON.stringify(blob));
|
||||
}
|
||||
} catch { /* private mode / quota — ignore */ }
|
||||
};
|
||||
|
||||
// Re-render labels on language change without losing state. The
|
||||
// existing onLangChange API is register-only (no off-handler). We
|
||||
// gate via a `destroyed` flag so a torn-down bar's callback no-ops.
|
||||
let destroyed = false;
|
||||
onLangChange(() => {
|
||||
if (destroyed) return;
|
||||
renderToolbar();
|
||||
});
|
||||
|
||||
const handle: BarHandle = {
|
||||
reset() {
|
||||
state = {};
|
||||
syncURL();
|
||||
syncPrefs();
|
||||
renderToolbar();
|
||||
void runAndRender();
|
||||
},
|
||||
async refresh() {
|
||||
await runAndRender();
|
||||
},
|
||||
getEffective() {
|
||||
if (lastEffective) return lastEffective;
|
||||
return computeEffective(opts.baseFilter, opts.baseRender, state);
|
||||
},
|
||||
destroy() {
|
||||
destroyed = true;
|
||||
toolbar.remove();
|
||||
},
|
||||
};
|
||||
|
||||
renderToolbar();
|
||||
void runAndRender();
|
||||
return handle;
|
||||
}
|
||||
|
||||
// hydratePrefs reads the saved `paliad.bar.<surfaceKey>` blob and fills
|
||||
// in render axes the URL didn't already pin. URL wins over prefs.
|
||||
function hydratePrefs(state: BarState, surfaceKey: string): void {
|
||||
let blob: PrefsBlob;
|
||||
try {
|
||||
const raw = localStorage.getItem(PREFS_PREFIX + surfaceKey);
|
||||
if (!raw) return;
|
||||
blob = JSON.parse(raw) as PrefsBlob;
|
||||
} catch { return; }
|
||||
if (!state.shape && (blob.shape === "list" || blob.shape === "cards" || blob.shape === "calendar")) {
|
||||
state.shape = blob.shape;
|
||||
}
|
||||
if (!state.density && (blob.density === "comfortable" || blob.density === "compact")) {
|
||||
state.density = blob.density;
|
||||
}
|
||||
if (!state.sort && (blob.sort === "date_asc" || blob.sort === "date_desc")) {
|
||||
state.sort = blob.sort;
|
||||
}
|
||||
}
|
||||
|
||||
// computeEffective overlays the BarState onto the base FilterSpec +
|
||||
// RenderSpec to produce the spec that gets POSTed to the substrate.
|
||||
//
|
||||
// Server-side validator (FilterSpec.Validate) is the final gate; we
|
||||
// produce shapes the validator will accept, but defer to it for the
|
||||
// hard rejection case (e.g. PersonalOnly + ScopeExplicit).
|
||||
export function computeEffective(
|
||||
base: FilterSpec,
|
||||
baseRender: RenderSpec,
|
||||
state: BarState,
|
||||
): EffectiveSpec {
|
||||
// Deep-clone to avoid mutating the caller's base. JSON round-trip is
|
||||
// fine here — every field on FilterSpec is a primitive / array /
|
||||
// object literal (no class instances, no Date, no functions).
|
||||
const filter = JSON.parse(JSON.stringify(base)) as FilterSpec;
|
||||
const render = JSON.parse(JSON.stringify(baseRender)) as RenderSpec;
|
||||
|
||||
if (state.time) {
|
||||
filter.time = {
|
||||
...filter.time,
|
||||
horizon: state.time.horizon,
|
||||
from: state.time.horizon === "custom" ? state.time.from : undefined,
|
||||
to: state.time.horizon === "custom" ? state.time.to : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (state.project) {
|
||||
if (state.project.mode === "personal") {
|
||||
filter.scope = {
|
||||
...filter.scope,
|
||||
personal_only: true,
|
||||
// When personal_only takes over, leave projects on the base
|
||||
// mode (typically all_visible). Validator rejects ScopeExplicit
|
||||
// + personal_only so we don't overwrite the mode here.
|
||||
};
|
||||
} else if (state.project.id) {
|
||||
filter.scope = {
|
||||
...filter.scope,
|
||||
projects: { mode: "explicit", ids: [state.project.id] },
|
||||
};
|
||||
}
|
||||
}
|
||||
if (state.personal_only) {
|
||||
filter.scope = { ...filter.scope, personal_only: true };
|
||||
}
|
||||
|
||||
// Per-source predicates. Build the predicates map idempotently;
|
||||
// never inject a predicate for a source the spec doesn't list.
|
||||
const sources = new Set(filter.sources);
|
||||
filter.predicates = filter.predicates ?? {};
|
||||
|
||||
if (sources.has("deadline") && (state.deadline_status || state.deadline_event_type)) {
|
||||
const cur = filter.predicates.deadline ?? {};
|
||||
const next = { ...cur };
|
||||
if (state.deadline_status) next.status = state.deadline_status;
|
||||
if (state.deadline_event_type) {
|
||||
next.event_types = state.deadline_event_type.ids;
|
||||
next.include_untyped = state.deadline_event_type.include_untyped;
|
||||
}
|
||||
filter.predicates.deadline = next;
|
||||
}
|
||||
if (sources.has("appointment") && state.appointment_type) {
|
||||
const cur = filter.predicates.appointment ?? {};
|
||||
filter.predicates.appointment = { ...cur, appointment_types: state.appointment_type };
|
||||
}
|
||||
if (sources.has("approval_request") && (state.approval_viewer_role || state.approval_status || state.approval_entity_type)) {
|
||||
const cur = filter.predicates.approval_request ?? {};
|
||||
const next = { ...cur };
|
||||
if (state.approval_viewer_role) next.viewer_role = state.approval_viewer_role;
|
||||
if (state.approval_status) next.status = state.approval_status;
|
||||
if (state.approval_entity_type) next.entity_types = state.approval_entity_type;
|
||||
filter.predicates.approval_request = next;
|
||||
}
|
||||
if (sources.has("project_event") && state.project_event_kind) {
|
||||
const cur = filter.predicates.project_event ?? {};
|
||||
filter.predicates.project_event = { ...cur, event_types: state.project_event_kind };
|
||||
}
|
||||
|
||||
// Render overlays.
|
||||
if (state.shape) render.shape = state.shape;
|
||||
if (state.sort) {
|
||||
if (render.shape === "list" || (state.shape === "list" && !render.list)) {
|
||||
render.list = { ...(render.list ?? {}), sort: state.sort };
|
||||
}
|
||||
if (render.shape === "cards" || state.shape === "cards") {
|
||||
render.cards = { ...(render.cards ?? {}), sort: state.sort };
|
||||
}
|
||||
}
|
||||
if (state.density && (render.shape === "list" || state.shape === "list")) {
|
||||
render.list = { ...(render.list ?? {}), density: state.density };
|
||||
}
|
||||
|
||||
return { filter, render };
|
||||
}
|
||||
|
||||
// isDirty — used to enable the Reset button only when there's something
|
||||
// to reset to.
|
||||
function isDirty(state: BarState): boolean {
|
||||
for (const k of Object.keys(state) as (keyof BarState)[]) {
|
||||
const v = state[k];
|
||||
if (v === undefined || v === null || v === false) continue;
|
||||
if (Array.isArray(v) && v.length === 0) continue;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
146
frontend/src/client/filter-bar/save-modal.ts
Normal file
146
frontend/src/client/filter-bar/save-modal.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
// Save-as-view modal for the FilterBar. Mirrors the create form on
|
||||
// /views/new (frontend/src/client/views-editor.ts:168) but as a modal
|
||||
// so the user can save the bar's current effective spec without
|
||||
// leaving the page they're filtering on.
|
||||
//
|
||||
// On success, the new view appears in the "Meine Sichten" sidebar
|
||||
// group on next render (the sidebar polls /api/user-views on init).
|
||||
|
||||
import { t } from "../i18n";
|
||||
import type { FilterSpec, RenderSpec, UserView } from "../views/types";
|
||||
|
||||
export interface SaveModalResult {
|
||||
view: UserView;
|
||||
}
|
||||
|
||||
const SLUG_REGEX = /^[a-z0-9][a-z0-9-]{0,62}$/;
|
||||
|
||||
export function openSaveModal(filter: FilterSpec, render: RenderSpec): Promise<SaveModalResult | null> {
|
||||
return new Promise((resolve) => {
|
||||
const dialog = document.createElement("dialog");
|
||||
dialog.className = "filter-bar-save-modal";
|
||||
|
||||
dialog.innerHTML = `
|
||||
<form method="dialog" class="filter-bar-save-form">
|
||||
<h2>${t("views.bar.save.heading")}</h2>
|
||||
<label class="filter-bar-save-field">
|
||||
<span>${t("views.bar.save.field.name")}</span>
|
||||
<input type="text" name="name" required maxlength="100" autocomplete="off" />
|
||||
</label>
|
||||
<label class="filter-bar-save-field">
|
||||
<span>${t("views.bar.save.field.slug")}</span>
|
||||
<input type="text" name="slug" required maxlength="63" autocomplete="off" pattern="[a-z0-9][a-z0-9-]*" />
|
||||
<small>${t("views.bar.save.field.slug_hint")}</small>
|
||||
</label>
|
||||
<label class="filter-bar-save-checkbox">
|
||||
<input type="checkbox" name="show_count" />
|
||||
<span>${t("views.bar.save.field.show_count")}</span>
|
||||
</label>
|
||||
<p class="filter-bar-save-error" hidden></p>
|
||||
<div class="filter-bar-save-actions">
|
||||
<button type="button" class="btn-secondary" data-action="cancel">${t("views.bar.save.cancel")}</button>
|
||||
<button type="submit" class="btn-primary">${t("views.bar.save.confirm")}</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
|
||||
document.body.appendChild(dialog);
|
||||
|
||||
const form = dialog.querySelector<HTMLFormElement>(".filter-bar-save-form")!;
|
||||
const errorEl = dialog.querySelector<HTMLParagraphElement>(".filter-bar-save-error")!;
|
||||
const nameInput = form.elements.namedItem("name") as HTMLInputElement;
|
||||
const slugInput = form.elements.namedItem("slug") as HTMLInputElement;
|
||||
const showCount = form.elements.namedItem("show_count") as HTMLInputElement;
|
||||
const cancelBtn = dialog.querySelector<HTMLButtonElement>('[data-action="cancel"]')!;
|
||||
|
||||
// Auto-derive slug from name as the user types — but only until
|
||||
// they touch the slug field manually.
|
||||
let slugDirty = false;
|
||||
nameInput.addEventListener("input", () => {
|
||||
if (!slugDirty) slugInput.value = derivedSlug(nameInput.value);
|
||||
});
|
||||
slugInput.addEventListener("input", () => { slugDirty = true; });
|
||||
|
||||
const cleanup = () => {
|
||||
dialog.close();
|
||||
dialog.remove();
|
||||
};
|
||||
|
||||
cancelBtn.addEventListener("click", () => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
errorEl.hidden = true;
|
||||
errorEl.textContent = "";
|
||||
|
||||
const name = nameInput.value.trim();
|
||||
const slug = slugInput.value.trim();
|
||||
if (!name) {
|
||||
showError(errorEl, t("views.bar.save.error.name_required"));
|
||||
return;
|
||||
}
|
||||
if (!SLUG_REGEX.test(slug)) {
|
||||
showError(errorEl, t("views.bar.save.error.slug_format"));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
slug,
|
||||
filter_spec: filter,
|
||||
render_spec: render,
|
||||
show_count: showCount.checked,
|
||||
};
|
||||
try {
|
||||
const r = await fetch("/api/user-views", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
if (r.status === 409) {
|
||||
showError(errorEl, t("views.bar.save.error.slug_taken"));
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({} as { error?: string }));
|
||||
showError(errorEl, body.error || `${r.status}: ${r.statusText}`);
|
||||
return;
|
||||
}
|
||||
const view = (await r.json()) as UserView;
|
||||
cleanup();
|
||||
resolve({ view });
|
||||
} catch (_e) {
|
||||
showError(errorEl, t("views.bar.save.error.network"));
|
||||
}
|
||||
});
|
||||
|
||||
dialog.addEventListener("cancel", () => {
|
||||
cleanup();
|
||||
resolve(null);
|
||||
});
|
||||
|
||||
dialog.showModal();
|
||||
nameInput.focus();
|
||||
});
|
||||
}
|
||||
|
||||
function showError(el: HTMLElement, msg: string): void {
|
||||
el.textContent = msg;
|
||||
el.hidden = false;
|
||||
}
|
||||
|
||||
function derivedSlug(name: string): string {
|
||||
return name
|
||||
.toLowerCase()
|
||||
.replace(/[äÄ]/g, "ae")
|
||||
.replace(/[öÖ]/g, "oe")
|
||||
.replace(/[üÜ]/g, "ue")
|
||||
.replace(/[ß]/g, "ss")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 63);
|
||||
}
|
||||
153
frontend/src/client/filter-bar/types.ts
Normal file
153
frontend/src/client/filter-bar/types.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
// FilterBar types — t-paliad-163. Mirrors the Go FilterSpec/RenderSpec
|
||||
// shapes from internal/services/{filter_spec,render_spec}.go via
|
||||
// client/views/types.ts. The FilterBar is the universal frontend
|
||||
// primitive that consumes a base FilterSpec + RenderSpec, declares
|
||||
// which axes the surface supports, and emits diffs back through
|
||||
// onResult after running the spec via /api/views/run.
|
||||
|
||||
import type { FilterSpec, RenderSpec, RenderShape, ViewRunResult, ListRowAction } from "../views/types";
|
||||
|
||||
// AxisKey — every filter dimension the bar can render. Declared per
|
||||
// surface in mountFilterBar's `axes` array. See design §3.1 for the
|
||||
// universal-vs-per-surface split.
|
||||
export type AxisKey =
|
||||
| "time"
|
||||
| "project"
|
||||
| "personal_only"
|
||||
| "deadline_status"
|
||||
| "deadline_event_type"
|
||||
| "appointment_type"
|
||||
| "approval_viewer_role"
|
||||
| "approval_status"
|
||||
| "approval_entity_type"
|
||||
| "project_event_kind"
|
||||
| "timeline_status"
|
||||
| "timeline_track"
|
||||
| "shape"
|
||||
| "sort"
|
||||
| "density";
|
||||
|
||||
// Effective spec — the result of overlaying URL + localStorage prefs
|
||||
// on top of the base spec. Handed back to onResult so the surface can
|
||||
// dispatch into the matching shape renderer with the right config.
|
||||
export interface EffectiveSpec {
|
||||
filter: FilterSpec;
|
||||
render: RenderSpec;
|
||||
}
|
||||
|
||||
// Per-axis state — what the URL codec round-trips. Each axis's value
|
||||
// type is bounded to the FilterSpec/RenderSpec subset it touches.
|
||||
export interface BarState {
|
||||
// Universal
|
||||
time?: TimeOverlay;
|
||||
project?: ProjectOverlay;
|
||||
personal_only?: boolean;
|
||||
|
||||
// Per-source
|
||||
deadline_status?: string[];
|
||||
deadline_event_type?: { ids: string[]; include_untyped: boolean };
|
||||
appointment_type?: string[];
|
||||
approval_viewer_role?: "approver_eligible" | "self_requested" | "any_visible";
|
||||
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;
|
||||
sort?: "date_asc" | "date_desc";
|
||||
density?: "comfortable" | "compact";
|
||||
}
|
||||
|
||||
export interface TimeOverlay {
|
||||
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;
|
||||
}
|
||||
|
||||
export interface ProjectOverlay {
|
||||
// The bar's project chip is single-select today; Phase C upgrades
|
||||
// to multi-select. "personal" is a sentinel — the legacy /events
|
||||
// contract reserved this name, we keep it so old bookmarks still
|
||||
// resolve to the right state.
|
||||
mode: "single" | "personal";
|
||||
id?: string;
|
||||
}
|
||||
|
||||
// MountOpts — the public API.
|
||||
export interface MountOpts {
|
||||
// Base spec. Usually a SystemView's FilterSpec+RenderSpec, fetched
|
||||
// from /api/views/system on the surface and passed in here. For
|
||||
// /views/{slug}, the saved user-view's spec.
|
||||
baseFilter: FilterSpec;
|
||||
baseRender: RenderSpec;
|
||||
|
||||
// Which axes the surface exposes. Order is preserved in the rendered
|
||||
// chrome — surfaces use this to control left-to-right grouping.
|
||||
axes: AxisKey[];
|
||||
|
||||
// URL parameter namespace. When set, every URL key is prefixed
|
||||
// (`?<ns>_time=`, `?<ns>_project=`, …). Used when two bars share a
|
||||
// page (dashboard inline lists). Defaults to no prefix.
|
||||
urlNamespace?: string;
|
||||
|
||||
// Surface key for localStorage prefs (density, default shape).
|
||||
// Required so two surfaces don't share preferences.
|
||||
surfaceKey: string;
|
||||
|
||||
// Whether to render "Speichern als Sicht" + "Zurücksetzen"
|
||||
// trailing actions. Defaults to true. Set false on the dashboard
|
||||
// inline bars (per design Q6).
|
||||
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
|
||||
// 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.
|
||||
// Set on /views/{slug} where the user is viewing a saved view.
|
||||
userViewId?: string;
|
||||
|
||||
// Called every time the spec changes (mount, URL change, axis
|
||||
// interaction). The surface dispatches to the matching shape
|
||||
// renderer with the rows from /api/views/{slug}/run.
|
||||
onResult(result: ViewRunResult, effective: EffectiveSpec): void;
|
||||
|
||||
// Optional — surface-specific row-action override. Phase 1: /inbox
|
||||
// pins this to "approve"; /events Phase 3 pins to "complete_toggle".
|
||||
// Future: sourced from the spec's render.list.row_action when set.
|
||||
rowAction?: ListRowAction;
|
||||
}
|
||||
|
||||
// Bar handle — what mountFilterBar returns. Pages can call .reset()
|
||||
// from page-level controls (e.g. an empty-state "Filter zurücksetzen"
|
||||
// button), or .destroy() if the page tears down.
|
||||
export interface BarHandle {
|
||||
reset(): void;
|
||||
refresh(): Promise<void>;
|
||||
destroy(): void;
|
||||
// Read-only effective spec at this moment (post URL + localStorage
|
||||
// overlay). Pages use this to construct deep-link URLs etc.
|
||||
getEffective(): EffectiveSpec;
|
||||
}
|
||||
102
frontend/src/client/filter-bar/url-codec.test.ts
Normal file
102
frontend/src/client/filter-bar/url-codec.test.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
// Unit tests for the FilterBar URL codec. Round-trip discipline:
|
||||
// every BarState shape parseBar produces must encode back to the same
|
||||
// URL params, and vice versa. Run with `bun test`.
|
||||
|
||||
import { test, expect, describe } from "bun:test";
|
||||
import { parseBar, encodeBar } from "./url-codec";
|
||||
import type { BarState } from "./types";
|
||||
|
||||
function roundTrip(state: BarState, ns?: string): BarState {
|
||||
const params = new URLSearchParams();
|
||||
encodeBar(state, params, ns);
|
||||
return parseBar(params, ns);
|
||||
}
|
||||
|
||||
describe("filter-bar/url-codec", () => {
|
||||
test("empty state round-trips to empty", () => {
|
||||
expect(roundTrip({})).toEqual({});
|
||||
});
|
||||
|
||||
test("time horizon round-trips", () => {
|
||||
for (const h of ["next_7d", "next_30d", "next_90d", "past_30d", "past_90d", "any", "all"] as const) {
|
||||
expect(roundTrip({ time: { horizon: h } })).toEqual({ time: { horizon: h } });
|
||||
}
|
||||
});
|
||||
|
||||
test("custom time horizon round-trips with from + to", () => {
|
||||
const state: BarState = { time: { horizon: "custom", from: "2026-01-01", to: "2026-12-31" } };
|
||||
expect(roundTrip(state)).toEqual(state);
|
||||
});
|
||||
|
||||
test("project sentinel + uuid round-trip", () => {
|
||||
expect(roundTrip({ project: { mode: "personal" } })).toEqual({ project: { mode: "personal" } });
|
||||
expect(roundTrip({ project: { mode: "single", id: "11111111-1111-1111-1111-111111111111" } }))
|
||||
.toEqual({ project: { mode: "single", id: "11111111-1111-1111-1111-111111111111" } });
|
||||
});
|
||||
|
||||
test("personal_only flag round-trips", () => {
|
||||
expect(roundTrip({ personal_only: true })).toEqual({ personal_only: true });
|
||||
expect(roundTrip({})).toEqual({});
|
||||
});
|
||||
|
||||
test("deadline_event_type honours legacy 'none' sentinel", () => {
|
||||
const state: BarState = { deadline_event_type: { ids: ["a", "b"], include_untyped: true } };
|
||||
expect(roundTrip(state)).toEqual(state);
|
||||
const state2: BarState = { deadline_event_type: { ids: [], include_untyped: true } };
|
||||
expect(roundTrip(state2)).toEqual(state2);
|
||||
const state3: BarState = { deadline_event_type: { ids: ["a"], include_untyped: false } };
|
||||
expect(roundTrip(state3)).toEqual(state3);
|
||||
});
|
||||
|
||||
test("approval_request triple round-trips together", () => {
|
||||
const state: BarState = {
|
||||
approval_viewer_role: "approver_eligible",
|
||||
approval_status: ["pending", "approved"],
|
||||
approval_entity_type: ["deadline"],
|
||||
};
|
||||
expect(roundTrip(state)).toEqual(state);
|
||||
});
|
||||
|
||||
test("namespace prefix isolates two bars on the same page", () => {
|
||||
const a: BarState = { time: { horizon: "next_7d" } };
|
||||
const b: BarState = { time: { horizon: "next_30d" } };
|
||||
const params = new URLSearchParams();
|
||||
encodeBar(a, params, "agenda");
|
||||
encodeBar(b, params, "activity");
|
||||
expect(parseBar(params, "agenda")).toEqual(a);
|
||||
expect(parseBar(params, "activity")).toEqual(b);
|
||||
// Without namespace neither bar's keys are visible.
|
||||
expect(parseBar(params)).toEqual({});
|
||||
});
|
||||
|
||||
test("render axes round-trip", () => {
|
||||
const state: BarState = { shape: "cards", sort: "date_desc", density: "compact" };
|
||||
expect(roundTrip(state)).toEqual(state);
|
||||
});
|
||||
|
||||
test("encode is idempotent — re-encoding same state replaces, doesn't accumulate", () => {
|
||||
const state: BarState = { time: { horizon: "next_7d" }, deadline_status: ["pending"] };
|
||||
const params = new URLSearchParams();
|
||||
encodeBar(state, params);
|
||||
encodeBar(state, params);
|
||||
expect(params.get("d_status")).toBe("pending");
|
||||
// Only one entry per key.
|
||||
expect(params.getAll("d_status")).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("encode replaces stale keys when state shrinks", () => {
|
||||
const params = new URLSearchParams();
|
||||
encodeBar({ deadline_status: ["pending"], approval_viewer_role: "self_requested" }, params);
|
||||
encodeBar({ deadline_status: ["completed"] }, params);
|
||||
expect(params.get("d_status")).toBe("completed");
|
||||
expect(params.has("a_role")).toBe(false);
|
||||
});
|
||||
|
||||
test("parse drops unknown enum values silently (forward-compat)", () => {
|
||||
const params = new URLSearchParams();
|
||||
params.set("a_role", "future_role_we_dont_know_yet");
|
||||
params.set("shape", "kanban");
|
||||
params.set("density", "huge");
|
||||
expect(parseBar(params)).toEqual({});
|
||||
});
|
||||
});
|
||||
198
frontend/src/client/filter-bar/url-codec.ts
Normal file
198
frontend/src/client/filter-bar/url-codec.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
// FilterBar URL codec — t-paliad-163. Encodes BarState ↔ URL
|
||||
// parameters with optional namespace prefix (?<ns>_<key>=).
|
||||
//
|
||||
// The bar treats the URL as canonical for everything that affects
|
||||
// which rows you see. Round-trip discipline: anything written by
|
||||
// encodeBar must parse back identically via parseBar so deep-links
|
||||
// and refresh both yield the same effective spec.
|
||||
//
|
||||
// Empty / default values are NOT written — the URL stays clean for
|
||||
// users who don't tweak. The page's base spec is the implicit baseline.
|
||||
|
||||
import type { BarState, TimeOverlay, ProjectOverlay } from "./types";
|
||||
|
||||
const PERSONAL_PROJECT_SENTINEL = "personal";
|
||||
|
||||
// parseBar reads URL params into a BarState. Unknown values are
|
||||
// dropped silently (forward-compat with future axes).
|
||||
export function parseBar(params: URLSearchParams, ns?: string): BarState {
|
||||
const k = (key: string) => (ns ? `${ns}_${key}` : key);
|
||||
const out: BarState = {};
|
||||
|
||||
// time
|
||||
const time = params.get(k("time"));
|
||||
if (time) {
|
||||
const horizon = parseHorizon(time);
|
||||
if (horizon) {
|
||||
const overlay: TimeOverlay = { horizon };
|
||||
if (horizon === "custom") {
|
||||
const from = params.get(k("from"));
|
||||
const to = params.get(k("to"));
|
||||
if (from) overlay.from = from;
|
||||
if (to) overlay.to = to;
|
||||
}
|
||||
out.time = overlay;
|
||||
}
|
||||
}
|
||||
|
||||
// project
|
||||
const project = params.get(k("project"));
|
||||
if (project) {
|
||||
if (project === PERSONAL_PROJECT_SENTINEL) {
|
||||
out.project = { mode: "personal" };
|
||||
} else {
|
||||
out.project = { mode: "single", id: project };
|
||||
}
|
||||
}
|
||||
|
||||
// personal_only
|
||||
if (params.get(k("personal")) === "1") {
|
||||
out.personal_only = true;
|
||||
}
|
||||
|
||||
// deadline.status
|
||||
const dStatus = params.get(k("d_status"));
|
||||
if (dStatus) out.deadline_status = parseCSV(dStatus);
|
||||
|
||||
// deadline.event_types — preserves the legacy /events contract
|
||||
// where "none" inside the CSV means include_untyped=true.
|
||||
const dEvent = params.get(k("d_event_type"));
|
||||
if (dEvent) {
|
||||
const tokens = parseCSV(dEvent);
|
||||
const ids: string[] = [];
|
||||
let untyped = false;
|
||||
for (const tok of tokens) {
|
||||
if (tok === "none") untyped = true;
|
||||
else ids.push(tok);
|
||||
}
|
||||
out.deadline_event_type = { ids, include_untyped: untyped };
|
||||
}
|
||||
|
||||
// appointment.types
|
||||
const appType = params.get(k("app_type"));
|
||||
if (appType) out.appointment_type = parseCSV(appType);
|
||||
|
||||
// approval_request.viewer_role
|
||||
const aRole = params.get(k("a_role"));
|
||||
if (aRole === "approver_eligible" || aRole === "self_requested" || aRole === "any_visible") {
|
||||
out.approval_viewer_role = aRole;
|
||||
}
|
||||
|
||||
// approval_request.status
|
||||
const aStatus = params.get(k("a_status"));
|
||||
if (aStatus) out.approval_status = parseCSV(aStatus);
|
||||
|
||||
// approval_request.entity_types
|
||||
const aEntity = params.get(k("a_entity_type"));
|
||||
if (aEntity) out.approval_entity_type = parseCSV(aEntity);
|
||||
|
||||
// project_event.event_types
|
||||
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;
|
||||
|
||||
// render.list.sort / render.cards.sort — the bar treats sort as one axis
|
||||
const sort = params.get(k("sort"));
|
||||
if (sort === "date_asc" || sort === "date_desc") out.sort = sort;
|
||||
|
||||
// render.list.density
|
||||
const density = params.get(k("density"));
|
||||
if (density === "comfortable" || density === "compact") out.density = density;
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
// encodeBar writes BarState back into URL params, mutating the
|
||||
// passed-in URLSearchParams. Empty / undefined values are omitted.
|
||||
// The caller controls how the result is applied (history.replaceState
|
||||
// with the page pathname unchanged).
|
||||
export function encodeBar(state: BarState, params: URLSearchParams, ns?: string): void {
|
||||
const k = (key: string) => (ns ? `${ns}_${key}` : key);
|
||||
|
||||
// Clear every key the bar owns first, then re-write the non-empty ones.
|
||||
for (const key of [
|
||||
"time", "from", "to", "project", "personal",
|
||||
"d_status", "d_event_type",
|
||||
"app_type",
|
||||
"a_role", "a_status", "a_entity_type",
|
||||
"pe_kind",
|
||||
"tl_status", "tl_track",
|
||||
"shape", "sort", "density",
|
||||
]) {
|
||||
params.delete(k(key));
|
||||
}
|
||||
|
||||
if (state.time) {
|
||||
params.set(k("time"), state.time.horizon);
|
||||
if (state.time.horizon === "custom") {
|
||||
if (state.time.from) params.set(k("from"), state.time.from);
|
||||
if (state.time.to) params.set(k("to"), state.time.to);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.project) {
|
||||
if (state.project.mode === "personal") {
|
||||
params.set(k("project"), PERSONAL_PROJECT_SENTINEL);
|
||||
} else if (state.project.id) {
|
||||
params.set(k("project"), state.project.id);
|
||||
}
|
||||
}
|
||||
|
||||
if (state.personal_only) params.set(k("personal"), "1");
|
||||
|
||||
if (state.deadline_status?.length) params.set(k("d_status"), state.deadline_status.join(","));
|
||||
|
||||
if (state.deadline_event_type) {
|
||||
const parts = [...state.deadline_event_type.ids];
|
||||
if (state.deadline_event_type.include_untyped) parts.push("none");
|
||||
if (parts.length) params.set(k("d_event_type"), parts.join(","));
|
||||
}
|
||||
|
||||
if (state.appointment_type?.length) params.set(k("app_type"), state.appointment_type.join(","));
|
||||
if (state.approval_viewer_role) params.set(k("a_role"), state.approval_viewer_role);
|
||||
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);
|
||||
if (state.density) params.set(k("density"), state.density);
|
||||
}
|
||||
|
||||
function parseHorizon(s: string): TimeOverlay["horizon"] | null {
|
||||
switch (s) {
|
||||
case "next_7d":
|
||||
case "next_30d":
|
||||
case "next_90d":
|
||||
case "past_7d":
|
||||
case "past_30d":
|
||||
case "past_90d":
|
||||
case "any":
|
||||
case "all":
|
||||
case "custom":
|
||||
return s;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseCSV(s: string): string[] {
|
||||
return s.split(",").map((x) => x.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
export { PERSONAL_PROJECT_SENTINEL };
|
||||
|
||||
// Re-exported so consumers don't need to import ProjectOverlay just
|
||||
// to construct one in tests.
|
||||
export type { ProjectOverlay };
|
||||
@@ -288,6 +288,12 @@ interface ProjectOption {
|
||||
// (Slice 3b) can scope the cascade by the project's jurisdiction
|
||||
// without an extra fetch.
|
||||
proceeding_type_id?: number | null;
|
||||
// our_side carries which side the firm represents on this project
|
||||
// (t-paliad-164). When a user selects an Akte, the perspective chip
|
||||
// pre-locks to this value; a small hint above the strip flags the
|
||||
// pre-selection and the user can still click another chip to
|
||||
// override. NULL/undefined leaves the chip unset (free-pick).
|
||||
our_side?: "claimant" | "defendant" | "court" | "both" | null;
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
@@ -2484,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 {
|
||||
@@ -2530,6 +2540,11 @@ function selectProject(project: ProjectOption) {
|
||||
writeStep1ContextToURL(currentStep1Context);
|
||||
renderStep1Summary();
|
||||
showStep2Card();
|
||||
// t-paliad-164: project.our_side predefines the perspective chip.
|
||||
// Only fires when the user hasn't already locked a perspective via
|
||||
// ?role= in the URL — the URL pick wins because it represents an
|
||||
// explicit choice (chip click or shared link).
|
||||
applyOurSidePredefine(project, /* replaceURL */ false);
|
||||
// Slice 3b: project's proceeding type narrows the B1 cascade if the
|
||||
// user reaches it via Step 2 → Etwas ist passiert. Refresh here so
|
||||
// a cascade already on screen (rare but possible via popstate) picks
|
||||
@@ -2551,6 +2566,12 @@ function clearStep1Context() {
|
||||
renderStep1Summary();
|
||||
hideStep2Card();
|
||||
triggerCascadeRefresh();
|
||||
// t-paliad-164: hint dies with the project context. We deliberately
|
||||
// leave the perspective chip itself alone — the user may want to
|
||||
// keep their pick when returning to Step 1; we only clear the
|
||||
// "vorgegeben durch Akte" annotation since there's no Akte anymore.
|
||||
const hint = document.getElementById("fristen-perspective-hint");
|
||||
if (hint) hint.hidden = true;
|
||||
}
|
||||
|
||||
function renderStep1Summary() {
|
||||
@@ -2626,6 +2647,10 @@ function initPathwayFork() {
|
||||
if (currentStep1Context.kind === "project" && currentStep1Context.projectId) {
|
||||
currentStep1Context.project = cachedAkten.find((p) => p.id === currentStep1Context.projectId);
|
||||
renderStep1Summary();
|
||||
// t-paliad-164: deep-link / refresh path. project loaded async, so
|
||||
// the predefine has to wait for cachedAkten. replace=true keeps
|
||||
// the URL clean — the user didn't navigate, they just refreshed.
|
||||
applyOurSidePredefine(currentStep1Context.project, /* replaceURL */ true);
|
||||
}
|
||||
renderAkteList("");
|
||||
// Cascade may already be on screen if the user landed with
|
||||
@@ -2657,6 +2682,11 @@ function initPathwayFork() {
|
||||
const next: Perspective = isClear ? null : ((chip.dataset.perspective as Perspective) ?? null);
|
||||
writePerspectiveToURL(next);
|
||||
applyPerspective(next);
|
||||
// t-paliad-164: any chip click is an explicit override; hide the
|
||||
// "vorgegeben durch Akte" hint so the bar reads as "user choice"
|
||||
// from here on.
|
||||
const hint = document.getElementById("fristen-perspective-hint");
|
||||
if (hint) hint.hidden = true;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2679,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;
|
||||
@@ -2744,6 +2780,17 @@ function initPathwayFork() {
|
||||
renderStep1Summary();
|
||||
if (currentStep1Context.kind !== "none") showStep2Card(); else hideStep2Card();
|
||||
applyPerspective(readPerspectiveFromURL());
|
||||
// t-paliad-164: restore the hint visibility from URL+project state.
|
||||
// The hint shows when the active URL perspective matches what the
|
||||
// current project's our_side would have predefined — i.e. the
|
||||
// "predefined-and-not-yet-overridden" state. Approximation: hint
|
||||
// visible iff project.our_side maps to currentPerspective.
|
||||
const hint = document.getElementById("fristen-perspective-hint");
|
||||
if (hint) {
|
||||
const proj = currentStep1Context.kind === "project" ? currentStep1Context.project : undefined;
|
||||
const expected = ourSideToPerspective(proj?.our_side);
|
||||
hint.hidden = !(proj && proj.our_side && expected === currentPerspective);
|
||||
}
|
||||
const path = readPathwayFromURL();
|
||||
const mode = readBModeFromURL();
|
||||
showPathway(path, mode);
|
||||
@@ -3382,6 +3429,45 @@ function applyPerspective(p: Perspective) {
|
||||
triggerCascadeRefresh();
|
||||
}
|
||||
|
||||
// ourSideToPerspective maps the project-level "Wir vertreten" enum
|
||||
// onto the chip-strip Perspective. 'court' / 'both' map to null
|
||||
// (chip cleared) — court actions are neutral to the user's side and
|
||||
// "both" is explicit no-filter intent.
|
||||
function ourSideToPerspective(os: string | null | undefined): Perspective {
|
||||
if (os === "claimant") return "claimant";
|
||||
if (os === "defendant") return "defendant";
|
||||
return null;
|
||||
}
|
||||
|
||||
// applyOurSidePredefine locks the perspective chip from
|
||||
// project.our_side when the user hasn't already explicitly picked
|
||||
// one. The URL is the "explicit pick" signal: if ?role= is present
|
||||
// at call time, the user (or a shared link) chose it and we don't
|
||||
// overwrite. When we do predefine, we write the same value to the
|
||||
// URL so back/forward + refresh round-trip cleanly, and we show the
|
||||
// "vorgegeben durch Akte" hint so the user knows where the
|
||||
// pre-selection came from. Clicking a chip clears the hint.
|
||||
//
|
||||
// `replaceURL=true` is for the deep-link / refresh path; `false` for
|
||||
// in-page project selection so back-button restores the empty state.
|
||||
function applyOurSidePredefine(project: ProjectOption | undefined, replaceURL: boolean) {
|
||||
const hint = document.getElementById("fristen-perspective-hint");
|
||||
if (!project || !project.our_side) {
|
||||
if (hint) hint.hidden = true;
|
||||
return;
|
||||
}
|
||||
// URL wins — user has an explicit pick. Don't clobber it; also no
|
||||
// hint, since the active perspective didn't come from the project.
|
||||
if (readPerspectiveFromURL() !== null) {
|
||||
if (hint) hint.hidden = true;
|
||||
return;
|
||||
}
|
||||
const next = ourSideToPerspective(project.our_side);
|
||||
writePerspectiveToURL(next, replaceURL);
|
||||
applyPerspective(next);
|
||||
if (hint) hint.hidden = false;
|
||||
}
|
||||
|
||||
// perspectiveAllowsParty returns true when a node tagged with `party`
|
||||
// should be visible under the current perspective. Neutral nodes
|
||||
// (party undefined / empty) always pass. "both" matches every
|
||||
|
||||
@@ -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",
|
||||
@@ -376,6 +379,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.perspective.claimant.title": "Klägerseite — versteckt typische Beklagten-Schriftsätze",
|
||||
"deadlines.perspective.defendant.title": "Beklagtenseite — versteckt typische Kläger-Schriftsätze",
|
||||
"deadlines.perspective.appeal_filed_by.label": "Berufung eingelegt durch:",
|
||||
"deadlines.perspective.predefined_hint": "vorgegeben durch Akte",
|
||||
"deadlines.event.composite.label": "Zusammengesetzt:",
|
||||
"deadlines.event.unit.days.one": "Tag",
|
||||
"deadlines.event.unit.days.many": "Tage",
|
||||
@@ -740,6 +744,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.field.due": "F\u00e4lligkeitsdatum",
|
||||
"deadlines.field.rule": "Regel (optional)",
|
||||
"deadlines.field.rule.none": "Keine Regel",
|
||||
"deadlines.field.rule.autofill": "Typ vorgegeben durch Regel — entfernen, um zu überschreiben.",
|
||||
"deadlines.field.rule.autofill_inline": " (vorgegeben durch Regel)",
|
||||
"deadlines.field.rule.mismatch": "Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.",
|
||||
"deadlines.field.rule.override": "Anderen Typ wählen",
|
||||
"deadlines.field.notes": "Notizen (optional)",
|
||||
"deadlines.field.notes.placeholder": "Hinweise, Verweise, n\u00e4chste Schritte\u2026",
|
||||
"deadlines.error.required": "Akte, Titel und F\u00e4lligkeitsdatum sind Pflichtfelder.",
|
||||
@@ -875,6 +883,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"dashboard.action.short.project_reparented": "ordnete Projekt neu zu",
|
||||
"dashboard.action.short.project_type_changed": "\u00e4nderte Projekt-Typ",
|
||||
"dashboard.action.short.status_changed": "\u00e4nderte Status",
|
||||
"dashboard.action.short.our_side_changed": "\u00e4nderte vertretene Seite",
|
||||
"dashboard.action.short.visibility_changed": "\u00e4nderte Sichtbarkeit",
|
||||
"dashboard.action.short.collaborators_updated": "aktualisierte Bearbeiter",
|
||||
"dashboard.action.short.note_created": "f\u00fcgte Notiz hinzu",
|
||||
@@ -896,6 +905,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.project_reparented": "Projekt umstrukturiert",
|
||||
"event.title.project_type_changed": "Projekt-Typ ge\u00e4ndert",
|
||||
"event.title.status_changed": "Status ge\u00e4ndert",
|
||||
"event.title.our_side_changed": "Vertretene Seite ge\u00e4ndert",
|
||||
"event.title.note_created": "Notiz hinzugef\u00fcgt",
|
||||
"event.title.deadline_created": "Frist angelegt",
|
||||
"event.title.deadline_updated": "Frist ge\u00e4ndert",
|
||||
@@ -1125,6 +1135,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.field.court": "Gericht",
|
||||
"projects.field.case_number": "Aktenzeichen (Gericht)",
|
||||
"projects.field.proceeding_type_id": "Verfahrensart",
|
||||
"projects.field.our_side": "Wir vertreten",
|
||||
"projects.field.our_side.hint": "Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. Lässt sich dort jederzeit überschreiben.",
|
||||
"projects.field.our_side.unset": "Unbekannt / nicht gesetzt",
|
||||
"projects.field.our_side.claimant": "Klägerseite",
|
||||
"projects.field.our_side.defendant": "Beklagtenseite",
|
||||
"projects.field.our_side.court": "Gericht / Tribunal",
|
||||
"projects.field.our_side.both": "Beide Seiten",
|
||||
"projects.field.our_side.none": "—",
|
||||
"projects.field.status": "Status",
|
||||
"projects.error.title_required": "Titel erforderlich",
|
||||
"projects.detail.edit.type_change_warning.title": "Diese Felder werden geleert:",
|
||||
@@ -1145,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",
|
||||
@@ -2154,6 +2245,81 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.editor.error.sources_required": "Mindestens eine Quelle wählen.",
|
||||
"views.editor.error.load_failed": "Ansicht konnte nicht geladen werden.",
|
||||
"views.editor.error.delete_failed": "Ansicht konnte nicht gelöscht werden.",
|
||||
|
||||
// Universal FilterBar — t-paliad-163. Mounted on every list-shaped
|
||||
// surface (starts with /inbox in Phase 1; /agenda + /events follow).
|
||||
"views.bar.label.time": "Zeitraum",
|
||||
"views.bar.label.personal": "Eigene",
|
||||
"views.bar.label.approval_role": "Sicht",
|
||||
"views.bar.label.approval_status": "Status",
|
||||
"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",
|
||||
"views.bar.common.all": "Alle",
|
||||
"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",
|
||||
"views.bar.approval_role.approver_eligible": "Zur Genehmigung",
|
||||
"views.bar.approval_role.self_requested": "Eigene Anfragen",
|
||||
"views.bar.approval_role.any_visible": "Alle sichtbaren",
|
||||
"views.bar.approval_status.pending": "Wartend",
|
||||
"views.bar.approval_status.approved": "Genehmigt",
|
||||
"views.bar.approval_status.rejected": "Abgelehnt",
|
||||
"views.bar.approval_status.revoked": "Zurückgezogen",
|
||||
"views.bar.approval_entity.deadline": "Frist",
|
||||
"views.bar.approval_entity.appointment": "Termin",
|
||||
"views.bar.deadline_status.pending": "Offen",
|
||||
"views.bar.deadline_status.completed": "Erledigt",
|
||||
"views.bar.appointment_type.hearing": "Verhandlung",
|
||||
"views.bar.appointment_type.meeting": "Besprechung",
|
||||
"views.bar.appointment_type.consultation": "Beratung",
|
||||
"views.bar.appointment_type.deadline_hearing": "Mündliche Verhandlung",
|
||||
"views.bar.shape.list": "Liste",
|
||||
"views.bar.shape.cards": "Karten",
|
||||
"views.bar.shape.calendar": "Kalender",
|
||||
"views.bar.density.comfortable": "Bequem",
|
||||
"views.bar.density.compact": "Kompakt",
|
||||
"views.bar.sort.date_asc": "Datum aufsteigend",
|
||||
"views.bar.sort.date_desc": "Datum absteigend",
|
||||
"views.bar.action.reset": "Zurücksetzen",
|
||||
"views.bar.action.save_as_view": "Als Sicht speichern",
|
||||
"views.bar.save.heading": "Sicht speichern",
|
||||
"views.bar.save.field.name": "Name",
|
||||
"views.bar.save.field.slug": "Slug",
|
||||
"views.bar.save.field.slug_hint": "Wird Teil der URL: /views/<slug>",
|
||||
"views.bar.save.field.show_count": "Anzahl in der Sidebar zeigen",
|
||||
"views.bar.save.cancel": "Abbrechen",
|
||||
"views.bar.save.confirm": "Speichern",
|
||||
"views.bar.save.error.name_required": "Bitte Namen vergeben.",
|
||||
"views.bar.save.error.slug_format": "Slug muss mit einem Buchstaben oder einer Ziffer beginnen und darf nur Kleinbuchstaben, Ziffern und Bindestriche enthalten.",
|
||||
"views.bar.save.error.slug_taken": "Dieser Slug ist bereits vergeben.",
|
||||
"views.bar.save.error.network": "Netzwerkfehler — bitte erneut versuchen.",
|
||||
},
|
||||
|
||||
en: {
|
||||
@@ -2161,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",
|
||||
@@ -2400,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",
|
||||
@@ -2520,6 +2689,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.perspective.claimant.title": "Claimant side — hides typical defendant submissions",
|
||||
"deadlines.perspective.defendant.title": "Defendant side — hides typical claimant submissions",
|
||||
"deadlines.perspective.appeal_filed_by.label": "Appeal filed by:",
|
||||
"deadlines.perspective.predefined_hint": "predefined from project",
|
||||
"deadlines.event.composite.label": "Composite:",
|
||||
"deadlines.event.unit.days.one": "day",
|
||||
"deadlines.event.unit.days.many": "days",
|
||||
@@ -2877,6 +3047,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.field.due": "Due date",
|
||||
"deadlines.field.rule": "Rule (optional)",
|
||||
"deadlines.field.rule.none": "No rule",
|
||||
"deadlines.field.rule.autofill": "Type set by rule — remove to override.",
|
||||
"deadlines.field.rule.autofill_inline": " (set by rule)",
|
||||
"deadlines.field.rule.mismatch": "Note: type contradicts rule — you have overridden the type.",
|
||||
"deadlines.field.rule.override": "Choose another type",
|
||||
"deadlines.field.notes": "Notes (optional)",
|
||||
"deadlines.field.notes.placeholder": "References, hints, next steps\u2026",
|
||||
"deadlines.error.required": "Matter, title and due date are required.",
|
||||
@@ -3002,6 +3176,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"dashboard.action.short.project_reparented": "re-parented project",
|
||||
"dashboard.action.short.project_type_changed": "changed project type",
|
||||
"dashboard.action.short.status_changed": "changed status",
|
||||
"dashboard.action.short.our_side_changed": "changed represented side",
|
||||
"dashboard.action.short.visibility_changed": "changed visibility",
|
||||
"dashboard.action.short.collaborators_updated": "updated collaborators",
|
||||
"dashboard.action.short.note_created": "added note",
|
||||
@@ -3023,6 +3198,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"event.title.project_reparented": "Project re-parented",
|
||||
"event.title.project_type_changed": "Project type changed",
|
||||
"event.title.status_changed": "Status changed",
|
||||
"event.title.our_side_changed": "Represented side changed",
|
||||
"event.title.note_created": "Note added",
|
||||
"event.title.deadline_created": "Deadline created",
|
||||
"event.title.deadline_updated": "Deadline updated",
|
||||
@@ -3250,6 +3426,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.field.court": "Court",
|
||||
"projects.field.case_number": "Case number (court)",
|
||||
"projects.field.proceeding_type_id": "Proceeding type",
|
||||
"projects.field.our_side": "We represent",
|
||||
"projects.field.our_side.hint": "Pre-selects the perspective chip in the Fristenrechner Determinator. Always overridable from there.",
|
||||
"projects.field.our_side.unset": "Unknown / not set",
|
||||
"projects.field.our_side.claimant": "Claimant side",
|
||||
"projects.field.our_side.defendant": "Defendant side",
|
||||
"projects.field.our_side.court": "Court / tribunal",
|
||||
"projects.field.our_side.both": "Both sides",
|
||||
"projects.field.our_side.none": "—",
|
||||
"projects.field.status": "Status",
|
||||
"projects.error.title_required": "Title required",
|
||||
"projects.detail.edit.type_change_warning.title": "These fields will be cleared:",
|
||||
@@ -3270,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",
|
||||
@@ -4276,6 +4532,80 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"views.editor.error.sources_required": "Pick at least one source.",
|
||||
"views.editor.error.load_failed": "Could not load this view.",
|
||||
"views.editor.error.delete_failed": "Could not delete this view.",
|
||||
|
||||
// Universal FilterBar — t-paliad-163.
|
||||
"views.bar.label.time": "Time",
|
||||
"views.bar.label.personal": "Mine",
|
||||
"views.bar.label.approval_role": "View",
|
||||
"views.bar.label.approval_status": "Status",
|
||||
"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",
|
||||
"views.bar.common.all": "All",
|
||||
"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",
|
||||
"views.bar.approval_role.approver_eligible": "To approve",
|
||||
"views.bar.approval_role.self_requested": "My requests",
|
||||
"views.bar.approval_role.any_visible": "All visible",
|
||||
"views.bar.approval_status.pending": "Pending",
|
||||
"views.bar.approval_status.approved": "Approved",
|
||||
"views.bar.approval_status.rejected": "Rejected",
|
||||
"views.bar.approval_status.revoked": "Revoked",
|
||||
"views.bar.approval_entity.deadline": "Deadline",
|
||||
"views.bar.approval_entity.appointment": "Appointment",
|
||||
"views.bar.deadline_status.pending": "Open",
|
||||
"views.bar.deadline_status.completed": "Completed",
|
||||
"views.bar.appointment_type.hearing": "Hearing",
|
||||
"views.bar.appointment_type.meeting": "Meeting",
|
||||
"views.bar.appointment_type.consultation": "Consultation",
|
||||
"views.bar.appointment_type.deadline_hearing": "Oral hearing",
|
||||
"views.bar.shape.list": "List",
|
||||
"views.bar.shape.cards": "Cards",
|
||||
"views.bar.shape.calendar": "Calendar",
|
||||
"views.bar.density.comfortable": "Comfortable",
|
||||
"views.bar.density.compact": "Compact",
|
||||
"views.bar.sort.date_asc": "Date ascending",
|
||||
"views.bar.sort.date_desc": "Date descending",
|
||||
"views.bar.action.reset": "Reset",
|
||||
"views.bar.action.save_as_view": "Save as view",
|
||||
"views.bar.save.heading": "Save view",
|
||||
"views.bar.save.field.name": "Name",
|
||||
"views.bar.save.field.slug": "Slug",
|
||||
"views.bar.save.field.slug_hint": "Becomes part of the URL: /views/<slug>",
|
||||
"views.bar.save.field.show_count": "Show count in sidebar",
|
||||
"views.bar.save.cancel": "Cancel",
|
||||
"views.bar.save.confirm": "Save",
|
||||
"views.bar.save.error.name_required": "Please supply a name.",
|
||||
"views.bar.save.error.slug_format": "Slug must start with a letter or digit and contain only lowercase letters, digits, and hyphens.",
|
||||
"views.bar.save.error.slug_taken": "This slug is already in use.",
|
||||
"views.bar.save.error.network": "Network error — please retry.",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4351,6 +4681,12 @@ function translateEventDescription(eventType: string, description: string): stri
|
||||
// New format: "active → archived". Legacy: "Status active → archived".
|
||||
return translateArrowSlugs(body.replace(/^Status\s+/, ""), "projects.filter.status.");
|
||||
}
|
||||
if (eventType === "our_side_changed") {
|
||||
// Format: "<from> → <to>", where each side is one of
|
||||
// claimant / defendant / court / both / "none" (the sentinel for
|
||||
// NULL the service writes when the column is unset on either end).
|
||||
return translateArrowSlugs(body, "projects.field.our_side.");
|
||||
}
|
||||
if (eventType === "note_created") {
|
||||
// New format: just the parent slug. Legacy: "Note zu <slug> hinzugefügt".
|
||||
const m = body.match(/^Note zu (project|deadline|appointment) hinzugef[üu]gt$/i);
|
||||
|
||||
@@ -1,122 +1,176 @@
|
||||
import { initI18n, t, getLang, type I18nKey } from "./i18n";
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { mountFilterBar, type BarHandle } from "./filter-bar";
|
||||
import type { AxisKey } from "./filter-bar";
|
||||
import type { FilterSpec, RenderSpec, SystemView, ViewRunResult } from "./views/types";
|
||||
import { renderListShape } from "./views/shape-list";
|
||||
|
||||
// /inbox client. Two tabs (pending-mine / mine), action buttons (approve /
|
||||
// reject / revoke), and a small inline diff for update / complete / delete
|
||||
// lifecycle events.
|
||||
// /inbox client — t-paliad-163 universal-filter migration.
|
||||
//
|
||||
// State is URL-driven via ?tab= so back/forward buttons work and the bell
|
||||
// badge can deep-link to either tab. The badge in the sidebar (id
|
||||
// sidebar-inbox-badge) is updated by the shared global polling loop in
|
||||
// sidebar.ts; this module just keeps the page content in sync.
|
||||
// The bar owns every axis the old tab UI exposed plus more:
|
||||
// - approval_viewer_role: "Zur Genehmigung" / "Eigene Anfragen" /
|
||||
// "Alle sichtbaren" (collapses the legacy two-tab UI per Q4 lock-in)
|
||||
// - approval_status: chip cluster (default: pending)
|
||||
// - approval_entity_type: chip pair (Frist / Termin)
|
||||
// - time: chip cluster (Any default)
|
||||
// - density: comfortable / compact
|
||||
// - sort: date asc / desc
|
||||
//
|
||||
// Row rendering: shape-list.ts with row_action="approve" stamps the
|
||||
// inbox markup (entity title, diff, approve/reject/revoke buttons).
|
||||
// We wire action click handlers in onResult and refresh through the
|
||||
// bar handle.
|
||||
|
||||
type Lifecycle = "create" | "update" | "complete" | "delete";
|
||||
type RequestStatus = "pending" | "approved" | "rejected" | "revoked" | "superseded";
|
||||
type DecisionKind = "peer" | "admin_override";
|
||||
const INBOX_AXES: AxisKey[] = [
|
||||
"time",
|
||||
"approval_viewer_role",
|
||||
"approval_status",
|
||||
"approval_entity_type",
|
||||
"density",
|
||||
"sort",
|
||||
];
|
||||
|
||||
interface ApprovalRequestView {
|
||||
id: string;
|
||||
project_id: string;
|
||||
project_title: string;
|
||||
entity_type: "deadline" | "appointment";
|
||||
entity_id: string;
|
||||
entity_title?: string;
|
||||
lifecycle_event: Lifecycle;
|
||||
pre_image?: Record<string, unknown> | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
required_role: string;
|
||||
status: RequestStatus;
|
||||
requested_at: string;
|
||||
requested_by: string;
|
||||
requester_name: string;
|
||||
decided_at?: string;
|
||||
decided_by?: string;
|
||||
decider_name?: string;
|
||||
decision_kind?: DecisionKind;
|
||||
decision_note?: string;
|
||||
// t-paliad-161: 'user' (direct create) or 'agent' (Paliadin-drafted).
|
||||
// 'agent' rows render with a sparkle ✨ next to the requester's name.
|
||||
requester_kind?: "user" | "agent";
|
||||
agent_turn_id?: string;
|
||||
}
|
||||
|
||||
type Tab = "pending-mine" | "mine";
|
||||
|
||||
let currentTab: Tab = "pending-mine";
|
||||
|
||||
initI18n();
|
||||
initSidebar();
|
||||
let bar: BarHandle | null = null;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const url = new URL(window.location.href);
|
||||
const t = url.searchParams.get("tab");
|
||||
if (t === "mine") currentTab = "mine";
|
||||
bindTabs();
|
||||
refresh();
|
||||
initI18n();
|
||||
initSidebar();
|
||||
applyLegacyTabRedirect();
|
||||
void hydrate();
|
||||
});
|
||||
|
||||
function bindTabs() {
|
||||
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tab = (btn.dataset.tab as Tab) || "pending-mine";
|
||||
if (tab === currentTab) return;
|
||||
currentTab = tab;
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("tab", tab);
|
||||
history.replaceState({}, "", url.toString());
|
||||
document.querySelectorAll<HTMLButtonElement>("#inbox-tab-row [data-tab]").forEach((b) => {
|
||||
b.classList.toggle("active", b.dataset.tab === tab);
|
||||
});
|
||||
refresh();
|
||||
});
|
||||
// ?tab=pending-mine | mine -> ?a_role=approver_eligible | self_requested.
|
||||
// Done client-side because /inbox serves a static dist file (no Go
|
||||
// router involvement). Bookmarks from the sidebar bell + outbound
|
||||
// emails keep landing on the right sub-view through the bar.
|
||||
function applyLegacyTabRedirect(): void {
|
||||
const url = new URL(window.location.href);
|
||||
const tab = url.searchParams.get("tab");
|
||||
if (!tab) return;
|
||||
url.searchParams.delete("tab");
|
||||
if (tab === "mine") {
|
||||
url.searchParams.set("a_role", "self_requested");
|
||||
} else if (tab === "pending-mine") {
|
||||
url.searchParams.set("a_role", "approver_eligible");
|
||||
}
|
||||
history.replaceState(null, "", url.toString());
|
||||
}
|
||||
|
||||
async function hydrate(): Promise<void> {
|
||||
const host = document.getElementById("inbox-filter-bar");
|
||||
const loading = document.getElementById("inbox-loading");
|
||||
const results = document.getElementById("inbox-results");
|
||||
const empty = document.getElementById("inbox-empty");
|
||||
if (!host || !loading || !results || !empty) return;
|
||||
|
||||
const sys = await fetchInboxSystemView();
|
||||
if (!sys) {
|
||||
loading.style.display = "none";
|
||||
empty.style.display = "";
|
||||
empty.textContent = t("approvals.error.internal");
|
||||
return;
|
||||
}
|
||||
|
||||
bar = mountFilterBar(host, {
|
||||
baseFilter: sys.Filter,
|
||||
baseRender: sys.Render,
|
||||
axes: INBOX_AXES,
|
||||
surfaceKey: "inbox",
|
||||
systemViewSlug: sys.Slug,
|
||||
onResult: (result, effective) => paint(result, effective.render, results, empty, loading),
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const loading = document.getElementById("inbox-loading") as HTMLElement | null;
|
||||
const empty = document.getElementById("inbox-empty") as HTMLElement | null;
|
||||
const list = document.getElementById("inbox-list") as HTMLUListElement | null;
|
||||
if (!loading || !empty || !list) return;
|
||||
loading.style.display = "";
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = "";
|
||||
const path = currentTab === "pending-mine" ? "/api/inbox/pending-mine" : "/api/inbox/mine";
|
||||
let rows: ApprovalRequestView[] = [];
|
||||
async function fetchInboxSystemView(): Promise<SystemView | null> {
|
||||
try {
|
||||
const r = await fetch(path, { credentials: "include" });
|
||||
if (r.ok) {
|
||||
// Defensive: a Go `nil` slice serialises as JSON `null`, not `[]`.
|
||||
// Coerce so `rows.length` never throws (t-paliad-160 §D regression
|
||||
// hardening). Server-side handler also forces `[]`, but keep the
|
||||
// client guard for older / cached deploys.
|
||||
const body = (await r.json()) as ApprovalRequestView[] | null;
|
||||
rows = body ?? [];
|
||||
}
|
||||
const r = await fetch("/api/views/system", { credentials: "include" });
|
||||
if (!r.ok) return null;
|
||||
const list = (await r.json()) as SystemView[];
|
||||
return list.find((v) => v.Slug === "inbox") ?? null;
|
||||
} catch (_e) {
|
||||
// Network errors fall through to empty render.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function paint(
|
||||
result: ViewRunResult,
|
||||
render: RenderSpec,
|
||||
results: HTMLElement,
|
||||
empty: HTMLElement,
|
||||
loading: HTMLElement,
|
||||
): void {
|
||||
loading.style.display = "none";
|
||||
if (rows.length === 0) {
|
||||
empty.textContent = t(
|
||||
currentTab === "pending-mine"
|
||||
? "approvals.empty.pending_mine"
|
||||
: "approvals.empty.mine"
|
||||
);
|
||||
|
||||
if (!result.rows || result.rows.length === 0) {
|
||||
results.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
empty.textContent = t("approvals.empty.pending_mine");
|
||||
void maybeShowAdminNudge();
|
||||
return;
|
||||
}
|
||||
hideAdminNudge();
|
||||
for (const row of rows) list.appendChild(renderRow(row));
|
||||
empty.style.display = "none";
|
||||
|
||||
// shape-list.ts honours render.list.row_action — InboxSystemView's
|
||||
// RenderSpec sets row_action="approve" so we get the inbox markup.
|
||||
renderListShape(results, result.rows, render);
|
||||
|
||||
// Wire action handlers on the freshly stamped DOM. The action
|
||||
// POSTs land on the same endpoints the legacy /inbox used; on
|
||||
// success we trigger a bar refresh so the new state propagates.
|
||||
wireApprovalActions(results);
|
||||
}
|
||||
|
||||
function wireApprovalActions(host: HTMLElement): void {
|
||||
host.querySelectorAll<HTMLButtonElement>(".views-approval-action").forEach((btn) => {
|
||||
const action = btn.dataset.action as "approve" | "reject" | "revoke" | undefined;
|
||||
const li = btn.closest<HTMLLIElement>(".views-approval-row");
|
||||
const id = li?.dataset.requestId;
|
||||
if (!action || !id) return;
|
||||
btn.addEventListener("click", async () => {
|
||||
let note = "";
|
||||
if (action === "reject") {
|
||||
note = window.prompt(t("approvals.note.placeholder")) || "";
|
||||
}
|
||||
btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(`/api/approval-requests/${id}/${action}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note }),
|
||||
});
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({} as { error?: string }));
|
||||
alert(mapApprovalError(body.error || "internal"));
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
await bar?.refresh();
|
||||
await refreshInboxBadge();
|
||||
} catch (_e) {
|
||||
alert("Network error");
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function mapApprovalError(key: string): string {
|
||||
switch (key) {
|
||||
case "self_approval_blocked": return t("approvals.error.self_approval");
|
||||
case "no_qualified_approver": return t("approvals.error.no_qualified_approver");
|
||||
case "concurrent_pending": return t("approvals.error.concurrent_pending");
|
||||
case "not_authorized": return t("approvals.error.not_authorized");
|
||||
case "request_not_pending": return t("approvals.error.request_not_pending");
|
||||
default: return key;
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-154 — show the admin-only "configure policies" nudge when:
|
||||
// - the current user is global_admin
|
||||
// - the inbox is empty
|
||||
// - no approval_policies row exists firm-wide (matrix is dormant)
|
||||
//
|
||||
// All three checks are AND-ed. Anonymous users + non-admins + active-policy
|
||||
// admins all skip the nudge.
|
||||
// - current user is global_admin
|
||||
// - inbox empty
|
||||
// - no approval_policies row exists firm-wide
|
||||
async function maybeShowAdminNudge(): Promise<void> {
|
||||
const nudge = document.getElementById("inbox-admin-nudge");
|
||||
if (!nudge) return;
|
||||
@@ -132,9 +186,7 @@ async function maybeShowAdminNudge(): Promise<void> {
|
||||
if (data.any) return;
|
||||
|
||||
nudge.style.display = "";
|
||||
} catch (_e) {
|
||||
// Network failure → keep nudge hidden.
|
||||
}
|
||||
} catch (_e) { /* keep hidden */ }
|
||||
}
|
||||
|
||||
function hideAdminNudge(): void {
|
||||
@@ -142,175 +194,7 @@ function hideAdminNudge(): void {
|
||||
if (nudge) nudge.style.display = "none";
|
||||
}
|
||||
|
||||
function renderRow(row: ApprovalRequestView): HTMLLIElement {
|
||||
const li = document.createElement("li");
|
||||
li.className = "inbox-row";
|
||||
|
||||
// Header: project / entity / lifecycle / required-role
|
||||
const head = document.createElement("div");
|
||||
head.className = "inbox-row-head";
|
||||
const title = document.createElement("div");
|
||||
title.className = "inbox-row-title";
|
||||
const entityLabel = t(("approvals.entity." + row.entity_type) as I18nKey);
|
||||
const lifecycleLabel = t(("approvals.lifecycle." + row.lifecycle_event) as I18nKey);
|
||||
const entityTitle = row.entity_title || "—";
|
||||
title.textContent = `${entityLabel}: ${entityTitle} — ${lifecycleLabel}`;
|
||||
head.appendChild(title);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "inbox-row-meta";
|
||||
const reqByLabel = t("approvals.requested_by");
|
||||
const roleLabel = t(("approvals.required_role." + row.required_role) as I18nKey);
|
||||
// t-paliad-161 ✨: when the request was drafted by Paliadin, surface
|
||||
// that next to the requester's name. Reads as "von Anna ✨ Paliadin".
|
||||
const requesterTag = row.requester_kind === "agent"
|
||||
? `${row.requester_name} ✨ ${t("approvals.agent.byline")}`
|
||||
: row.requester_name;
|
||||
meta.textContent = `${row.project_title} · ${reqByLabel} ${requesterTag} · ${roleLabel}+ · ${formatRelativeTime(row.requested_at)}`;
|
||||
head.appendChild(meta);
|
||||
li.appendChild(head);
|
||||
|
||||
// Diff for update / complete (date-bearing fields)
|
||||
const diff = renderDiff(row);
|
||||
if (diff) li.appendChild(diff);
|
||||
|
||||
// Decision note if any
|
||||
if (row.decision_note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "inbox-row-note";
|
||||
note.textContent = row.decision_note;
|
||||
li.appendChild(note);
|
||||
}
|
||||
|
||||
// Action row
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "inbox-row-actions";
|
||||
|
||||
if (row.status === "pending" && currentTab === "pending-mine") {
|
||||
actions.appendChild(actionButton("approve", row.id, () => doDecision(row.id, "approve")));
|
||||
actions.appendChild(actionButton("reject", row.id, () => doDecision(row.id, "reject")));
|
||||
} else if (row.status === "pending" && currentTab === "mine") {
|
||||
actions.appendChild(actionButton("revoke", row.id, () => doDecision(row.id, "revoke")));
|
||||
} else {
|
||||
// historic — show status pill
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "approval-pill approval-pill--historic";
|
||||
pill.textContent = t(("approvals.status." + row.status) as I18nKey);
|
||||
if (row.decider_name && row.status !== "revoked") {
|
||||
const decided = document.createElement("span");
|
||||
decided.className = "inbox-row-decided";
|
||||
decided.textContent = ` · ${t("approvals.decided_by")} ${row.decider_name}`;
|
||||
pill.appendChild(decided);
|
||||
}
|
||||
actions.appendChild(pill);
|
||||
}
|
||||
li.appendChild(actions);
|
||||
|
||||
return li;
|
||||
}
|
||||
|
||||
function renderDiff(row: ApprovalRequestView): HTMLElement | null {
|
||||
const before = (row.pre_image || {}) as Record<string, unknown>;
|
||||
const after = (row.payload || {}) as Record<string, unknown>;
|
||||
const keys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)]));
|
||||
if (keys.length === 0) return null;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "inbox-row-diff";
|
||||
for (const k of keys) {
|
||||
const line = document.createElement("div");
|
||||
line.className = "inbox-row-diff-line";
|
||||
const label = document.createElement("span");
|
||||
label.className = "inbox-row-diff-key";
|
||||
label.textContent = k;
|
||||
line.appendChild(label);
|
||||
const span = document.createElement("span");
|
||||
span.className = "inbox-row-diff-values";
|
||||
const fmt = (v: unknown) =>
|
||||
v === null || v === undefined ? "—" : String(v);
|
||||
if (k in before && k in after) {
|
||||
span.textContent = `${fmt(before[k])} → ${fmt(after[k])}`;
|
||||
} else if (k in before) {
|
||||
span.textContent = `${t("approvals.diff.before")}: ${fmt(before[k])}`;
|
||||
} else {
|
||||
span.textContent = `${t("approvals.diff.after")}: ${fmt(after[k])}`;
|
||||
}
|
||||
line.appendChild(span);
|
||||
wrap.appendChild(line);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function actionButton(action: "approve" | "reject" | "revoke", _requestID: string, onClick: () => void): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = `btn btn-${action === "approve" ? "primary" : action === "reject" ? "danger" : "secondary"} inbox-row-action`;
|
||||
btn.textContent = t(("approvals.action." + action) as I18nKey);
|
||||
btn.addEventListener("click", onClick);
|
||||
return btn;
|
||||
}
|
||||
|
||||
async function doDecision(requestID: string, action: "approve" | "reject" | "revoke") {
|
||||
let note = "";
|
||||
if (action === "reject") {
|
||||
note = window.prompt(t("approvals.note.placeholder")) || "";
|
||||
}
|
||||
let r: Response;
|
||||
try {
|
||||
r = await fetch(`/api/approval-requests/${requestID}/${action}`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ note }),
|
||||
});
|
||||
} catch (_e) {
|
||||
alert("Network error");
|
||||
return;
|
||||
}
|
||||
if (!r.ok) {
|
||||
const body = await r.json().catch(() => ({}));
|
||||
const errKey = (body && body.error) || "internal";
|
||||
const msg = mapApprovalError(errKey);
|
||||
alert(msg);
|
||||
return;
|
||||
}
|
||||
refresh();
|
||||
// Update sidebar bell count.
|
||||
refreshInboxBadge();
|
||||
}
|
||||
|
||||
function mapApprovalError(key: string): string {
|
||||
switch (key) {
|
||||
case "self_approval_blocked":
|
||||
return t("approvals.error.self_approval");
|
||||
case "no_qualified_approver":
|
||||
return t("approvals.error.no_qualified_approver");
|
||||
case "concurrent_pending":
|
||||
return t("approvals.error.concurrent_pending");
|
||||
case "not_authorized":
|
||||
return t("approvals.error.not_authorized");
|
||||
case "request_not_pending":
|
||||
return t("approvals.error.request_not_pending");
|
||||
default:
|
||||
return key;
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const t0 = Date.parse(iso);
|
||||
if (isNaN(t0)) return iso;
|
||||
const diffMs = Date.now() - t0;
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 60) return getLang() === "de" ? `vor ${sec}s` : `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return getLang() === "de" ? `vor ${min}m` : `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return getLang() === "de" ? `vor ${hr}h` : `${hr}h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
return getLang() === "de" ? `vor ${day}d` : `${day}d ago`;
|
||||
}
|
||||
|
||||
// Update the sidebar inbox badge (shared with sidebar.ts polling).
|
||||
async function refreshInboxBadge() {
|
||||
async function refreshInboxBadge(): Promise<void> {
|
||||
const badge = document.getElementById("sidebar-inbox-badge");
|
||||
if (!badge) return;
|
||||
try {
|
||||
@@ -323,7 +207,5 @@ async function refreshInboxBadge() {
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
}
|
||||
} catch (_e) {
|
||||
/* noop */
|
||||
}
|
||||
} catch (_e) { /* noop */ }
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface ProjectFormState {
|
||||
grantDate: string;
|
||||
court: string;
|
||||
caseNumber: string;
|
||||
ourSide: string;
|
||||
}
|
||||
|
||||
let parentCandidates: ProjectMini[] = [];
|
||||
@@ -178,6 +179,17 @@ export function readPayload(
|
||||
stringField("project-case-number", "case_number");
|
||||
}
|
||||
|
||||
// our_side is type-agnostic — every project type can carry "Wir
|
||||
// vertreten" because the Determinator picks it up regardless of
|
||||
// type. The select uses "" for the unset option; the service maps
|
||||
// empty string to NULL via nullableOurSide.
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) {
|
||||
const v = osSel.value.trim();
|
||||
if (v) payload.our_side = v;
|
||||
else if (!opts.omitEmpty) payload.our_side = "";
|
||||
}
|
||||
|
||||
const desc = ($("project-description") as HTMLTextAreaElement).value.trim();
|
||||
if (desc) payload.description = desc;
|
||||
else if (!opts.omitEmpty) payload.description = "";
|
||||
@@ -214,6 +226,8 @@ export function prefillForm(p: Record<string, unknown>) {
|
||||
get("project-grant-date").value = isoToDate(p.grant_date as string | null | undefined);
|
||||
get("project-court").value = String(p.court ?? "");
|
||||
get("project-case-number").value = String(p.case_number ?? "");
|
||||
const osSel = tryGet("project-our-side") as HTMLSelectElement | null;
|
||||
if (osSel) osSel.value = String(p.our_side ?? "");
|
||||
getTA("project-description").value = String(p.description ?? "");
|
||||
getSel("project-status").value = String(p.status ?? "active");
|
||||
}
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -1,17 +1,25 @@
|
||||
import { t, type I18nKey } from "../i18n";
|
||||
import type { RenderSpec, ViewRow } from "./types";
|
||||
import { t, tDyn, getLang, type I18nKey } from "../i18n";
|
||||
import type { ListRowAction, RenderSpec, ViewRow } from "./types";
|
||||
import { formatDate, formatRelative, parseDateOnly } from "./format";
|
||||
|
||||
// shape-list: renders ViewRows as a table (density=comfortable) or a
|
||||
// compact one-line stream (density=compact). The "activity feed" look
|
||||
// is just density=compact + actor/time columns — see Q4 lock-in
|
||||
// 2026-05-07 (3 shapes; no separate "activity").
|
||||
//
|
||||
// Row interaction is controlled by render.list.row_action
|
||||
// (t-paliad-163 schema bump). Default "navigate" keeps every existing
|
||||
// caller's contract — clicking a row goes to the per-kind detail
|
||||
// page. "approve" produces the approval-list layout for /inbox.
|
||||
// "complete_toggle" is wired in Phase 3 (/events). "none" suppresses
|
||||
// any row interaction (audit views).
|
||||
|
||||
export function renderListShape(host: HTMLElement, rows: ViewRow[], render: RenderSpec): void {
|
||||
host.innerHTML = "";
|
||||
const list = render.list ?? {};
|
||||
const density = list.density ?? "comfortable";
|
||||
const sort = list.sort ?? "date_asc";
|
||||
const rowAction: ListRowAction = list.row_action ?? "navigate";
|
||||
|
||||
const sorted = [...rows].sort((a, b) => {
|
||||
const aT = sortKey(a.event_date);
|
||||
@@ -19,6 +27,11 @@ export function renderListShape(host: HTMLElement, rows: ViewRow[], render: Rend
|
||||
return sort === "date_asc" ? aT - bT : bT - aT;
|
||||
});
|
||||
|
||||
if (rowAction === "approve") {
|
||||
host.appendChild(renderApprovalList(sorted));
|
||||
return;
|
||||
}
|
||||
|
||||
if (density === "compact") {
|
||||
host.appendChild(renderCompact(sorted));
|
||||
} else {
|
||||
@@ -162,3 +175,166 @@ function sortKey(iso: string): number {
|
||||
if (dateOnly) return dateOnly.getTime();
|
||||
return Date.parse(iso);
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------
|
||||
// row_action = "approve" — approval inbox layout
|
||||
//
|
||||
// Stamps the markup the /inbox surface needs (data attrs + classes);
|
||||
// the surface (client/inbox.ts) wires the action handlers in onResult.
|
||||
// This keeps shape-list independent of any specific surface's wiring.
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
interface ApprovalDetail {
|
||||
status?: string;
|
||||
lifecycle_event?: string;
|
||||
entity_type?: string;
|
||||
entity_title?: string;
|
||||
pre_image?: Record<string, unknown> | null;
|
||||
payload?: Record<string, unknown> | null;
|
||||
required_role?: string;
|
||||
requester_name?: string;
|
||||
requester_kind?: "user" | "agent";
|
||||
decider_name?: string;
|
||||
decision_note?: string;
|
||||
}
|
||||
|
||||
function renderApprovalList(rows: ViewRow[]): HTMLElement {
|
||||
const ul = document.createElement("ul");
|
||||
ul.className = "inbox-list views-approval-list";
|
||||
for (const row of rows) {
|
||||
const detail = (row.detail || {}) as ApprovalDetail;
|
||||
const li = document.createElement("li");
|
||||
li.className = "inbox-row views-approval-row";
|
||||
li.dataset.requestId = row.id;
|
||||
li.dataset.status = detail.status ?? "";
|
||||
|
||||
// Header: entity / lifecycle
|
||||
const head = document.createElement("div");
|
||||
head.className = "inbox-row-head";
|
||||
const title = document.createElement("div");
|
||||
title.className = "inbox-row-title";
|
||||
const entityLabel = detail.entity_type ? t(("approvals.entity." + detail.entity_type) as I18nKey) : "";
|
||||
const lifecycleLabel = detail.lifecycle_event ? t(("approvals.lifecycle." + detail.lifecycle_event) as I18nKey) : "";
|
||||
const entityTitle = detail.entity_title || row.title || "—";
|
||||
title.textContent = `${entityLabel}: ${entityTitle} — ${lifecycleLabel}`;
|
||||
head.appendChild(title);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "inbox-row-meta";
|
||||
const reqByLabel = t("approvals.requested_by");
|
||||
const roleLabel = detail.required_role
|
||||
? t(("approvals.required_role." + detail.required_role) as I18nKey)
|
||||
: "";
|
||||
const requester = detail.requester_name || row.actor_name || "";
|
||||
const requesterTag = detail.requester_kind === "agent"
|
||||
? `${requester} ✨ ${t("approvals.agent.byline")}`
|
||||
: requester;
|
||||
const projectTitle = row.project_title ?? "";
|
||||
const parts = [
|
||||
projectTitle,
|
||||
`${reqByLabel} ${requesterTag}`,
|
||||
];
|
||||
if (roleLabel) parts.push(`${roleLabel}+`);
|
||||
parts.push(formatRelativeTime(row.event_date));
|
||||
meta.textContent = parts.filter(Boolean).join(" · ");
|
||||
head.appendChild(meta);
|
||||
li.appendChild(head);
|
||||
|
||||
// Diff for update / complete
|
||||
const diff = renderDiff(detail);
|
||||
if (diff) li.appendChild(diff);
|
||||
|
||||
if (detail.decision_note) {
|
||||
const note = document.createElement("div");
|
||||
note.className = "inbox-row-note";
|
||||
note.textContent = detail.decision_note;
|
||||
li.appendChild(note);
|
||||
}
|
||||
|
||||
// Action row — surface attaches handlers via data-attrs.
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "inbox-row-actions";
|
||||
|
||||
if (detail.status === "pending") {
|
||||
// The bar's approval_viewer_role distinguishes which actions are
|
||||
// appropriate. The surface inspects the active role and decides
|
||||
// which buttons to keep — but for default rendering we stamp all
|
||||
// three with role-class hints and let the surface filter.
|
||||
actions.appendChild(actionBtn("approve"));
|
||||
actions.appendChild(actionBtn("reject"));
|
||||
actions.appendChild(actionBtn("revoke"));
|
||||
} else if (detail.status) {
|
||||
const pill = document.createElement("span");
|
||||
pill.className = "approval-pill approval-pill--historic";
|
||||
pill.textContent = t(("approvals.status." + detail.status) as I18nKey);
|
||||
if (detail.decider_name && detail.status !== "revoked") {
|
||||
const decided = document.createElement("span");
|
||||
decided.className = "inbox-row-decided";
|
||||
decided.textContent = ` · ${t("approvals.decided_by")} ${detail.decider_name}`;
|
||||
pill.appendChild(decided);
|
||||
}
|
||||
actions.appendChild(pill);
|
||||
}
|
||||
li.appendChild(actions);
|
||||
|
||||
ul.appendChild(li);
|
||||
}
|
||||
return ul;
|
||||
}
|
||||
|
||||
function renderDiff(detail: ApprovalDetail): HTMLElement | null {
|
||||
const before = (detail.pre_image || {}) as Record<string, unknown>;
|
||||
const after = (detail.payload || {}) as Record<string, unknown>;
|
||||
const keys = Array.from(new Set([...Object.keys(before), ...Object.keys(after)]));
|
||||
if (keys.length === 0) return null;
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "inbox-row-diff";
|
||||
for (const k of keys) {
|
||||
const line = document.createElement("div");
|
||||
line.className = "inbox-row-diff-line";
|
||||
const label = document.createElement("span");
|
||||
label.className = "inbox-row-diff-key";
|
||||
label.textContent = k;
|
||||
line.appendChild(label);
|
||||
const span = document.createElement("span");
|
||||
span.className = "inbox-row-diff-values";
|
||||
const fmt = (v: unknown) => v === null || v === undefined ? "—" : String(v);
|
||||
if (k in before && k in after) {
|
||||
span.textContent = `${fmt(before[k])} → ${fmt(after[k])}`;
|
||||
} else if (k in before) {
|
||||
span.textContent = `${t("approvals.diff.before")}: ${fmt(before[k])}`;
|
||||
} else {
|
||||
span.textContent = `${t("approvals.diff.after")}: ${fmt(after[k])}`;
|
||||
}
|
||||
line.appendChild(span);
|
||||
wrap.appendChild(line);
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function actionBtn(action: "approve" | "reject" | "revoke"): HTMLButtonElement {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.dataset.action = action;
|
||||
const cls = action === "approve" ? "btn-primary" : action === "reject" ? "btn-danger" : "btn-secondary";
|
||||
btn.className = `btn ${cls} inbox-row-action views-approval-action`;
|
||||
btn.textContent = t(("approvals.action." + action) as I18nKey);
|
||||
return btn;
|
||||
}
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const t0 = Date.parse(iso);
|
||||
if (isNaN(t0)) return iso;
|
||||
const diffMs = Date.now() - t0;
|
||||
const sec = Math.floor(diffMs / 1000);
|
||||
if (sec < 60) return getLang() === "de" ? `vor ${sec}s` : `${sec}s ago`;
|
||||
const min = Math.floor(sec / 60);
|
||||
if (min < 60) return getLang() === "de" ? `vor ${min}m` : `${min}m ago`;
|
||||
const hr = Math.floor(min / 60);
|
||||
if (hr < 24) return getLang() === "de" ? `vor ${hr}h` : `${hr}h ago`;
|
||||
const day = Math.floor(hr / 24);
|
||||
return getLang() === "de" ? `vor ${day}d` : `${day}d ago`;
|
||||
}
|
||||
|
||||
// Suppress unused warning for tDyn — kept available for future axes.
|
||||
void tDyn;
|
||||
|
||||
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";
|
||||
@@ -71,10 +71,13 @@ export interface FilterSpec {
|
||||
|
||||
export type RenderShape = "list" | "cards" | "calendar";
|
||||
|
||||
export type ListRowAction = "navigate" | "complete_toggle" | "approve" | "none";
|
||||
|
||||
export interface ListConfig {
|
||||
columns?: string[];
|
||||
sort?: "date_asc" | "date_desc";
|
||||
density?: "comfortable" | "compact";
|
||||
row_action?: ListRowAction;
|
||||
}
|
||||
|
||||
export interface CardsConfig {
|
||||
|
||||
@@ -153,6 +153,20 @@ export function ProjectFormFields(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-our-side" data-i18n="projects.field.our_side">Wir vertreten</label>
|
||||
<select id="project-our-side">
|
||||
<option value="" data-i18n="projects.field.our_side.unset">Unbekannt / nicht gesetzt</option>
|
||||
<option value="claimant" data-i18n="projects.field.our_side.claimant">Klägerseite</option>
|
||||
<option value="defendant" data-i18n="projects.field.our_side.defendant">Beklagtenseite</option>
|
||||
<option value="court" data-i18n="projects.field.our_side.court">Gericht / Tribunal</option>
|
||||
<option value="both" data-i18n="projects.field.our_side.both">Beide Seiten</option>
|
||||
</select>
|
||||
<p className="form-hint" data-i18n="projects.field.our_side.hint">
|
||||
Bestimmt die Voreinstellung der Perspektive im Fristenrechner-Determinator. Lässt sich dort jederzeit überschreiben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label htmlFor="project-description" data-i18n="projects.field.description">Notizen</label>
|
||||
<textarea id="project-description" rows={4} placeholder="Kurznotizen zum Projekt (optional)..." data-i18n-placeholder="projects.field.description.placeholder" />
|
||||
|
||||
@@ -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) +
|
||||
|
||||
@@ -55,9 +55,50 @@ export function renderDeadlinesNew(): string {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<div className="form-field" id="deadline-event-type-field">
|
||||
<label data-i18n="deadlines.field.event_type">Typ (optional)</label>
|
||||
{/* t-paliad-165 follow-up — collapsed view: when a Regel
|
||||
is selected and a default event_type is known, the
|
||||
Typ chip is hidden and the type is rendered inline
|
||||
as a single read-only summary with an "Anderen Typ
|
||||
wählen" link that re-expands the picker. */}
|
||||
<div
|
||||
className="event-type-collapsed"
|
||||
id="deadline-event-type-collapsed"
|
||||
style="display:none"
|
||||
>
|
||||
<span
|
||||
className="event-type-collapsed-label"
|
||||
id="deadline-event-type-collapsed-label"
|
||||
/>
|
||||
<span
|
||||
className="event-type-collapsed-source"
|
||||
data-i18n="deadlines.field.rule.autofill_inline"
|
||||
>
|
||||
(vorgegeben durch Regel)
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="event-type-collapsed-override"
|
||||
id="deadline-event-type-override-btn"
|
||||
data-i18n="deadlines.field.rule.override"
|
||||
>
|
||||
Anderen Typ wählen
|
||||
</button>
|
||||
</div>
|
||||
<div id="deadline-event-types" className="event-type-picker-host" />
|
||||
{/* Soft warning when the user is in expanded mode AND
|
||||
has picked an event_type that doesn't include the
|
||||
rule's canonical default. Reuses the existing
|
||||
yellow form-hint--warning style; never blocking. */}
|
||||
<p
|
||||
className="form-hint form-hint--warning"
|
||||
id="deadline-event-type-rule-mismatch"
|
||||
style="display:none"
|
||||
data-i18n="deadlines.field.rule.mismatch"
|
||||
>
|
||||
Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="form-field-row">
|
||||
|
||||
@@ -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">
|
||||
@@ -273,6 +287,14 @@ export function renderFristenrechner(): string {
|
||||
<span data-i18n="deadlines.perspective.both.short">Beide</span>
|
||||
</button>
|
||||
</div>
|
||||
{/* t-paliad-164 — predefined-from-Akte hint. Hidden by
|
||||
default; client/fristenrechner.ts shows it when the
|
||||
active perspective came from project.our_side. The
|
||||
user can still click another chip to override. */}
|
||||
<span className="fristen-perspective-hint" id="fristen-perspective-hint"
|
||||
data-i18n="deadlines.perspective.predefined_hint" hidden>
|
||||
vorgegeben durch Akte
|
||||
</span>
|
||||
</div>
|
||||
<div className="fristen-inbox-bar" id="fristen-inbox-bar" role="group" aria-label="Inbox channel">
|
||||
<span className="fristen-inbox-bar-label" data-i18n="deadlines.inbox.label">Wo kam es an?</span>
|
||||
|
||||
@@ -652,6 +652,7 @@ export type I18nKey =
|
||||
| "dashboard.action.short.fristen_imported"
|
||||
| "dashboard.action.short.note_created"
|
||||
| "dashboard.action.short.notiz_created"
|
||||
| "dashboard.action.short.our_side_changed"
|
||||
| "dashboard.action.short.partei_added"
|
||||
| "dashboard.action.short.partei_removed"
|
||||
| "dashboard.action.short.project_archived"
|
||||
@@ -819,7 +820,11 @@ export type I18nKey =
|
||||
| "deadlines.field.notes"
|
||||
| "deadlines.field.notes.placeholder"
|
||||
| "deadlines.field.rule"
|
||||
| "deadlines.field.rule.autofill"
|
||||
| "deadlines.field.rule.autofill_inline"
|
||||
| "deadlines.field.rule.mismatch"
|
||||
| "deadlines.field.rule.none"
|
||||
| "deadlines.field.rule.override"
|
||||
| "deadlines.field.title"
|
||||
| "deadlines.field.title.placeholder"
|
||||
| "deadlines.filter.akte"
|
||||
@@ -905,6 +910,7 @@ export type I18nKey =
|
||||
| "deadlines.perspective.defendant.short"
|
||||
| "deadlines.perspective.defendant.title"
|
||||
| "deadlines.perspective.label"
|
||||
| "deadlines.perspective.predefined_hint"
|
||||
| "deadlines.print"
|
||||
| "deadlines.priority.date"
|
||||
| "deadlines.proceeding.reselect"
|
||||
@@ -964,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"
|
||||
@@ -1108,6 +1116,7 @@ export type I18nKey =
|
||||
| "event.title.deadline_updated"
|
||||
| "event.title.deadlines_imported"
|
||||
| "event.title.note_created"
|
||||
| "event.title.our_side_changed"
|
||||
| "event.title.project_archived"
|
||||
| "event.title.project_created"
|
||||
| "event.title.project_reparented"
|
||||
@@ -1459,6 +1468,7 @@ export type I18nKey =
|
||||
| "nav.team"
|
||||
| "nav.termine"
|
||||
| "nav.user_views.new"
|
||||
| "nav.verfahrensablauf"
|
||||
| "notes.cancel"
|
||||
| "notes.delete"
|
||||
| "notes.delete.confirm"
|
||||
@@ -1685,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"
|
||||
@@ -1746,6 +1828,14 @@ export type I18nKey =
|
||||
| "projects.field.matter_number"
|
||||
| "projects.field.netdocuments_url"
|
||||
| "projects.field.office"
|
||||
| "projects.field.our_side"
|
||||
| "projects.field.our_side.both"
|
||||
| "projects.field.our_side.claimant"
|
||||
| "projects.field.our_side.court"
|
||||
| "projects.field.our_side.defendant"
|
||||
| "projects.field.our_side.hint"
|
||||
| "projects.field.our_side.none"
|
||||
| "projects.field.our_side.unset"
|
||||
| "projects.field.parent"
|
||||
| "projects.field.parent.hint"
|
||||
| "projects.field.parent.placeholder"
|
||||
@@ -1929,6 +2019,78 @@ export type I18nKey =
|
||||
| "unit_role.paralegal"
|
||||
| "unit_role.senior_pa"
|
||||
| "views.action.edit"
|
||||
| "views.bar.action.reset"
|
||||
| "views.bar.action.save_as_view"
|
||||
| "views.bar.appointment_type.consultation"
|
||||
| "views.bar.appointment_type.deadline_hearing"
|
||||
| "views.bar.appointment_type.hearing"
|
||||
| "views.bar.appointment_type.meeting"
|
||||
| "views.bar.approval_entity.appointment"
|
||||
| "views.bar.approval_entity.deadline"
|
||||
| "views.bar.approval_role.any_visible"
|
||||
| "views.bar.approval_role.approver_eligible"
|
||||
| "views.bar.approval_role.self_requested"
|
||||
| "views.bar.approval_status.approved"
|
||||
| "views.bar.approval_status.pending"
|
||||
| "views.bar.approval_status.rejected"
|
||||
| "views.bar.approval_status.revoked"
|
||||
| "views.bar.common.all"
|
||||
| "views.bar.deadline_status.completed"
|
||||
| "views.bar.deadline_status.pending"
|
||||
| "views.bar.density.comfortable"
|
||||
| "views.bar.density.compact"
|
||||
| "views.bar.label.appointment_type"
|
||||
| "views.bar.label.approval_entity"
|
||||
| "views.bar.label.approval_role"
|
||||
| "views.bar.label.approval_status"
|
||||
| "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"
|
||||
| "views.bar.save.error.name_required"
|
||||
| "views.bar.save.error.network"
|
||||
| "views.bar.save.error.slug_format"
|
||||
| "views.bar.save.error.slug_taken"
|
||||
| "views.bar.save.field.name"
|
||||
| "views.bar.save.field.show_count"
|
||||
| "views.bar.save.field.slug"
|
||||
| "views.bar.save.field.slug_hint"
|
||||
| "views.bar.save.heading"
|
||||
| "views.bar.shape.calendar"
|
||||
| "views.bar.shape.cards"
|
||||
| "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"
|
||||
| "views.bar.time.next_30d"
|
||||
| "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"
|
||||
|
||||
@@ -5,13 +5,20 @@ import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// Approval inbox page (t-paliad-138). Two-tab UI:
|
||||
// - "Zur Genehmigung": requests where the caller is qualified to approve
|
||||
// - "Meine Anfragen": requests submitted by the caller
|
||||
// /inbox — t-paliad-163 universal-filter migration.
|
||||
//
|
||||
// Hydrates lazily on load (no inline payload) — unlike the dashboard, the
|
||||
// inbox doesn't carry SSR state. The client bundle calls /api/inbox/* on
|
||||
// hydration and re-renders.
|
||||
// The page is a thin shell around two host divs: one for the
|
||||
// <FilterBar> primitive and one for the result list. The bar takes
|
||||
// care of every axis (approval_viewer_role chip cluster replaces the
|
||||
// two-tab UI; status / entity_type / time chips are new affordances).
|
||||
// Rows render via shape-list.ts with row_action="approve" — the
|
||||
// inbox-specific markup that produces the diff + approve/reject/revoke
|
||||
// buttons. Action handlers are wired in client/inbox.ts.
|
||||
//
|
||||
// The legacy `?tab=` URL is preserved by the client: ?tab=mine maps
|
||||
// to ?a_role=self_requested before the bar mounts so old bookmarks
|
||||
// (sidebar bell, Genehmigungen email links) keep landing on the
|
||||
// expected sub-view.
|
||||
|
||||
export function renderInbox(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
@@ -38,18 +45,11 @@ export function renderInbox(): string {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="agenda-controls">
|
||||
<div className="agenda-filter-group" role="group">
|
||||
<div className="agenda-chip-row" id="inbox-tab-row">
|
||||
<button type="button" className="agenda-chip active" data-tab="pending-mine" data-i18n="approvals.tab.pending_mine">Zur Genehmigung</button>
|
||||
<button type="button" className="agenda-chip" data-tab="mine" data-i18n="approvals.tab.mine">Meine Anfragen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="inbox-filter-bar" />
|
||||
|
||||
<div className="agenda-loading" id="inbox-loading" data-i18n="agenda.loading">Lädt …</div>
|
||||
<div className="entity-empty" id="inbox-empty" style="display:none" />
|
||||
<ul className="inbox-list" id="inbox-list" />
|
||||
<div id="inbox-results" />
|
||||
|
||||
{/* t-paliad-154 — admin-only nudge surfaced when:
|
||||
- the user is global_admin
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1981,6 +1981,22 @@ input[type="range"]::-moz-range-thumb {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
/* t-paliad-164 — "vorgegeben durch Akte" hint shown next to the
|
||||
* perspective chips when project.our_side has predefined the chip.
|
||||
* Italic, muted, with a subtle leading bullet so it reads as
|
||||
* meta-info rather than a chip. The user can still click another
|
||||
* chip to override; the hint quietly disappears when they do. */
|
||||
.fristen-perspective-hint {
|
||||
font-size: 0.8rem;
|
||||
font-style: italic;
|
||||
color: var(--color-muted, #666);
|
||||
margin-left: 0.4rem;
|
||||
}
|
||||
|
||||
.fristen-perspective-hint::before {
|
||||
content: "·\00a0";
|
||||
}
|
||||
|
||||
.fristen-inbox-bar-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-muted, #666);
|
||||
@@ -10404,6 +10420,43 @@ dialog.quick-add-sheet::backdrop {
|
||||
t-paliad-088 — Event Types: picker, multi-select filter, add modal
|
||||
============================================================================ */
|
||||
|
||||
/* t-paliad-165 follow-up — collapsed read-only view used on
|
||||
/deadlines/new when a Regel is selected and a default event_type is
|
||||
known. Replaces the picker with a single inline label + an
|
||||
"Anderen Typ wählen" override link. */
|
||||
.event-type-collapsed {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 0.4rem;
|
||||
padding: 0.35rem 0.55rem;
|
||||
background: var(--color-bg-lime-tint);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.3;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.event-type-collapsed-label {
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.event-type-collapsed-source {
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.event-type-collapsed-override {
|
||||
margin-left: auto;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--color-link, #1d4ed8);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.event-type-collapsed-override:hover { color: var(--color-link-hover, #1e40af); }
|
||||
|
||||
/* Picker host — chip cluster + search + suggest dropdown */
|
||||
.event-type-picker {
|
||||
display: flex;
|
||||
@@ -13235,3 +13288,887 @@ dialog.quick-add-sheet::backdrop {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------
|
||||
Universal FilterBar — t-paliad-163.
|
||||
|
||||
Mounts on every list-shaped surface (starting with /inbox in Phase 1).
|
||||
Reuses .agenda-chip + .filter-group + .entity-select for legacy
|
||||
parity; wraps them with .filter-bar* scoping so the bar can be
|
||||
styled independently if a surface needs to override.
|
||||
---------------------------------------------------------------------- */
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-end;
|
||||
gap: 0.85rem 1.1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0 0 1rem 0;
|
||||
background: var(--color-surface, #ffffff);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 0.75rem;
|
||||
}
|
||||
|
||||
.filter-bar-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.filter-bar-label {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.filter-bar-chip-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.filter-bar-chip-row.filter-bar-segment {
|
||||
flex-wrap: nowrap;
|
||||
gap: 0;
|
||||
padding: 0.15rem;
|
||||
background: var(--color-surface-muted, #f5f5f5);
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 999px;
|
||||
}
|
||||
.filter-bar-segment .filter-bar-chip {
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.filter-bar-segment .filter-bar-chip.agenda-chip-active {
|
||||
background: var(--color-surface, #ffffff);
|
||||
border-color: var(--color-border, #e5e7eb);
|
||||
}
|
||||
|
||||
.filter-bar-chip-pending {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.filter-bar-select {
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.filter-bar-trailing {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.filter-bar {
|
||||
gap: 0.6rem 0.7rem;
|
||||
padding: 0.6rem;
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
.filter-bar-trailing {
|
||||
margin-left: 0;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.filter-bar-chip-row {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Save-as-view modal — anchored as a native <dialog>. */
|
||||
.filter-bar-save-modal::backdrop {
|
||||
background: rgba(15, 23, 42, 0.4);
|
||||
}
|
||||
|
||||
.filter-bar-save-modal {
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 0.85rem;
|
||||
padding: 0;
|
||||
max-width: 28rem;
|
||||
width: calc(100% - 2rem);
|
||||
background: var(--color-surface, #ffffff);
|
||||
color: var(--color-text, #111827);
|
||||
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.filter-bar-save-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.85rem;
|
||||
padding: 1.1rem 1.25rem 1.25rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.filter-bar-save-form h2 {
|
||||
margin: 0 0 0.35rem 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.filter-bar-save-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
.filter-bar-save-field span {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
.filter-bar-save-field input {
|
||||
padding: 0.45rem 0.6rem;
|
||||
border: 1px solid var(--color-border, #e5e7eb);
|
||||
border-radius: 0.4rem;
|
||||
background: var(--color-surface, #ffffff);
|
||||
color: var(--color-text, #111827);
|
||||
font: inherit;
|
||||
}
|
||||
.filter-bar-save-field small {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted, #6b7280);
|
||||
}
|
||||
|
||||
.filter-bar-save-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.filter-bar-save-error {
|
||||
margin: 0;
|
||||
color: var(--status-red-fg, #c54);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.filter-bar-save-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
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;
|
||||
}
|
||||
|
||||
4
internal/db/migrations/072_projects_our_side.down.sql
Normal file
4
internal/db/migrations/072_projects_our_side.down.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Reverse t-paliad-164: drop the our_side column + check constraint.
|
||||
|
||||
ALTER TABLE paliad.projects DROP CONSTRAINT IF EXISTS projects_our_side_check;
|
||||
ALTER TABLE paliad.projects DROP COLUMN IF EXISTS our_side;
|
||||
42
internal/db/migrations/072_projects_our_side.up.sql
Normal file
42
internal/db/migrations/072_projects_our_side.up.sql
Normal file
@@ -0,0 +1,42 @@
|
||||
-- t-paliad-164 / m's 2026-05-08 21:42 dogfood feedback: when the user
|
||||
-- selects an Akte in the Determinator (Slice 3c perspective chip),
|
||||
-- the chip should already be locked to the firm's known side instead
|
||||
-- of asking the user to re-pick something the project already knows.
|
||||
--
|
||||
-- Add a project-level our_side text column. NULL = unknown / not set
|
||||
-- (default), so existing projects stay neutral and the Determinator
|
||||
-- falls back to free-pick. The chip values mirror event_categories.
|
||||
-- party so the Determinator can predefine the chip without mapping.
|
||||
--
|
||||
-- 'court' is allowed for completeness (paliad runs internal projects
|
||||
-- where the firm represents the court / a tribunal-side stakeholder
|
||||
-- — rare but real); the Determinator currently only acts on
|
||||
-- claimant / defendant.
|
||||
--
|
||||
-- Idempotent so re-runs against a partially-applied state stay safe
|
||||
-- (live tracker is at v71; paliad has been bitten by collisions
|
||||
-- twice this week, see m/paliad#15 commits and dirac's mig 070).
|
||||
|
||||
ALTER TABLE paliad.projects
|
||||
ADD COLUMN IF NOT EXISTS our_side text;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_constraint
|
||||
WHERE conname = 'projects_our_side_check'
|
||||
AND conrelid = 'paliad.projects'::regclass
|
||||
) THEN
|
||||
ALTER TABLE paliad.projects
|
||||
ADD CONSTRAINT projects_our_side_check
|
||||
CHECK (our_side IS NULL
|
||||
OR our_side IN ('claimant', 'defendant', 'court', 'both'));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
COMMENT ON COLUMN paliad.projects.our_side IS
|
||||
'Which side the firm represents on this project. Used by the '
|
||||
'Fristenrechner Determinator (Slice 3c) to predefine the '
|
||||
'perspective chip from the project context. Allowed: claimant, '
|
||||
'defendant, court, both. NULL = unknown / not set; Determinator '
|
||||
'falls back to free-pick.';
|
||||
@@ -0,0 +1,2 @@
|
||||
-- t-paliad-165 down: drop the concept→event_type junction.
|
||||
DROP TABLE IF EXISTS paliad.deadline_concept_event_types;
|
||||
113
internal/db/migrations/073_deadline_concept_event_types.up.sql
Normal file
113
internal/db/migrations/073_deadline_concept_event_types.up.sql
Normal file
@@ -0,0 +1,113 @@
|
||||
-- t-paliad-165: junction paliad.deadline_concept_event_types — maps each
|
||||
-- deadline_concept to the canonical paliad.event_types row(s) that
|
||||
-- represent it on the Typ chip cluster of the deadline create form.
|
||||
--
|
||||
-- Why this exists
|
||||
-- ---------------
|
||||
-- The deadline create form (/projects/{id}/deadlines/new and the global
|
||||
-- /deadlines/new) lets the user pick a Regel (paliad.deadline_rules) AND
|
||||
-- independently pick a Typ (paliad.event_types). They are decoupled, so a
|
||||
-- user can save a deadline whose Regel is `damages.rejoin — Duplik` but
|
||||
-- Typ is `Klageerwiderung` — two different legal events. m hit this
|
||||
-- contradiction during 2026-05-08 dogfooding (Gitea m/paliad#18).
|
||||
--
|
||||
-- Each rule already carries paliad.deadline_rules.concept_id (mig 040),
|
||||
-- so the rule knows what legal idea it represents. What was missing was
|
||||
-- the canonical event_type for that concept. Slug-pattern heuristics are
|
||||
-- unreliable (concept `notice-of-appeal` ↔ event_type
|
||||
-- `upc_statement_of_appeal_2201`) and many concepts have multiple
|
||||
-- candidate event_types (`statement-of-defence` ↔ base + with_ccr +
|
||||
-- no_ccr); this junction makes the mapping explicit and curated.
|
||||
--
|
||||
-- Shape
|
||||
-- -----
|
||||
-- Many-to-many, so concepts that genuinely have several candidate types
|
||||
-- (with_ccr / no_ccr / base; UPC + EPO + DPMA opposition) get one row
|
||||
-- per type. is_default picks the single row the create-form auto-fills
|
||||
-- when the user picks a Regel attached to this concept. The remaining
|
||||
-- rows are reserved for future surfaces (e.g. Determinator save flow
|
||||
-- might want to see all candidates) but the create-form only consumes
|
||||
-- is_default for now.
|
||||
--
|
||||
-- Idempotent against re-seeds: the seed below uses ON CONFLICT DO
|
||||
-- NOTHING so a second run after manual mapping additions doesn't blow
|
||||
-- them away. Down migration drops the table entirely.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.deadline_concept_event_types (
|
||||
concept_id uuid NOT NULL
|
||||
REFERENCES paliad.deadline_concepts(id) ON DELETE CASCADE,
|
||||
event_type_id uuid NOT NULL
|
||||
REFERENCES paliad.event_types(id) ON DELETE CASCADE,
|
||||
is_default bool NOT NULL DEFAULT false,
|
||||
sort_order int NOT NULL DEFAULT 100,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (concept_id, event_type_id)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE paliad.deadline_concept_event_types IS
|
||||
'Junction mapping paliad.deadline_concepts → paliad.event_types. '
|
||||
'Lets the deadline create form auto-populate the Typ chip when the '
|
||||
'user picks a Regel — the rule''s concept points here for its '
|
||||
'canonical event_type(s). Many-to-many for concepts with several '
|
||||
'natural variants (with_ccr / no_ccr / base, EPO + DPMA opposition).';
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_concept_event_types.is_default IS
|
||||
'Exactly one row per concept_id should be marked default — that is '
|
||||
'the row the create-form chip cluster auto-fills with. Other rows '
|
||||
'remain selectable from the picker as alternatives.';
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS deadline_concept_event_types_one_default
|
||||
ON paliad.deadline_concept_event_types (concept_id)
|
||||
WHERE is_default = true;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS deadline_concept_event_types_event_type
|
||||
ON paliad.deadline_concept_event_types (event_type_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- Seed: curated mapping for active concepts that drive existing rules.
|
||||
--
|
||||
-- Concepts without an obvious event_type counterpart (filing, grant,
|
||||
-- decision, publication, communication-r71-3, search-report, the various
|
||||
-- DE-only Begründung concepts) stay unmapped — auto-fill silently
|
||||
-- skips them, leaving the user to pick a Typ manually as today.
|
||||
-- Future migrations can fill those gaps as event_types are added.
|
||||
-- ============================================================================
|
||||
|
||||
INSERT INTO paliad.deadline_concept_event_types (concept_id, event_type_id, is_default, sort_order)
|
||||
SELECT dc.id, et.id, mapping.is_default, mapping.sort_order
|
||||
FROM (VALUES
|
||||
-- (concept_slug, event_type_slug, is_default, sort_order)
|
||||
('application-for-cost-decision', 'upc_application_for_cost_decision', true, 10),
|
||||
('application-for-determination-of-damages', 'upc_application_for_damages', true, 10),
|
||||
('application-for-revocation', 'upc_statement_for_revocation', true, 10),
|
||||
('application-for-provisional-measures', 'upc_protective_letter', true, 10),
|
||||
('cost-decision', 'upc_decision_on_costs', true, 10),
|
||||
('counterclaim-for-infringement', 'upc_counterclaim_for_infringement', true, 10),
|
||||
('counterclaim-for-revocation', 'upc_counterclaim_for_revocation', true, 10),
|
||||
('cross-appeal', 'upc_cross_appeal_2242a', true, 10),
|
||||
('defence-to-application-to-amend', 'upc_defence_to_amend_patent', true, 10),
|
||||
('defence-to-counterclaim-for-revocation', 'upc_defence_to_revocation', true, 10),
|
||||
('notice-of-appeal', 'upc_statement_of_appeal_2201', true, 10),
|
||||
('opposition', 'epo_opposition_filing', true, 10),
|
||||
('opposition', 'dpma_opposition', false, 20),
|
||||
('oral-hearing', 'upc_oral_hearing', true, 10),
|
||||
('order', 'upc_case_management_order', true, 10),
|
||||
('rejoinder', 'upc_rejoinder_to_reply', true, 10),
|
||||
('reply-to-cross-appeal', 'upc_cross_appeal_2242a', true, 10),
|
||||
('reply-to-defence', 'upc_reply_to_defence', true, 10),
|
||||
('reply-to-defence-to-application-to-amend', 'upc_reply_to_defence_to_amend_patent', true, 10),
|
||||
('reply-to-defence-to-counterclaim-for-revocation','upc_reply_to_defence_to_revocation', true, 10),
|
||||
('request-for-examination', 'dpma_examination_request', true, 10),
|
||||
('request-to-lay-open-books', 'upc_request_to_lay_open_books', true, 10),
|
||||
('response-to-appeal', 'upc_grounds_of_appeal_2242a', true, 10),
|
||||
('statement-of-claim', 'upc_statement_of_claim', true, 10),
|
||||
('statement-of-defence', 'upc_statement_of_defence', true, 10),
|
||||
('statement-of-defence', 'upc_statement_of_defence_with_ccr', false, 20),
|
||||
('statement-of-defence', 'upc_statement_of_defence_no_ccr', false, 30),
|
||||
('statement-of-grounds-of-appeal', 'upc_grounds_of_appeal_2242a', true, 10),
|
||||
('statement-of-grounds-of-appeal', 'epo_appeal_grounds', false, 20)
|
||||
) AS mapping(concept_slug, event_type_slug, is_default, sort_order)
|
||||
JOIN paliad.deadline_concepts dc ON dc.slug = mapping.concept_slug
|
||||
JOIN paliad.event_types et ON et.slug = mapping.event_type_slug
|
||||
AND et.archived_at IS NULL
|
||||
ON CONFLICT (concept_id, event_type_id) DO NOTHING;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- t-paliad-165 follow-up down: remove jurisdiction column + restore the
|
||||
-- old one-default-per-concept index. The added jurisdictional default
|
||||
-- rows are kept (harmless without the index), but this isn't an
|
||||
-- expected operation in production.
|
||||
|
||||
DROP INDEX IF EXISTS paliad.deadline_concept_event_types_one_default_per_jur;
|
||||
|
||||
ALTER TABLE paliad.deadline_concept_event_types
|
||||
DROP COLUMN IF EXISTS jurisdiction;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS deadline_concept_event_types_one_default
|
||||
ON paliad.deadline_concept_event_types (concept_id)
|
||||
WHERE is_default = true;
|
||||
@@ -0,0 +1,143 @@
|
||||
-- t-paliad-165 follow-up (m's 2026-05-08 22:08 dogfood): add jurisdiction
|
||||
-- to paliad.deadline_concept_event_types so DE rules don't auto-fill a
|
||||
-- UPC event_type and vice versa.
|
||||
--
|
||||
-- Bug being fixed
|
||||
-- ---------------
|
||||
-- m: rule '§ 276 Abs. 1 S. 2 ZPO — Klageerwiderung' (DE) auto-filled to
|
||||
-- 'Klageerwiderung' but the chosen event_type was upc_statement_of_defence
|
||||
-- (UPC). Both render as 'Klageerwiderung' in the UI, but they are
|
||||
-- different legal events in different jurisdictions — the auto-link is
|
||||
-- technically wrong even though the label looks right.
|
||||
--
|
||||
-- Root cause
|
||||
-- ----------
|
||||
-- Migration 073 made the junction one-default-per-concept. The same
|
||||
-- legal concept ('statement-of-defence' = Klageerwiderung) has several
|
||||
-- jurisdictional flavours (upc_statement_of_defence, de_klageerwiderung,
|
||||
-- DPMA Erwiderung, EPA Patentinhaber-Erwiderung). The default was
|
||||
-- jurisdiction-blind — it always picked the UPC variant.
|
||||
--
|
||||
-- Fix
|
||||
-- ---
|
||||
-- 1. Add a jurisdiction text column to the junction.
|
||||
-- 2. Backfill from each event_type's own jurisdiction.
|
||||
-- 3. Replace the unique-default index with a (concept_id, jurisdiction)
|
||||
-- pair so each concept can carry one default per jurisdiction.
|
||||
-- 4. Add jurisdictional defaults where a non-UPC event_type genuinely
|
||||
-- exists (DE Klageerwiderung, DPMA / EPO opposition + appeal).
|
||||
--
|
||||
-- Lookup contract (consumed by the rule-service hydrator)
|
||||
-- -------------------------------------------------------
|
||||
-- For a rule with proceeding_types.jurisdiction = J, the auto-fill
|
||||
-- looks up the row WHERE is_default AND jurisdiction = J. EPA→EPO
|
||||
-- canonicalisation lives in the Go service (proceeding_types use 'EPA'
|
||||
-- but event_types use 'EPO' — the two columns disagreed before this
|
||||
-- mapping table existed). When NO row matches the rule's jurisdiction,
|
||||
-- the auto-fill silently no-ops; better than a wrong default.
|
||||
|
||||
ALTER TABLE paliad.deadline_concept_event_types
|
||||
ADD COLUMN IF NOT EXISTS jurisdiction text;
|
||||
|
||||
COMMENT ON COLUMN paliad.deadline_concept_event_types.jurisdiction IS
|
||||
'Which jurisdiction this default applies to. Matches the rule''s '
|
||||
'proceeding_types.jurisdiction (UPC / DE / DPMA / EPO). EPA→EPO '
|
||||
'canonicalisation is done service-side. NULL = applies to any '
|
||||
'jurisdiction (the catch-all fallback — currently unused).';
|
||||
|
||||
-- Backfill jurisdiction from the event_type's own column.
|
||||
UPDATE paliad.deadline_concept_event_types j
|
||||
SET jurisdiction = et.jurisdiction
|
||||
FROM paliad.event_types et
|
||||
WHERE j.event_type_id = et.id
|
||||
AND j.jurisdiction IS NULL;
|
||||
|
||||
-- Replace the old unique-default index (one default per concept) with
|
||||
-- one default per (concept, jurisdiction). We DROP IF EXISTS so the
|
||||
-- migration is rerunnable against a freshly-rebuilt schema.
|
||||
DROP INDEX IF EXISTS paliad.deadline_concept_event_types_one_default;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS deadline_concept_event_types_one_default_per_jur
|
||||
ON paliad.deadline_concept_event_types (concept_id, jurisdiction)
|
||||
WHERE is_default = true;
|
||||
|
||||
-- ============================================================================
|
||||
-- Demote then re-elect defaults so each (concept, jurisdiction) pair is
|
||||
-- correctly anchored. Rows that never had jurisdiction picked stay as
|
||||
-- non-defaults until a curated row beats them.
|
||||
-- ============================================================================
|
||||
|
||||
-- statement-of-defence DE: de_klageerwiderung becomes the DE default.
|
||||
INSERT INTO paliad.deadline_concept_event_types (concept_id, event_type_id, is_default, sort_order, jurisdiction)
|
||||
SELECT dc.id, et.id, true, 10, 'DE'
|
||||
FROM paliad.deadline_concepts dc
|
||||
JOIN paliad.event_types et ON et.slug = 'de_klageerwiderung' AND et.archived_at IS NULL
|
||||
WHERE dc.slug = 'statement-of-defence'
|
||||
ON CONFLICT (concept_id, event_type_id) DO UPDATE
|
||||
SET is_default = true, jurisdiction = 'DE', sort_order = 10;
|
||||
|
||||
-- opposition: split per jurisdiction. EPO is the canonical EU-wide
|
||||
-- pre-grant Einspruch, DPMA is the German national variant.
|
||||
UPDATE paliad.deadline_concept_event_types j
|
||||
SET is_default = true, jurisdiction = 'EPO', sort_order = 10
|
||||
FROM paliad.deadline_concepts dc, paliad.event_types et
|
||||
WHERE j.concept_id = dc.id
|
||||
AND j.event_type_id = et.id
|
||||
AND dc.slug = 'opposition'
|
||||
AND et.slug = 'epo_opposition_filing';
|
||||
|
||||
UPDATE paliad.deadline_concept_event_types j
|
||||
SET is_default = true, jurisdiction = 'DPMA', sort_order = 20
|
||||
FROM paliad.deadline_concepts dc, paliad.event_types et
|
||||
WHERE j.concept_id = dc.id
|
||||
AND j.event_type_id = et.id
|
||||
AND dc.slug = 'opposition'
|
||||
AND et.slug = 'dpma_opposition';
|
||||
|
||||
-- request-for-examination: DPMA is the only jurisdiction with an
|
||||
-- event_type counterpart (EP-grant exam request has no event_type yet).
|
||||
UPDATE paliad.deadline_concept_event_types j
|
||||
SET is_default = true, jurisdiction = 'DPMA'
|
||||
FROM paliad.deadline_concepts dc, paliad.event_types et
|
||||
WHERE j.concept_id = dc.id
|
||||
AND j.event_type_id = et.id
|
||||
AND dc.slug = 'request-for-examination'
|
||||
AND et.slug = 'dpma_examination_request';
|
||||
|
||||
-- notice-of-appeal: keep the UPC default (already set by mig 073) AND
|
||||
-- add EPO + DPMA jurisdictional variants for non-UPC rules.
|
||||
INSERT INTO paliad.deadline_concept_event_types (concept_id, event_type_id, is_default, sort_order, jurisdiction)
|
||||
SELECT dc.id, et.id, true, 10, 'EPO'
|
||||
FROM paliad.deadline_concepts dc
|
||||
JOIN paliad.event_types et ON et.slug = 'epo_appeal_notice' AND et.archived_at IS NULL
|
||||
WHERE dc.slug = 'notice-of-appeal'
|
||||
ON CONFLICT (concept_id, event_type_id) DO UPDATE
|
||||
SET is_default = true, jurisdiction = 'EPO', sort_order = 10;
|
||||
|
||||
INSERT INTO paliad.deadline_concept_event_types (concept_id, event_type_id, is_default, sort_order, jurisdiction)
|
||||
SELECT dc.id, et.id, true, 10, 'DPMA'
|
||||
FROM paliad.deadline_concepts dc
|
||||
JOIN paliad.event_types et ON et.slug = 'dpma_appeal' AND et.archived_at IS NULL
|
||||
WHERE dc.slug = 'notice-of-appeal'
|
||||
ON CONFLICT (concept_id, event_type_id) DO UPDATE
|
||||
SET is_default = true, jurisdiction = 'DPMA', sort_order = 10;
|
||||
|
||||
-- statement-of-grounds-of-appeal: keep UPC default; add EPO variant.
|
||||
UPDATE paliad.deadline_concept_event_types j
|
||||
SET is_default = true, jurisdiction = 'EPO', sort_order = 10
|
||||
FROM paliad.deadline_concepts dc, paliad.event_types et
|
||||
WHERE j.concept_id = dc.id
|
||||
AND j.event_type_id = et.id
|
||||
AND dc.slug = 'statement-of-grounds-of-appeal'
|
||||
AND et.slug = 'epo_appeal_grounds';
|
||||
|
||||
-- ============================================================================
|
||||
-- Final pass: any junction row that still has NULL jurisdiction (none
|
||||
-- expected after the backfill, but defensive) gets its event_type's
|
||||
-- jurisdiction copied so the partial-unique index is well-defined.
|
||||
-- ============================================================================
|
||||
UPDATE paliad.deadline_concept_event_types j
|
||||
SET jurisdiction = et.jurisdiction
|
||||
FROM paliad.event_types et
|
||||
WHERE j.event_type_id = et.id
|
||||
AND j.jurisdiction IS NULL;
|
||||
@@ -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
|
||||
|
||||
@@ -156,6 +156,21 @@ type Project struct {
|
||||
CaseNumber *string `db:"case_number" json:"case_number,omitempty"`
|
||||
ProceedingTypeID *int `db:"proceeding_type_id" json:"proceeding_type_id,omitempty"`
|
||||
|
||||
// OurSide is which side the firm represents on this project. Used
|
||||
// by the Fristenrechner Determinator to predefine the perspective
|
||||
// chip from the project context (t-paliad-164). NULL = unknown /
|
||||
// not set; Determinator falls back to free-pick. Allowed values:
|
||||
// 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"`
|
||||
@@ -468,6 +483,12 @@ type DeadlineRule struct {
|
||||
AltRuleCode *string `db:"alt_rule_code" json:"alt_rule_code,omitempty"`
|
||||
AnchorAlt *string `db:"anchor_alt" json:"anchor_alt,omitempty"`
|
||||
ConceptID *uuid.UUID `db:"concept_id" json:"concept_id,omitempty"`
|
||||
// ConceptDefaultEventTypeID is the canonical paliad.event_types row for
|
||||
// this rule's concept (joined via paliad.deadline_concept_event_types
|
||||
// where is_default = true). Lets the deadline create form auto-populate
|
||||
// the Typ chip when the user picks this rule. Hydrated by the service
|
||||
// layer; not a column. NULL when the concept has no mapped event_type.
|
||||
ConceptDefaultEventTypeID *uuid.UUID `db:"-" json:"concept_default_event_type_id,omitempty"`
|
||||
LegalSource *string `db:"legal_source" json:"legal_source,omitempty"`
|
||||
IsSpawn bool `db:"is_spawn" json:"is_spawn"`
|
||||
SpawnLabel *string `db:"spawn_label" json:"spawn_label,omitempty"`
|
||||
|
||||
@@ -32,6 +32,9 @@ const proceedingTypeColumns = `id, code, name, name_en, description, jurisdictio
|
||||
category, default_color, sort_order, is_active`
|
||||
|
||||
// List returns active rules, optionally filtered by proceeding type.
|
||||
// Each row has ConceptDefaultEventTypeID hydrated from
|
||||
// paliad.deadline_concept_event_types so the deadline-create form can
|
||||
// auto-populate the Typ chip when the user picks a Regel.
|
||||
func (s *DeadlineRuleService) List(ctx context.Context, proceedingTypeID *int) ([]models.DeadlineRule, error) {
|
||||
var rules []models.DeadlineRule
|
||||
var err error
|
||||
@@ -52,9 +55,71 @@ func (s *DeadlineRuleService) List(ctx context.Context, proceedingTypeID *int) (
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list deadline rules: %w", err)
|
||||
}
|
||||
if err := s.hydrateConceptDefaultEventTypes(ctx, rules); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rules, nil
|
||||
}
|
||||
|
||||
// hydrateConceptDefaultEventTypes resolves each rule's (concept_id,
|
||||
// proceeding_type.jurisdiction) pair to the canonical paliad.event_types
|
||||
// row from paliad.deadline_concept_event_types (where is_default and
|
||||
// jurisdiction matches), and assigns it to ConceptDefaultEventTypeID.
|
||||
//
|
||||
// One round-trip via JOIN to paliad.proceeding_types so we can match on
|
||||
// the rule's jurisdiction without a per-rule second query. EPA→EPO
|
||||
// canonicalisation is done in SQL because event_types use 'EPO' but
|
||||
// proceeding_types use 'EPA' — the two columns disagreed before this
|
||||
// mapping table existed (mig 074).
|
||||
//
|
||||
// Rules whose (concept, jurisdiction) has no default stay NULL —
|
||||
// silent no-op on the form, better than a wrong-jurisdiction default.
|
||||
func (s *DeadlineRuleService) hydrateConceptDefaultEventTypes(ctx context.Context, rules []models.DeadlineRule) error {
|
||||
ruleIDs := make([]uuid.UUID, 0, len(rules))
|
||||
for _, r := range rules {
|
||||
if r.ConceptID == nil {
|
||||
continue
|
||||
}
|
||||
ruleIDs = append(ruleIDs, r.ID)
|
||||
}
|
||||
if len(ruleIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
query, args, err := sqlx.In(
|
||||
`SELECT dr.id AS rule_id, j.event_type_id
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
JOIN paliad.deadline_concept_event_types j
|
||||
ON j.concept_id = dr.concept_id
|
||||
AND j.is_default = true
|
||||
AND j.jurisdiction = CASE WHEN pt.jurisdiction = 'EPA' THEN 'EPO' ELSE pt.jurisdiction END
|
||||
WHERE dr.id IN (?)`, ruleIDs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("build rule→event_type IN query: %w", err)
|
||||
}
|
||||
query = s.db.Rebind(query)
|
||||
|
||||
type row struct {
|
||||
RuleID uuid.UUID `db:"rule_id"`
|
||||
EventTypeID uuid.UUID `db:"event_type_id"`
|
||||
}
|
||||
var rows []row
|
||||
if err := s.db.SelectContext(ctx, &rows, query, args...); err != nil {
|
||||
return fmt.Errorf("load rule→event_type defaults: %w", err)
|
||||
}
|
||||
defaultByRule := make(map[uuid.UUID]uuid.UUID, len(rows))
|
||||
for _, r := range rows {
|
||||
defaultByRule[r.RuleID] = r.EventTypeID
|
||||
}
|
||||
for i := range rules {
|
||||
if et, ok := defaultByRule[rules[i].ID]; ok {
|
||||
etCopy := et
|
||||
rules[i].ConceptDefaultEventTypeID = &etCopy
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RuleTreeNode pairs a rule with its child rules in a parent_id hierarchy.
|
||||
type RuleTreeNode struct {
|
||||
models.DeadlineRule
|
||||
|
||||
@@ -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, 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 {
|
||||
@@ -121,6 +121,14 @@ type CreateProjectInput struct {
|
||||
Court *string `json:"court,omitempty"`
|
||||
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.
|
||||
@@ -144,6 +152,7 @@ type UpdateProjectInput struct {
|
||||
Court *string `json:"court,omitempty"`
|
||||
CaseNumber *string `json:"case_number,omitempty"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
OurSide *string `json:"our_side,omitempty"`
|
||||
}
|
||||
|
||||
// ListFilter narrows List results. Zero-value → no filter.
|
||||
@@ -819,14 +828,20 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
|
||||
|
||||
// path is NOT NULL but the trigger populates it; supply a placeholder
|
||||
// the trigger will overwrite. (BEFORE INSERT trigger rewrites path.)
|
||||
if input.OurSide != nil {
|
||||
if err := validateOurSide(*input.OurSide); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects
|
||||
(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, 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, '{}'::jsonb, $21, $21)`,
|
||||
$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,
|
||||
@@ -834,6 +849,8 @@ func (s *ProjectService) Create(ctx context.Context, userID uuid.UUID, input Cre
|
||||
input.ClientNumber, input.MatterNumber, input.NetDocumentsURL,
|
||||
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)
|
||||
@@ -967,6 +984,12 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
|
||||
if input.ProceedingTypeID != nil {
|
||||
appendSetSkippable("proceeding_type_id", *input.ProceedingTypeID)
|
||||
}
|
||||
if input.OurSide != nil {
|
||||
if err := validateOurSide(*input.OurSide); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendSet("our_side", nullableOurSide(input.OurSide))
|
||||
}
|
||||
if typeChanged {
|
||||
for _, col := range typeSpecificColumns(current.Type) {
|
||||
appendSet(col, nil)
|
||||
@@ -1012,6 +1035,32 @@ func (s *ProjectService) Update(ctx context.Context, userID, id uuid.UUID, input
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
// our_side change: log when the value (or its set/unset state) actually
|
||||
// flips. Description follows the same value-only "old → new" pattern as
|
||||
// status_changed; frontend renderer maps the slugs to localized labels
|
||||
// (claimant / defendant / court / both / "—" for NULL).
|
||||
if input.OurSide != nil {
|
||||
nextOS := strings.TrimSpace(*input.OurSide)
|
||||
prevOS := ""
|
||||
if current.OurSide != nil {
|
||||
prevOS = *current.OurSide
|
||||
}
|
||||
if nextOS != prevOS {
|
||||
from := prevOS
|
||||
if from == "" {
|
||||
from = "none"
|
||||
}
|
||||
to := nextOS
|
||||
if to == "" {
|
||||
to = "none"
|
||||
}
|
||||
desc := fmt.Sprintf("%s → %s", from, to)
|
||||
descPtr := &desc
|
||||
if err := insertProjectEvent(ctx, tx, id, userID, "our_side_changed", "Represented side changed", descPtr); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return nil, fmt.Errorf("commit update project: %w", err)
|
||||
}
|
||||
@@ -1056,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
|
||||
|
||||
@@ -1518,6 +1829,35 @@ func validateProjectStatus(s string) error {
|
||||
return fmt.Errorf("%w: invalid status %q", ErrInvalidInput, s)
|
||||
}
|
||||
|
||||
// validateOurSide checks the project-level "represented side" enum
|
||||
// (t-paliad-164). Empty string is the explicit "clear" sentinel —
|
||||
// callers pass the value as-is from the form payload, and the helper
|
||||
// accepts it so an Update can null the column. The DB-level CHECK
|
||||
// constraint enforces the same set; this validation gives a clearer
|
||||
// error than relying on the constraint to fire.
|
||||
func validateOurSide(s string) error {
|
||||
switch strings.TrimSpace(s) {
|
||||
case "", "claimant", "defendant", "court", "both":
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("%w: invalid our_side %q", ErrInvalidInput, s)
|
||||
}
|
||||
|
||||
// nullableOurSide returns nil for an empty / whitespace value so the
|
||||
// SQL driver writes NULL, otherwise the trimmed string. Mirrors the
|
||||
// Update payload contract: empty string from the form clears the
|
||||
// column, a value sets it.
|
||||
func nullableOurSide(p *string) any {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
v := strings.TrimSpace(*p)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func sortByOrder(xs []models.Project, order map[uuid.UUID]int) {
|
||||
// Insertion sort — ancestor lists are short (<20).
|
||||
for i := 1; i < len(xs); i++ {
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -45,10 +45,23 @@ type RenderSpec struct {
|
||||
// ListConfig is the per-shape config for shape=list. Powers both the
|
||||
// /events table look (density=comfortable) and the activity-feed look
|
||||
// (density=compact + actor/time columns).
|
||||
//
|
||||
// RowAction tells shape-list which row interaction to wire when the
|
||||
// universal <FilterBar> renders the table. "navigate" (the default and
|
||||
// the contract for the existing /agenda/dashboard surfaces) routes a
|
||||
// row click to a per-kind detail page. "complete_toggle" is the
|
||||
// /events deadline-row pattern (checkbox + reopen button). "approve"
|
||||
// is the /inbox approver row (approve/reject buttons + revoke). "none"
|
||||
// is read-only (audit views, retrospective lists).
|
||||
//
|
||||
// shape-list.ts honours this when emitting the table's `entity-table`
|
||||
// classes — `entity-table--readonly` plus `none` skips the navigate
|
||||
// handler entirely.
|
||||
type ListConfig struct {
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
Sort SortOrder `json:"sort,omitempty"`
|
||||
Density ListDensity `json:"density,omitempty"`
|
||||
Columns []string `json:"columns,omitempty"`
|
||||
Sort SortOrder `json:"sort,omitempty"`
|
||||
Density ListDensity `json:"density,omitempty"`
|
||||
RowAction ListRowAction `json:"row_action,omitempty"`
|
||||
}
|
||||
|
||||
// CardsConfig is the per-shape config for shape=cards.
|
||||
@@ -78,6 +91,29 @@ const (
|
||||
DensityCompact ListDensity = "compact"
|
||||
)
|
||||
|
||||
// ListRowAction identifies which row interaction the list-shape renderer
|
||||
// should wire. Defaults to RowActionNavigate when empty so existing
|
||||
// SystemView definitions and saved user views continue to render rows
|
||||
// that route to the per-kind detail page.
|
||||
type ListRowAction string
|
||||
|
||||
const (
|
||||
RowActionNavigate ListRowAction = "navigate"
|
||||
RowActionCompleteToggle ListRowAction = "complete_toggle"
|
||||
RowActionApprove ListRowAction = "approve"
|
||||
RowActionNone ListRowAction = "none"
|
||||
)
|
||||
|
||||
// KnownRowActions is the registry the validator checks against. Adding a
|
||||
// new action = add a const above AND append here AND extend
|
||||
// shape-list.ts's switch.
|
||||
var KnownRowActions = []ListRowAction{
|
||||
RowActionNavigate,
|
||||
RowActionCompleteToggle,
|
||||
RowActionApprove,
|
||||
RowActionNone,
|
||||
}
|
||||
|
||||
type CardsGroupBy string
|
||||
|
||||
const (
|
||||
@@ -148,6 +184,9 @@ func (c *ListConfig) validate() error {
|
||||
default:
|
||||
return fmt.Errorf("%w: unknown list.density %q", ErrInvalidInput, c.Density)
|
||||
}
|
||||
if c.RowAction != "" && !slices.Contains(KnownRowActions, c.RowAction) {
|
||||
return fmt.Errorf("%w: unknown list.row_action %q", ErrInvalidInput, c.RowAction)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -75,6 +75,26 @@ func TestRenderSpec_CalendarViewEnum(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_RowActionEnum(t *testing.T) {
|
||||
for _, action := range KnownRowActions {
|
||||
t.Run(string(action), func(t *testing.T) {
|
||||
s := RenderSpec{Shape: ShapeList, List: &ListConfig{RowAction: action}}
|
||||
if err := s.Validate(); err != nil {
|
||||
t.Fatalf("known row_action %q must validate: %v", action, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
s := RenderSpec{Shape: ShapeList, List: &ListConfig{RowAction: "delete"}}
|
||||
if err := s.Validate(); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Fatalf("unknown row_action must reject, got %v", err)
|
||||
}
|
||||
// Empty defaults to navigate at the renderer level — schema accepts.
|
||||
empty := RenderSpec{Shape: ShapeList, List: &ListConfig{}}
|
||||
if err := empty.Validate(); err != nil {
|
||||
t.Fatalf("empty row_action must validate (defaults to navigate): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderSpec_RoundTrip(t *testing.T) {
|
||||
original := RenderSpec{
|
||||
Shape: ShapeList,
|
||||
|
||||
@@ -101,8 +101,17 @@ func EventsSystemView() SystemView {
|
||||
}
|
||||
|
||||
// InboxSystemView returns the SystemView definition for /inbox — the
|
||||
// 4-eye approval surface (the "Zur Genehmigung" tab). The "Meine
|
||||
// Anfragen" tab is a sibling spec resolved by tab-state on the page.
|
||||
// 4-eye approval surface. The bar's approval_viewer_role chip
|
||||
// cluster narrows to "Zur Genehmigung" / "Eigene Anfragen" /
|
||||
// "Alle sichtbaren". Default is "any_visible" so the page lands on
|
||||
// a populated view for every user (m's 2026-05-08 22:08 dogfood:
|
||||
// "the inbox somehow does not show nothing no more" — the prior
|
||||
// default was approver_eligible, which is empty for users who only
|
||||
// SUBMIT requests and have nothing to approve themselves).
|
||||
//
|
||||
// RowAction = RowActionApprove → shape-list.ts renders the approval
|
||||
// row layout (entity title + diff + approve/reject/revoke buttons)
|
||||
// and the surface wires action handlers via the rendered data-attrs.
|
||||
func InboxSystemView() SystemView {
|
||||
return SystemView{
|
||||
Slug: "inbox",
|
||||
@@ -114,7 +123,7 @@ func InboxSystemView() SystemView {
|
||||
Time: TimeSpec{Horizon: HorizonAny, Field: FieldAuto},
|
||||
Predicates: map[DataSource]Predicates{
|
||||
SourceApprovalRequest: {ApprovalRequest: &ApprovalRequestPredicates{
|
||||
ViewerRole: "approver_eligible",
|
||||
ViewerRole: "any_visible",
|
||||
Status: []string{"pending"},
|
||||
}},
|
||||
},
|
||||
@@ -122,14 +131,17 @@ func InboxSystemView() SystemView {
|
||||
Render: RenderSpec{
|
||||
Shape: ShapeList,
|
||||
List: &ListConfig{
|
||||
Density: DensityComfortable,
|
||||
Sort: SortDateAsc,
|
||||
Density: DensityComfortable,
|
||||
Sort: SortDateAsc,
|
||||
RowAction: RowActionApprove,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// InboxRequesterSystemView is the "Meine Anfragen" tab of /inbox.
|
||||
// InboxRequesterSystemView is the "Eigene Anfragen" sibling view of
|
||||
// /inbox. Reachable via the bar's approval_viewer_role chip ("Eigene
|
||||
// Anfragen") on the /inbox surface, or as its own URL on /views/inbox-mine.
|
||||
func InboxRequesterSystemView() SystemView {
|
||||
return SystemView{
|
||||
Slug: "inbox-mine",
|
||||
@@ -148,8 +160,9 @@ func InboxRequesterSystemView() SystemView {
|
||||
Render: RenderSpec{
|
||||
Shape: ShapeList,
|
||||
List: &ListConfig{
|
||||
Density: DensityComfortable,
|
||||
Sort: SortDateAsc,
|
||||
Density: DensityComfortable,
|
||||
Sort: SortDateAsc,
|
||||
RowAction: RowActionApprove,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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