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.
51 KiB
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 frompaliad.project_eventsvialoadEvents(id)atclient/projects-detail.ts:305. Pure audit log:event_typedistribution 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 aUIResponse{Deadlines []UIDeadline}keyed byrule_code.CalcOptions.AnchorOverrides map[string]stringlets 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_rulescarries 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_ccrenables 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_cciwork on UPC_REV.paliad.projects.our_sidecolumn 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 stubsdeadline_event_type+project_event_kindalready wired intoBarStateandAxisKey— 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=historypanel 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-logpage (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/fristenrechnerremains a knowledge-platform tool. paliad.project_eventskeeps its full audit-log role for/admin/audit-log.paliad.deadlines+paliad.appointmentsschemas 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
shapeswitching, 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:
- Three of the four zones already have authoritative tables.
paliad.deadlinesis the source-of-truth for legal deadlines (with completion + approval state);paliad.appointmentsfor hearings + court dates;paliad.project_eventsfor audit. Forcing a copy intotimeline_eventscreates a sync problem on every mutation. - 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.deadlineschange. 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 cachedFristenrechnerService(already memoised per request via service instantiation). - 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:
- 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.CalculatewithAnchorOverridesderived from completed actuals → emits Kind=projected rows for any rule that does not have a matchingpaliad.deadlines.rule_idrow. - 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.deadlinesschema — unchanged. (The existingoriginal_due_date,source, and the AnchorOverrides plumbing already cover "actual date anchors downstream", §6.)paliad.appointments— unchanged.paliad.deadline_rules— unchanged. The existingcondition_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-eventrow contract as today (cf. CLAUDE.md whole-card click rule), no::beforeoverlay.
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_eventsrow withtimeline_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_idis 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_numberreflects 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:
-- 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(statusdone) orpaliad.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_rulesentry that has a real deadline (not court-set), the action creates apaliad.deadlinesrow withrule_idset,due_date=entered,original_due_date=projected,source='anchor',status='done',completed_at=entered. (The "anchor" source is new; existing values aremanual,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.appointmentsrow withstart_at=entered,appointment_type='hearing'|'decision'|'order'derived from the rule'sevent_type. The appointment links back torule_codevia a new optional FK columnpaliad.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 = anytimeline_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 ifprojectedkind is hidden — the chip group is one logical "show future" toggle)timeline_track = all availableshape = timelinesort = 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:
- Three new axis keys (
timeline_kind,timeline_status,timeline_track). They render as chip clusters — the same primitivechipRow + chipBtnriemann already factored. shape: "timeline"is a new render shape. Existing shapes arelist | cards | calendar(t-paliad-144). We pick "timeline" as a 4th shape so the FilterBar's shape switcher lets the user collapse tolist(compact audit log) orcards(chronological card grid) without losing the data. Implementation = newfrontend/src/client/views/shape-timeline.tsmirroring the other shape files. Out of scope for t-paliad-170 (riemann ports the bar, not the new shape).- The
timeline_trackaxis options are dynamic — they depend on whether the project has a counterclaim child. The bar already supports lazy axes (theprojectaxis pattern inaxes.ts:30—"populated lazily").timeline_trackfollows 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:
internal/services/fristenrechner.go:Calculate(...)— the canonical Go implementation. Already returns aUIResponse{Deadlines []UIDeadline}keyed by rule_code, supportsAnchorOverrides. ~1000 lines, tested.frontend/src/client/fristenrechner.ts:calculate()— the frontend wrapper that POSTs/api/tools/fristenrechnerand 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.gokeeps 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.gowithFor()returning only actuals (deadlines + appointments + opted-inproject_events). Nofristenrechnercall yet. - Migration
NNN_project_events_timeline_kind.up.sqladds the optional column + partial index (§2.2). - New endpoint
GET /api/projects/{id}/timeline?…returning[]TimelineEvent. frontend/src/client/projects-detail.ts:loadEventsrewritten to call/timelineinstead 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_eventsALL — not justtimeline_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.ForcallsFristenrechnerService.Calculateand emits projected rows.- Click-to-anchor inline date editor (§6.2). New endpoint
POST /api/projects/{id}/timeline/anchortaking{rule_code, actual_date, kind?}and writing the appropriatepaliad.deadlines(source='anchor') orpaliad.appointments(deadline_rule_idFK new) row. - Migration
NNN_appointments_deadline_rule_id.up.sqladds the optional FK on appointments + extendspaliad.deadlines.sourceCHECK 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 newcounterclaim_ofFK + 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_sideflip,proceeding_type_id, and title, then navigates to it for the user to fill incase_number. ProjectionServiceloads CCR children + emits parallel-track rows.[Track ▼]chip in the header — readsavailable_tracksfrom the timeline response.- The two-column rendering on State C (§3.3).
paliad.project_eventsaudit 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)inProjectionService— 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: trueflag (§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 underprojects.detail.smarttimeline.*.
Tests:
internal/services/projection_service_test.go— new (live-DB integration test, skipped withoutTEST_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_idis 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.deadlinesrows. Mitigation: server-side checkWHERE 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_idafter deadlines have been calculated. Existing actuals stay (they haverule_idFK 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