Files
paliad/docs/design-smart-timeline-2026-05-08.md
m f8cc86cd02 docs(t-paliad-169): SmartTimeline design — Verlauf-tab redesign with past+future+off-script
Inventor design for replacing the project-page Verlauf with a SmartTimeline that
composes past actuals (deadlines, appointments, structural project_events),
present, future-projected (deadline_rules calculator at read time), and
off-script events into one project-scoped vertical timeline.

Key calls:
- virtual view, no new top-level table; single optional column
  paliad.project_events.timeline_kind so a subset of audit rows surface as
  timeline content
- counterclaim = sub-project (new paliad.projects.counterclaim_of FK), parent
  renders parallel tracks; default our_side flips on creation
- date-anchoring reuses fristenrechner CalcOptions.AnchorOverrides — actuals
  anchor downstream projections automatically
- new ProjectionService.For(projectID) thin adapter over FristenrechnerService
- 3 new FilterBar axes (timeline_kind, timeline_status, timeline_track) +
  reuse of time, personal_only, deadline_event_type
- per-level aggregation rule: each level removes one tier of detail and adds
  one tier of grouping (Case → Patent → Litigation → Client)
- 4-slice phasing: skeleton, projection+anchor, counterclaim sub-project,
  parent-node aggregation

12 open questions for m before slice 1 PR opens. Inventor parks per gate
protocol; coder shift only after m's go-ahead.
2026-05-08 23:14:30 +02:00

51 KiB
Raw Permalink Blame History

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

-- 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:

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.

A new optional FK on paliad.projects:

-- 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:

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)

// 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:

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

// 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